com.chaosinmotion.securechat.messages.SCMessageQueue.java Source code

Java tutorial

Introduction

Here is the source code for com.chaosinmotion.securechat.messages.SCMessageQueue.java

Source

/*
 * Copyright (c) 2016. William Edward Woody
 *
 * This program 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.
 *
 * This program 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 this program. If not, see <http://www.gnu.org/licenses/>
 *
 */

package com.chaosinmotion.securechat.messages;

import android.content.Context;
import android.util.Base64;
import android.util.Log;

import com.chaosinmotion.securechat.encapsulation.SCMessageObject;
import com.chaosinmotion.securechat.network.SCNetwork;
import com.chaosinmotion.securechat.network.SCNetworkCredentials;
import com.chaosinmotion.securechat.rsa.SCRSAEncoder;
import com.chaosinmotion.securechat.rsa.SCRSAManager;
import com.chaosinmotion.securechat.rsa.SCSHA256;
import com.chaosinmotion.securechat.utils.DateUtils;
import com.chaosinmotion.securechat.utils.NotificationCenter;
import com.chaosinmotion.securechat.utils.ThreadPool;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;

import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.Socket;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;

import javax.net.ssl.KeyManager;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;

/**
 *   This is the manager which handles all of the messages that are read
 *   and written on this device.
 *
 * Created by woody on 4/11/16.
 */
public class SCMessageQueue {
    public interface SenderCompletion {
        void senderCallback(boolean success);
    }

    /************************************************************************/
    /*                                                      */
    /*   Notification Constants                                     */
    /*                                                      */
    /************************************************************************/

    public static final String NOTIFICATION_NEWMESSAGE = "NOTIFICATION_NEWMESSAGE";
    public static final String NOTIFICATION_STARTQUEUE = "NOTIFICATION_STARTQUEUE";
    public static final String NOTIFICATION_STOPQUEUE = "NOTIFICATION_STOPQUEUE";
    public static final String NOTIFICATION_ADMINMESSAGE = "NOTIFICATION_ADMINMESSAGE";

    /************************************************************************/
    /*                                                      */
    /*   Constants                                                 */
    /*                                                      */
    /************************************************************************/

    private static final long POLLRATE = 5000; // 5 seconds in ms

    /************************************************************************/
    /*                                                      */
    /*   Fields                                                 */
    /*                                                      */
    /************************************************************************/

    // Polling API fields
    private static Timer timer;
    private TimerTask timerTask;
    private boolean receiving;

    // Asynchronous API fields
    private Socket socket;
    private SCInputStream input;
    private SCOutputStream output;

    // SQLITE database of messages
    private SCMessageDatabase database;

    /************************************************************************/
    /*                                                      */
    /*   Startup/Shutdown                                       */
    /*                                                      */
    /************************************************************************/

    private static SCMessageQueue instance;

    /**
     * Get the singleton object
     * @return
     */
    public static synchronized SCMessageQueue get() {
        if (null == instance)
            instance = new SCMessageQueue();
        return instance;
    }

    private SCMessageQueue() {
    }

    /************************************************************************/
    /*                                                      */
    /*   Notification Queue                                       */
    /*                                                      */
    /************************************************************************/

    /**
     * Internal routine for processing insert messages
     */
    private void insertMessage(int sender, String name, boolean receiveFlag, final int messageID, Date timestamp,
            final byte[] message) {
        /*
         *  Determine if this is an admin message, and if it is, decrypt
         *  and send notification. Otherwise, insert into our own database,
         *  delete the message from the back end, and notify we have a
         *  new message
         */

        if (sender != 0) {
            if (database == null)
                return; // sanity check.

            /*
             *  Step 1: insert into our database
             */

            database.insertMessage(sender, name, receiveFlag, messageID, timestamp, message);

            /*
             *  Step 2: enqueue for deletion from server
             */

            SCMessageDeleteQueue.get().deleteMessage(messageID, message);

            /*
             *  Step 3: send notification of message
             */

            HashMap<String, Object> d = new HashMap<String, Object>();
            d.put("userid", sender);
            d.put("username", name);
            NotificationCenter.defaultCenter().postNotification(NOTIFICATION_NEWMESSAGE, this, d);
        } else {
            /*
             *  Admin message. Decrypt asynchronously, then send as
             *  a notification
             */

            ThreadPool.get().enqueueAsync(new Runnable() {
                @Override
                public void run() {
                    try {
                        /*
                         *  Step 1: decrypt and post notification
                         */
                        byte[] decrypt = SCRSAManager.shared().decodeData(message);
                        String json = new String(decrypt, "UTF-8");
                        JSONTokener t = new JSONTokener(json);
                        JSONObject obj = (JSONObject) t.nextValue();
                        final HashMap<String, Object> d = new HashMap<String, Object>();
                        d.put("admin", obj);
                        ThreadPool.get().enqueueMain(new Runnable() {
                            @Override
                            public void run() {
                                NotificationCenter.defaultCenter().postNotification(NOTIFICATION_ADMINMESSAGE, this,
                                        d);
                            }
                        });

                        /*
                         *  Step 2: delete from server
                         */

                        SCMessageDeleteQueue.get().deleteMessage(messageID, message);
                    } catch (UnsupportedEncodingException e) {
                    } catch (JSONException e) {
                    }
                }
            });
        }
    }

    /**
     * Internal routine for polling for messages, used if we cannot open
     * a socket for notifications.
     */
    private void pollForMessages() {
        if (receiving)
            return;
        receiving = true;

        /*
         *  Poll messages
         */

        JSONObject obj = new JSONObject();
        try {
            obj.put("deviceid", SCRSAManager.shared().getDeviceUUID());
        } catch (JSONException e) {
            // Should never happen
        }
        SCNetwork.get().request("messages/getmessages", obj, false, this, new SCNetwork.ResponseInterface() {
            @Override
            public void responseResult(SCNetwork.Response response) {
                if (response.isSuccess()) {
                    JSONArray a = response.getData().optJSONArray("messages");
                    int i, len = a.length();
                    for (i = 0; i < len; ++i) {
                        JSONObject d = a.optJSONObject(i);

                        int messageID = d.optInt("messageID");
                        int senderID = d.optInt("senderID");
                        String senderName = d.optString("senderName");
                        String received = d.optString("received");
                        Date timestamp = DateUtils.parseServerDate(received);
                        boolean toFlag = d.optBoolean("toflag");
                        byte[] data = Base64.decode(d.optString("message"), Base64.DEFAULT);

                        insertMessage(senderID, senderName, toFlag, messageID, timestamp, data);
                    }
                }
                receiving = false;
            }
        });
    }

    /**
     * Start polling. Reason is for debugging only
     */

    private void startPolling(String reason) {
        Log.d("SecureChat", "Polling because " + reason);

        if (timer == null) {
            timer = new Timer();
        }
        timerTask = new TimerTask() {
            @Override
            public void run() {
                pollForMessages();
            }
        };

        timer.schedule(timerTask, 0, POLLRATE);
    }

    /************************************************************************/
    /*                                                      */
    /*   Notification Stream                                       */
    /*                                                      */
    /************************************************************************/

    /**
     *   Notification stream login phase two: this is sent in response to a
     *   token request; this sends the username/password pair for logging in,
     *   as well as the device on this connection that is listening for
     *   messages.
     */

    private void loginPhaseTwo(String token) {
        String username = SCRSAManager.shared().getUsername();
        String password = SCRSAManager.shared().getPasswordHash();
        SCNetworkCredentials creds = new SCNetworkCredentials(username, password);

        JSONObject d = new JSONObject();
        try {
            d.put("cmd", "login");
            d.put("deviceid", SCRSAManager.shared().getDeviceUUID());
            d.put("username", creds.getUsername());
            d.put("password", creds.hashPasswordWithToken(token));
            final byte[] data = d.toString().getBytes("UTF-8");

            ThreadPool.get().enqueueAsync(new Runnable() {
                @Override
                public void run() {
                    try {
                        output.writeData(data);
                    } catch (IOException e) {
                        // Should never happen.
                    }
                }
            });
        } catch (JSONException e) {
            // Should never happen
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
    }

    /**
     *  Process a data packet from the back end notification service. A data
     *  packet response form the back end has the format:
     *
     *  first byte
     *  0x20        Message
     *  0x21        Token response
     *  0x22        Login failure
     *
     *  Note login success is implicit; if login worked, we start receiving
     *  message notifications, starting with the backlog of stored messages
     *  waiting for us
     */

    private void processDataPacket(byte[] data) {
        if (data.length == 0)
            return;

        if (data[0] == 0x20) {
            /*
             *  Process received message.
             */

            ByteArrayInputStream bais = new ByteArrayInputStream(data, 1, data.length - 1);
            DataInputStream dis = new DataInputStream(bais);

            try {
                boolean toflag = dis.readBoolean();
                int messageID = dis.readInt();
                int senderID = dis.readInt();
                String ts = dis.readUTF();
                String senderName = dis.readUTF();
                int messagelen = dis.readInt();
                byte[] message = new byte[messagelen];
                dis.read(message);

                dis.close();

                insertMessage(senderID, senderName, toflag, messageID, DateUtils.parseServerDate(ts), message);

            } catch (IOException e) {
                e.printStackTrace();
            }
        } else if (data[0] == 0x21) {
            /*
             * Received token; rest is string
             */

            try {
                String token = new String(data, 1, data.length - 1, "UTF-8");
                loginPhaseTwo(token);
            } catch (UnsupportedEncodingException e) {
                // SHould never happen
            }
        } else if (data[0] == 0x22) {
            /*
             *  Login failure. Close connection and start polling
             */

            closeConnection();
            startPolling("Login failure");
        }
    }

    /**
     * Close network connection to notification stream
     */
    private synchronized void closeConnection() {
        final SCInputStream is = input;
        final SCOutputStream os = output;
        final Socket s = socket;
        socket = null;
        input = null;
        output = null;

        /*
         *  If this is called on the main thread and the socket is open, this
         *  will throw an exception. So make sure this is on a background
         *  thread when we close, in case network activity needs to be
         *  performed to shut down the connection.
         */
        ThreadPool.get().enqueueAsync(new Runnable() {
            @Override
            public void run() {
                try {
                    input.close();
                } catch (IOException e) {
                }

                try {
                    output.close();
                } catch (IOException e) {
                }

                try {
                    socket.close();
                } catch (IOException e) {
                }
            }
        });
    }

    /**
     * The back end is advertising an endpoint we can connect to for
     * asynchronous networking. Attempt to open a connection. Note that
     * this must be kicked off in a background thread.
     */

    private void openConnection(String host, int port, boolean ssl)
            throws NoSuchAlgorithmException, KeyManagementException, IOException, JSONException {
        if (ssl) {
            TrustManager acceptAllTrustManager = new X509TrustManager() {
                @Override
                public void checkClientTrusted(X509Certificate[] chain, String authType)
                        throws CertificateException {
                }

                @Override
                public void checkServerTrusted(X509Certificate[] chain, String authType)
                        throws CertificateException {
                }

                @Override
                public X509Certificate[] getAcceptedIssuers() {
                    return new X509Certificate[0];
                }
            };
            TrustManager[] tm = new TrustManager[] { acceptAllTrustManager };
            SSLContext context = SSLContext.getInstance("TLS");
            context.init(new KeyManager[0], tm, new SecureRandom());

            SSLSocketFactory factory = context.getSocketFactory();

            socket = factory.createSocket(host, port);
        } else {
            socket = new Socket(host, port);
        }

        /*
         *  Kick off an output stream
         */

        output = new SCOutputStream(socket.getOutputStream());

        /*
         *  Kick off a thread to process the input stream
         */

        Thread thread = new Thread() {
            @Override
            public void run() {
                try {
                    input = new SCInputStream(socket.getInputStream()) {
                        @Override
                        public void processPacket(byte[] data) {
                            processDataPacket(data);
                        }
                    };
                    input.processStream();
                    input.close();

                    /*
                     *  When the input closes, we simply quit the thread.
                     *  TODO: I'm not sure if that's the correct answer.
                     */
                } catch (final Exception ex) {
                    ThreadPool.get().enqueueMain(new Runnable() {
                        @Override
                        public void run() {
                            startPolling("Unknown exception " + ex.getMessage());
                            Log.d("SecureChat", "Exception while opening socket", ex);
                        }
                    });
                }
            }
        };
        thread.start();

        /*
         *   Now the first packet we need to send to the writer (and our
         *   output stream will cache this) is a JSON request to log in.
         *
         *   On the off chance logging in fails, the back end will simply
         *   close the connection.
         *
         *   Because there is no one-to-one (in theory) of data sent and
         *   received, we drive this through a state machine.
         */

        JSONObject obj = new JSONObject();
        obj.put("cmd", "token");
        byte[] data = obj.toString().getBytes("UTF-8");
        output.writeData(data);
    }

    /**
     * Ask the back end if we can connect to a separate port for async
     * message handling; if not, start background polling
     */
    private void startNetworkQueue() {
        SCNetwork.get().request("messages/notifications", null, false, this, new SCNetwork.ResponseInterface() {
            @Override
            public void responseResult(SCNetwork.Response response) {
                /*
                 *  If we get here but we're already running, bail.
                 */

                if ((socket != null) || (timerTask != null))
                    return;

                /*
                 *  Success or error?
                 */

                if (response.isSuccess()) {
                    final String host = response.getData().optString("host");
                    final int port = response.getData().optInt("port");
                    final boolean ssl = response.getData().optBoolean("ssl");

                    ThreadPool.get().enqueueAsync(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                openConnection(host, port, ssl);
                            } catch (final Exception ex) {
                                ThreadPool.get().enqueueMain(new Runnable() {
                                    @Override
                                    public void run() {
                                        startPolling("Unknown exception " + ex.getMessage());
                                        Log.d("SecureChat", "Exception while opening socket", ex);
                                    }
                                });
                            }
                        }
                    });
                } else {
                    startPolling("Server responsed unavailable");
                }
            }
        });
    }

    /************************************************************************/
    /*                                                      */
    /*   External Methods                                       */
    /*                                                      */
    /************************************************************************/

    /**
     *   Start the message queue. This makes sure the messages are loaded from
     *   memory and starts either the periodic timer or the direct network
     *   connection to the server to send and receive messages
     */

    public void startQueue(Context ctx) {
        if (!SCRSAManager.shared().canStartServices())
            return;
        if (database != null)
            return; // already started

        /*
         *  Open database
         */

        if (database == null) {
            database = new SCMessageDatabase();
            database.openDatabase(ctx);
        }

        /*
         *  Start network queue and send notification
         */

        startNetworkQueue();

        NotificationCenter.defaultCenter().postNotification(NOTIFICATION_STARTQUEUE, this);
    }

    /**
     * Stop message queue
     */

    public void stopQueue() {
        if (database == null)
            return; // Already stopped
        NotificationCenter.defaultCenter().postNotification(NOTIFICATION_STOPQUEUE, this);

        /*
         *  Close connection or stop polling
         */

        if (null != socket) {
            closeConnection();
        }
        if (null != timerTask) {
            timerTask.cancel();
            timerTask = null;
        }

        /*
         *  Close database
         */

        if (database != null) {
            database.closeDatabase();
            database = null;
        }
    }

    /**
     * Clear queue
     */

    public void clearQueue(Context ctx) {
        stopQueue();
        SCMessageDatabase.removeDatabase(ctx);
    }

    /************************************************************************/
    /*                                                      */
    /*   Database Access                                          */
    /*                                                      */
    /************************************************************************/

    public List<SCMessageDatabase.Sender> getSenders() {
        if (database == null)
            return new ArrayList<SCMessageDatabase.Sender>(); // empty list.
        return database.getSenders();
    }

    public int getMessagesForSender(int senderID) {
        if (database == null)
            return 0;
        return database.messageCountForSender(senderID);
    }

    public List<SCMessageDatabase.Message> getMessagesInRange(int senderID, int location, int length) {
        if (database == null)
            return new ArrayList<SCMessageDatabase.Message>();
        return database.messages(senderID, location, length);
    }

    public boolean deleteSender(int senderID) {
        if (database == null)
            return false;
        return database.deleteSenderForIdent(senderID);
    }

    public boolean deleteMessage(int messageID) {
        if (database == null)
            return false;
        return database.deleteMessageForIdent(messageID);
    }

    /************************************************************************/
    /*                                                      */
    /*   Sending Messages                                       */
    /*                                                      */
    /************************************************************************/

    private void encodeMessages(byte[] cdata, final String sender, final int senderID,
            List<SCDeviceCache.Device> sarray, List<SCDeviceCache.Device> marray, final SenderCompletion callback)
            throws UnsupportedEncodingException {
        /*
         *  Calculate message checksum
         */

        String checksum = SCSHA256.sha256(cdata);

        /*
         *  Build the encoding list to encode all sent messages.
         */

        JSONArray messages = new JSONArray();

        // Devices we're sending to
        for (SCDeviceCache.Device d : sarray) {
            SCRSAEncoder encoder = d.getPublicKey();
            byte[] encoded = new byte[0];
            try {
                encoded = encoder.encodeData(cdata);
                String message = Base64.encodeToString(encoded, Base64.DEFAULT);
                JSONObject ds = new JSONObject();
                ds.put("checksum", checksum);
                ds.put("message", message);
                ds.put("deviceid", d.getDeviceID());
                messages.put(ds);
            } catch (Exception e) {
                Log.d("SecureChat", "Exception", e);
                // Should not happen; only if there is a constant error above
            }
        }

        // My devices
        for (SCDeviceCache.Device d : marray) {
            if (d.getDeviceID().equals(SCRSAManager.shared().getDeviceUUID())) {
                // Skip me; we put me last
                continue;
            }

            SCRSAEncoder encoder = d.getPublicKey();
            byte[] encoded = new byte[0];
            try {
                encoded = encoder.encodeData(cdata);
                String message = Base64.encodeToString(encoded, Base64.DEFAULT);
                JSONObject ds = new JSONObject();
                ds.put("checksum", checksum);
                ds.put("message", message);
                ds.put("deviceid", d.getDeviceID());
                ds.put("destuser", senderID);
                messages.put(ds);
            } catch (Exception e) {
                Log.d("SecureChat", "Exception", e);
                // Should not happen; only if there is a constant error above
            }
        }

        // Encode for myself. This is kind of a kludge; we need
        // the message ID from the back end to assure proper sorting.
        // But we only get that if this is the last message in the
        // array of messages. (See SendMessages.java.)
        SCRSAEncoder encoder = new SCRSAEncoder(SCRSAManager.shared().getPublicKey());
        byte[] encoded = new byte[0];
        try {
            encoded = encoder.encodeData(cdata);
            String message = Base64.encodeToString(encoded, Base64.DEFAULT);
            JSONObject ds = new JSONObject();
            ds.put("checksum", checksum);
            ds.put("message", message);
            ds.put("deviceid", SCRSAManager.shared().getDeviceUUID());
            ds.put("destuser", senderID);
            messages.put(ds);
        } catch (Exception e) {
            Log.d("SecureChat", "Exception", e);
            // Should not happen; only if there is a constant error above
        }

        /*
         *  Send all messages to the back end.
         */

        JSONObject params = new JSONObject();
        try {
            params.put("messages", messages);
        } catch (JSONException ex) {
            // Should never happen
        }

        final byte[] encodedData = encoded;
        SCNetwork.get().request("messages/sendmessages", params, false, this, new SCNetwork.ResponseInterface() {
            @Override
            public void responseResult(SCNetwork.Response response) {
                if (response.isSuccess()) {
                    int messageID = response.getData().optInt("messageid");

                    // Insert sent message into myself. This is so we
                    // immediately see the sent message right away.
                    // Note we may have a race condition but we don't
                    // care; the messageID will screen out duplicates.
                    insertMessage(senderID, sender, true, messageID, new Date(), encodedData);
                }

                callback.senderCallback(response.isSuccess());
            }
        });
    }

    /**
     *   Message send. This also handles encryption and enqueuing into our own
     *   internal queue. Note that internally we cache senders and the devices
     *   on which messages are sent for a user, refreshing every 5 minutes.
     */

    public void sendMessage(final SCMessageObject clearText, final String sender, final SenderCompletion callback) {
        /*
         *  This gets the devices for the sender, for me, and then
         *  runs the encoding process on a background thread.
         */
        SCDeviceCache.get().devicesForSender(sender, new SCDeviceCache.DeviceCallback() {
            @Override
            public void foundDevices(final int userID, final List<SCDeviceCache.Device> sarray) {
                if (sarray == null) {
                    callback.senderCallback(false);
                }

                /*
                 *  Get our devices as well.
                 */

                String me = SCRSAManager.shared().getUsername();
                SCDeviceCache.get().devicesForSender(me, new SCDeviceCache.DeviceCallback() {
                    @Override
                    public void foundDevices(int meID, final List<SCDeviceCache.Device> marray) {
                        if (marray == null) {
                            callback.senderCallback(false);
                        }

                        /*
                         *  Now encode the message and send to the back end. Note
                         *  that we run this on a background thread.
                         */

                        ThreadPool.get().enqueueAsync(new Runnable() {
                            @Override
                            public void run() {
                                try {
                                    encodeMessages(clearText.dataFromMessage(), sender, userID, sarray, marray,
                                            callback);
                                } catch (UnsupportedEncodingException e) {
                                    callback.senderCallback(false);
                                }
                            }
                        });
                    }
                });
            }
        });
    }
}