diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c7783cd..7f6f5fa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,7 +42,7 @@ android:value=".activities.MainActivity" /> - + diff --git a/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala b/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala index 159085d..56adb42 100644 --- a/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala +++ b/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala @@ -10,10 +10,8 @@ import android.view._ import android.widget.AdapterView.OnItemClickListener import android.widget._ import com.nutomic.ensichat.R -import com.nutomic.ensichat.bluetooth.ChatService -import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener import com.nutomic.ensichat.protocol.messages.{Message, RequestAddContact, ResultAddContact} -import com.nutomic.ensichat.protocol.{Address, Crypto} +import com.nutomic.ensichat.protocol.{Address, ChatService, Crypto} import com.nutomic.ensichat.util.{DevicesAdapter, IdenticonGenerator} import scala.collection.SortedSet @@ -23,14 +21,14 @@ import scala.collection.SortedSet * * Adding a contact requires confirmation on both sides. */ -class AddContactsActivity extends EnsiChatActivity with ChatService.OnNearbyContactsChangedListener - with OnItemClickListener with OnMessageReceivedListener { +class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnectionsChangedListener + with OnItemClickListener with ChatService.OnMessageReceivedListener { private val Tag = "AddContactsActivity" private lazy val Adapter = new DevicesAdapter(this) - private lazy val Database = service.database + private lazy val Database = service.Database private lazy val Crypto = new Crypto(this) @@ -46,8 +44,7 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnNearbyCont * @param localConfirmed If true, the local user has accepted adding the contact. * @param remoteConfirmed If true, the remote contact has accepted adding this device as contact. */ - private class AddContactInfo(val localConfirmed: Boolean, val remoteConfirmed: Boolean) { - } + private class AddContactInfo(val localConfirmed: Boolean, val remoteConfirmed: Boolean) /** * Initializes layout, registers connection and message listeners. @@ -71,7 +68,7 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnNearbyCont /** * Displays newly connected devices in the list. */ - override def onNearbyContactsChanged(devices: Set[Address]): Unit = { + override def onConnectionsChanged(devices: Set[Address]): Unit = { runOnUiThread(new Runnable { override def run(): Unit = { Adapter.clear() diff --git a/app/src/main/scala/com/nutomic/ensichat/activities/EnsiChatActivity.scala b/app/src/main/scala/com/nutomic/ensichat/activities/EnsiChatActivity.scala index 499051f..867192f 100644 --- a/app/src/main/scala/com/nutomic/ensichat/activities/EnsiChatActivity.scala +++ b/app/src/main/scala/com/nutomic/ensichat/activities/EnsiChatActivity.scala @@ -3,7 +3,8 @@ package com.nutomic.ensichat.activities import android.app.Activity import android.content.{ComponentName, Context, Intent, ServiceConnection} import android.os.{Bundle, IBinder} -import com.nutomic.ensichat.bluetooth.{ChatService, ChatServiceBinder} +import com.nutomic.ensichat.protocol.ChatService +import com.nutomic.ensichat.protocol.ChatServiceBinder /** * Connects to [[ChatService]] and provides access to it. diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/BluetoothInterface.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/BluetoothInterface.scala new file mode 100644 index 0000000..9763477 --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/BluetoothInterface.scala @@ -0,0 +1,198 @@ +package com.nutomic.ensichat.bluetooth + +import java.util.UUID + +import android.bluetooth.{BluetoothAdapter, BluetoothDevice, BluetoothSocket} +import android.content.{BroadcastReceiver, Context, Intent, IntentFilter} +import android.preference.PreferenceManager +import android.util.Log +import com.google.common.collect.HashBiMap +import com.nutomic.ensichat.R +import com.nutomic.ensichat.protocol.ChatService.InterfaceHandler +import com.nutomic.ensichat.protocol._ +import com.nutomic.ensichat.protocol.messages.{ConnectionInfo, Message} + +import scala.collection.immutable.HashMap + +object BluetoothInterface { + + /** + * Bluetooth service UUID version 5, created with namespace URL and "ensichat.nutomic.com". + */ + val AppUuid: UUID = UUID.fromString("8ed52b7a-4501-5348-b054-3d94d004656e") + +} + +/** + * Handles all Bluetooth connectivity. + */ +class BluetoothInterface(Service: ChatService, Crypto: Crypto) extends InterfaceHandler { + + private val Tag = "BluetoothInterface" + + private lazy val BtAdapter = BluetoothAdapter.getDefaultAdapter + + private var devices = new HashMap[Device.ID, Device]() + + private var connections = new HashMap[Device.ID, TransferThread]() + + private lazy val ListenThread = + new ListenThread(Service.getString(R.string.app_name), BtAdapter, onConnectionOpened) + + private var cancelDiscovery = false + + private var discovered = Set[Device]() + + private val AddressDeviceMap = HashBiMap.create[Address, Device.ID]() + + /** + * Initializes and starts discovery and listening. + */ + override def create(): Unit = { + Service.registerReceiver(DeviceDiscoveredReceiver, + new IntentFilter(BluetoothDevice.ACTION_FOUND)) + Service.registerReceiver(BluetoothStateReceiver, + new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) + Service.registerReceiver(DiscoveryFinishedReceiver, + new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)) + startBluetoothConnections() + } + + /** + * Stops discovery and listening. + */ + override def destroy(): Unit = { + ListenThread.cancel() + cancelDiscovery = true + Service.unregisterReceiver(DeviceDiscoveredReceiver) + Service.unregisterReceiver(BluetoothStateReceiver) + Service.unregisterReceiver(DiscoveryFinishedReceiver) + } + + /** + * Starts discovery and listening. + */ + private def startBluetoothConnections(): Unit = { + ListenThread.start() + cancelDiscovery = false + discover() + } + + /** + * Runs discovery as long as [[cancelDiscovery]] is false. + */ + def discover(): Unit = { + if (cancelDiscovery) + return + + if (!BtAdapter.isDiscovering) { + Log.v(Tag, "Starting discovery") + BtAdapter.startDiscovery() + } + + val scanInterval = PreferenceManager.getDefaultSharedPreferences(Service) + .getString("scan_interval_seconds", "15").toInt * 1000 + Service.MainHandler.postDelayed(new Runnable { + override def run(): Unit = discover() + }, scanInterval) + } + + /** + * Stores newly discovered devices. + */ + private val DeviceDiscoveredReceiver = new BroadcastReceiver() { + override def onReceive(context: Context, intent: Intent) { + discovered += new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false) + } + } + + /** + * Initiates connection to discovered devices. + */ + private val DiscoveryFinishedReceiver = new BroadcastReceiver() { + override def onReceive(context: Context, intent: Intent): Unit = { + discovered.filterNot(d => connections.keySet.contains(d.Id)) + .foreach { d => + new ConnectThread(d, onConnectionOpened).start() + devices += (d.Id -> d) + } + discovered = Set[Device]() + } + } + + /** + * Starts or stops listening and discovery based on bluetooth state. + */ + private val BluetoothStateReceiver = new BroadcastReceiver { + override def onReceive(context: Context, intent: Intent): Unit = { + intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) match { + case BluetoothAdapter.STATE_ON => + if (Crypto.localKeysExist) + startBluetoothConnections() + case BluetoothAdapter.STATE_TURNING_OFF => + Log.i(Tag, "Bluetooth disabled, stopping connectivity") + ListenThread.cancel() + cancelDiscovery = true + connections.foreach(_._2.close()) + case _ => + } + } + } + + + /** + * Initiates data transfer with device. + */ + def onConnectionOpened(device: Device, socket: BluetoothSocket): Unit = { + devices += (device.Id -> device) + connections += (device.Id -> + new TransferThread(device, socket, this, Crypto, onReceiveMessage)) + connections(device.Id).start() + } + + /** + * Removes device from active connections. + */ + def onConnectionClosed(device: Device, socket: BluetoothSocket): Unit = { + devices -= device.Id + connections -= device.Id + val inv = AddressDeviceMap.inverse() + Service.callConnectionListeners() + inv.remove(device.Id) + } + + /** + * Passes incoming messages to [[ChatService]]. + * + * Also uses [[ConnectionInfo]] message to determine mapping from [[Device.ID]] to [[Address]]. + * + * @param msg The message that was received. + * @param device Device that sent the message. + */ + private def onReceiveMessage(msg: Message, device: Device.ID): Unit = msg.Body match { + case info: ConnectionInfo => + val sender = Service.onConnectionOpened(msg) + sender match { + case Some(s) => + AddressDeviceMap.put(Crypto.calculateAddress(info.key), device) + Service.callConnectionListeners() + case None => + } + case _ => + Service.onMessageReceived(msg) + } + + /** + * Sends the message to the target address specified in the message header. + */ + override def send(msg: Message): Unit = { + connections.apply(AddressDeviceMap.get(msg.Header.Target)).send(msg) + } + + /** + * Returns all active Bluetooth connections. + */ + def getConnections: Set[Address] = + connections.map(x => AddressDeviceMap.inverse().get(x._1)).toSet + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala deleted file mode 100644 index 28022ce..0000000 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala +++ /dev/null @@ -1,333 +0,0 @@ -package com.nutomic.ensichat.bluetooth - -import java.util.UUID - -import android.app.Service -import android.bluetooth.{BluetoothAdapter, BluetoothDevice, BluetoothSocket} -import android.content.{BroadcastReceiver, Context, Intent, IntentFilter} -import android.os.Handler -import android.preference.PreferenceManager -import android.util.Log -import com.google.common.collect.HashBiMap -import com.nutomic.ensichat.R -import com.nutomic.ensichat.bluetooth.ChatService.{OnMessageReceivedListener, OnNearbyContactsChangedListener} -import com.nutomic.ensichat.protocol._ -import com.nutomic.ensichat.protocol.messages.{ConnectionInfo, Message, MessageBody, MessageHeader} -import com.nutomic.ensichat.util.Database - -import scala.collection.SortedSet -import scala.collection.immutable.{HashMap, HashSet, TreeSet} -import scala.concurrent.Future -import scala.concurrent.ExecutionContext.Implicits.global -import scala.ref.WeakReference - -object ChatService { - - /** - * Bluetooth service UUID version 5, created with namespace URL and "ensichat.nutomic.com". - */ - val appUuid: UUID = UUID.fromString("8ed52b7a-4501-5348-b054-3d94d004656e") - - /** - * Used with [[ChatService.registerConnectionListener]], called when a bluetooth device - * connects or disconnects - */ - trait OnNearbyContactsChangedListener { - def onNearbyContactsChanged(devices: Set[Address]): Unit - } - - trait OnMessageReceivedListener { - def onMessageReceived(messages: SortedSet[Message]): Unit - } - -} - -/** - * Handles all Bluetooth connectivity. - */ -class ChatService extends Service { - - private val Tag = "ChatService" - - private val Binder = new ChatServiceBinder(this) - - private var bluetoothAdapter: BluetoothAdapter = _ - - /** - * For this (and [[messageListeners]], functions would be useful instead of instances, - * but on a Nexus S (Android 4.1.2), these functions are garbage collected even when - * referenced. - */ - private var connectionListeners = new HashSet[WeakReference[OnNearbyContactsChangedListener]]() - - private var messageListeners = Set[WeakReference[OnMessageReceivedListener]]() - - private var devices = new HashMap[Device.ID, Device]() - - private var connections = new HashMap[Device.ID, TransferThread]() - - private var ListenThread: ListenThread = _ - - private var cancelDiscovery = false - - private val MainHandler = new Handler() - - private lazy val Database = new Database(this) - - private lazy val Crypto = new Crypto(this) - - private var discovered = Set[Device]() - - private val AddressDeviceMap = HashBiMap.create[Address, Device.ID]() - - /** - * Sets up bluetooth connectivity. - */ - override def onCreate(): Unit = { - super.onCreate() - - bluetoothAdapter = BluetoothAdapter.getDefaultAdapter - - registerReceiver(DeviceDiscoveredReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND)) - registerReceiver(BluetoothStateReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) - registerReceiver(DiscoveryFinishedReceiver, - new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)) - - Future { - Crypto.generateLocalKeys() - startBluetoothConnections() - Log.i(Tag, "Service started, address is " + Crypto.getLocalAddress) - } - } - - override def onStartCommand(intent: Intent, flags: Int, startId: Int) = Service.START_STICKY - - override def onBind(intent: Intent) = Binder - - /** - * Stops discovery, listening and unregisters receivers. - */ - override def onDestroy(): Unit = { - super.onDestroy() - ListenThread.cancel() - cancelDiscovery = true - unregisterReceiver(DeviceDiscoveredReceiver) - unregisterReceiver(BluetoothStateReceiver) - unregisterReceiver(DiscoveryFinishedReceiver) - } - - /** - * Stops any current discovery, then starts a new one, recursively until service is stopped. - */ - def discover(): Unit = { - if (cancelDiscovery) - return - - if (!bluetoothAdapter.isDiscovering) { - Log.v(Tag, "Starting discovery") - bluetoothAdapter.startDiscovery() - } - - val scanInterval = PreferenceManager.getDefaultSharedPreferences(this) - .getString("scan_interval_seconds", "15").toInt * 1000 - MainHandler.postDelayed(new Runnable { - override def run(): Unit = discover() - }, scanInterval) - } - - /** - * Receives newly discovered devices and connects to them. - */ - private val DeviceDiscoveredReceiver = new BroadcastReceiver() { - override def onReceive(context: Context, intent: Intent) { - discovered += new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false) - } - } - - /** - * Iniates the actual connection to discovered devices. - */ - private val DiscoveryFinishedReceiver = new BroadcastReceiver() { - override def onReceive(context: Context, intent: Intent): Unit = { - discovered.filterNot(d => connections.keySet.contains(d.Id)) - .foreach { d => - new ConnectThread(d, onConnectionChanged).start() - devices += (d.Id -> d) - } - discovered = Set[Device]() - } - } - - /** - * Starts or stops listening and discovery based on bluetooth state. - */ - private val BluetoothStateReceiver = new BroadcastReceiver { - override def onReceive(context: Context, intent: Intent): Unit = { - intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) match { - case BluetoothAdapter.STATE_ON => - if (Crypto.localKeysExist) - startBluetoothConnections() - case BluetoothAdapter.STATE_TURNING_OFF => - connections.foreach(d => d._2.close()) - case BluetoothAdapter.STATE_OFF => - Log.i(Tag, "Bluetooth disabled, stopping listening and discovery") - if (ListenThread != null) { - ListenThread.cancel() - } - cancelDiscovery = true - case _ => - } - } - } - - /** - * Starts to listen for incoming connections, and starts regular active discovery. - */ - private def startBluetoothConnections(): Unit = { - cancelDiscovery = false - discover() - ListenThread = - new ListenThread(getString(R.string.app_name), bluetoothAdapter, onConnectionChanged) - ListenThread.start() - } - - /** - * Registers a listener that is called whenever a new device is connected. - */ - def registerConnectionListener(listener: OnNearbyContactsChangedListener): Unit = { - connectionListeners += new WeakReference[OnNearbyContactsChangedListener](listener) - nearbyContactsChanged(listener) - } - - /** - * Called when a Bluetooth device is connected. - * - * Adds the device to [[connections]], notifies all [[connectionListeners]], sends DeviceInfoMessage. - * - * @param device The updated device info for the remote device. - * @param socket A socket for data transfer if device.connected is true, otherwise null. - */ - def onConnectionChanged(device: Device, socket: BluetoothSocket): Unit = { - devices += (device.Id -> device) - - if (device.Connected && !connections.keySet.contains(device.Id)) { - connections += (device.Id -> - new TransferThread(device, socket, this, Crypto, onReceiveMessage)) - connections(device.Id).start() - } else { - Log.i(Tag, device + " has disconnected") - AddressDeviceMap.inverse().remove(device.Id) - } - } - - /** - * Calls listener with [[devices]] (converting [[Device.ID]]s to [[Address]]es. - */ - def nearbyContactsChanged(listener: OnNearbyContactsChangedListener) = { - listener.onNearbyContactsChanged( - devices.keySet.map(d => AddressDeviceMap.inverse().get(d)).filter(_ != null)) - } - - /** - * Sends a new message to the given target address. - */ - def sendTo(target: Address, body: MessageBody): Unit = { - if (!AddressDeviceMap.containsKey(target)) { - Log.w(Tag, "Receiver " + target + " is not connected, ignoring message") - return - } - - val header = new MessageHeader(body.Type, MessageHeader.DefaultHopLimit, - Crypto.getLocalAddress, target, 0, 0) - - val msg = new Message(header, body) - val encrypted = Crypto.encrypt(Crypto.sign(msg)) - connections.apply(AddressDeviceMap.get(target)).send(encrypted) - Database.addMessage(msg) - callMessageReceivedListeners(msg) - } - - /** - * Saves the message to database and sends it to registered listeners. - * - * If you want to send a new message, use [[sendTo]]. - * - * Messages must always be sent between local device and a contact. - * - * NOTE: Messages sent from the local node using [[sendTo]] are also passed through this method. - */ - private def onReceiveMessage(message: Message, device: Device.ID): Unit = { - assert(message.Header.Origin != Crypto.getLocalAddress) - - message.Body match { - case info: ConnectionInfo => - if (message.Header.Origin == Crypto.getLocalAddress) - return - onNeighborConnected(info, device) - case _ => - val decrypted = Crypto.decrypt(message) - if (!Crypto.verify(decrypted)) { - Log.i(Tag, "Dropping message with invalid signature from " + message.Header.Origin) - return - } - - callMessageReceivedListeners(decrypted) - Database.addMessage(decrypted) - } - } - - /** - * Calls all [[OnMessageReceivedListener]]s with the new message. - */ - private def callMessageReceivedListeners(message: Message): Unit = { - MainHandler.post(new Runnable { - override def run(): Unit = { - messageListeners.foreach(l => - if (l.get != null) - l.apply().onMessageReceived(new TreeSet[Message]()(Message.Ordering) + message) - else - messageListeners -= l) - } - }) - } - - /** - * Called when a [[ConnectionInfo]] message from a new neighbor is received. - */ - private def onNeighborConnected(info: ConnectionInfo, device: Device.ID): Unit = { - val sender = Crypto.calculateAddress(info.key) - if (sender == Address.Broadcast || sender == Address.Null) { - Log.i(Tag, "Received ConnectionInfo message with invalid sender " + sender + ", ignoring") - return - } - - if (!Crypto.havePublicKey(sender)) { - Crypto.addPublicKey(sender, info.key) - Log.i(Tag, "Added public key for new device " + sender.toString) - } - - AddressDeviceMap.put(sender, device) - Log.i(Tag, "Node " + sender + " connected as " + device) - - connectionListeners.foreach(l => l.get match { - case Some(c) => nearbyContactsChanged(c) - case None => connectionListeners -= l - }) - - } - - /** - * Registers a listener that is called whenever a new message is sent or received. - */ - def registerMessageListener(listener: OnMessageReceivedListener): Unit = { - messageListeners += new WeakReference[OnMessageReceivedListener](listener) - } - - /** - * Returns the unique bluetooth address of the local device. - */ - private def localDeviceId = new Device.ID(bluetoothAdapter.getAddress) - - def database = Database - -} diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ConnectThread.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ConnectThread.scala index 2727e5e..98a21e5 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ConnectThread.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ConnectThread.scala @@ -14,7 +14,7 @@ class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Un private val Tag = "ConnectThread" private val Socket: BluetoothSocket = - device.bluetoothDevice.createInsecureRfcommSocketToServiceRecord(ChatService.appUuid) + device.bluetoothDevice.createInsecureRfcommSocketToServiceRecord(BluetoothInterface.AppUuid) override def run(): Unit = { Log.i(Tag, "Connecting to " + device.toString) diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ListenThread.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ListenThread.scala index d155f48..2d81850 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ListenThread.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ListenThread.scala @@ -17,7 +17,7 @@ class ListenThread(name: String, adapter: BluetoothAdapter, private val ServerSocket: BluetoothServerSocket = try { - adapter.listenUsingInsecureRfcommWithServiceRecord(name, ChatService.appUuid) + adapter.listenUsingInsecureRfcommWithServiceRecord(name, BluetoothInterface.AppUuid) } catch { case e: IOException => Log.e(Tag, "Failed to create listener", e) diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala index 6ad37f3..c507449 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala @@ -16,8 +16,8 @@ import com.nutomic.ensichat.protocol.messages.{ConnectionInfo, Message, MessageH * @param socket An open socket to the given device. * @param onReceive Called when a message was received from the other device. */ -class TransferThread(device: Device, socket: BluetoothSocket, service: ChatService, - crypto: Crypto, onReceive: (Message, Device.ID) => Unit) +class TransferThread(device: Device, socket: BluetoothSocket, Handler: BluetoothInterface, + Crypto: Crypto, onReceive: (Message, Device.ID) => Unit) extends Thread { private val Tag: String = "TransferThread" @@ -43,8 +43,8 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi override def run(): Unit = { Log.i(Tag, "Starting data transfer with " + device.toString) - send(crypto.sign(new Message(new MessageHeader(ConnectionInfo.Type, ConnectionInfo.HopLimit, - Address.Null, Address.Null, 0, 0), new ConnectionInfo(crypto.getLocalPublicKey)))) + send(Crypto.sign(new Message(new MessageHeader(ConnectionInfo.Type, ConnectionInfo.HopLimit, + Address.Null, Address.Null, 0, 0), new ConnectionInfo(Crypto.getLocalPublicKey)))) while (socket.isConnected) { try { @@ -62,8 +62,7 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi return } } - service.onConnectionChanged(new Device(device.bluetoothDevice, false), null) - Log.i(Tag, "Neighbor " + device + " has disconnected") + close() } def send(msg: Message): Unit = { @@ -81,7 +80,7 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi } catch { case e: IOException => Log.e(Tag, "Failed to close socket", e); } finally { - service.onConnectionChanged(new Device(device.bluetoothDevice, false), null) + Handler.onConnectionClosed(new Device(device.bluetoothDevice, false), null) } } diff --git a/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala b/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala index 6b8a5d0..00ca4be 100644 --- a/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala +++ b/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala @@ -9,11 +9,10 @@ import android.widget.TextView.OnEditorActionListener import android.widget._ import com.nutomic.ensichat.R import com.nutomic.ensichat.activities.EnsiChatActivity -import com.nutomic.ensichat.bluetooth.ChatService -import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener -import com.nutomic.ensichat.protocol.Address +import com.nutomic.ensichat.protocol.{ChatService, Address} +import com.nutomic.ensichat.protocol.ChatService.OnMessageReceivedListener import com.nutomic.ensichat.protocol.messages.{Message, Text} -import com.nutomic.ensichat.util.MessagesAdapter +import com.nutomic.ensichat.util.{Database, MessagesAdapter} import scala.collection.SortedSet @@ -53,7 +52,7 @@ class ChatFragment extends ListFragment with OnClickListener // Read local device ID from service, adapter = new MessagesAdapter(getActivity, address) chatService.registerMessageListener(ChatFragment.this) - onMessageReceived(chatService.database.getMessages(address, 15)) + onMessageReceived(chatService.Database.getMessages(address, 15)) if (listView != null) { listView.setAdapter(adapter) diff --git a/app/src/main/scala/com/nutomic/ensichat/fragments/ContactsFragment.scala b/app/src/main/scala/com/nutomic/ensichat/fragments/ContactsFragment.scala index 2dca13f..c646960 100644 --- a/app/src/main/scala/com/nutomic/ensichat/fragments/ContactsFragment.scala +++ b/app/src/main/scala/com/nutomic/ensichat/fragments/ContactsFragment.scala @@ -7,7 +7,7 @@ import android.view._ import android.widget.ListView import com.nutomic.ensichat.R import com.nutomic.ensichat.activities.{AddContactsActivity, EnsiChatActivity, MainActivity, SettingsActivity} -import com.nutomic.ensichat.bluetooth.ChatService +import com.nutomic.ensichat.protocol.ChatService import com.nutomic.ensichat.util.DevicesAdapter /** @@ -17,7 +17,7 @@ class ContactsFragment extends ListFragment { private lazy val Adapter = new DevicesAdapter(getActivity) - private lazy val Database = getActivity.asInstanceOf[EnsiChatActivity].service.database + private lazy val Database = getActivity.asInstanceOf[EnsiChatActivity].service.Database override def onCreate(savedInstanceState: Bundle): Unit = { super.onCreate(savedInstanceState) diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala new file mode 100644 index 0000000..ee08ea8 --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala @@ -0,0 +1,192 @@ +package com.nutomic.ensichat.protocol + +import android.app.Service +import android.content.Intent +import android.os.Handler +import android.util.Log +import com.nutomic.ensichat.bluetooth.BluetoothInterface +import com.nutomic.ensichat.protocol.ChatService.{OnMessageReceivedListener, OnConnectionsChangedListener} +import com.nutomic.ensichat.protocol.messages.{ConnectionInfo, Message, MessageBody, MessageHeader} +import com.nutomic.ensichat.util.Database + +import scala.collection.SortedSet +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.ref.WeakReference + +object ChatService { + + abstract class InterfaceHandler { + + def create(): Unit + + def destroy(): Unit + + def send(msg: Message): Unit + + } + + trait OnMessageReceivedListener { + def onMessageReceived(messages: SortedSet[Message]): Unit + } + + /** + * Used with [[ChatService.registerConnectionListener]], called when a Bluetooth device + * connects or disconnects + */ + trait OnConnectionsChangedListener { + def onConnectionsChanged(devices: Set[Address]): Unit + } + +} + +/** + * High-level handling of all message transfers and callbacks. + */ +class ChatService extends Service { + + private val Tag = "ChatService" + + lazy val Database = new Database(this) + + val MainHandler = new Handler() + + private lazy val Binder = new ChatServiceBinder(this) + + private lazy val Crypto = new Crypto(this) + + private lazy val BluetoothInterface = new BluetoothInterface(this, Crypto) + + /** + * For this (and [[messageListeners]], functions would be useful instead of instances, + * but on a Nexus S (Android 4.1.2), these functions are garbage collected even when + * referenced. + */ + private var connectionListeners = Set[WeakReference[OnConnectionsChangedListener]]() + + private var messageListeners = Set[WeakReference[OnMessageReceivedListener]]() + + /** + * Generates keys and starts Bluetooth interface. + */ + override def onCreate(): Unit = { + super.onCreate() + + Future { + Crypto.generateLocalKeys() + Log.i(Tag, "Service started, address is " + Crypto.getLocalAddress) + + BluetoothInterface.create() + } + } + + override def onDestroy(): Unit = { + BluetoothInterface.destroy() + } + + override def onStartCommand(intent: Intent, flags: Int, startId: Int) = Service.START_STICKY + + override def onBind(intent: Intent) = Binder + + /** + * Registers a listener that is called whenever a new message is sent or received. + */ + def registerMessageListener(listener: OnMessageReceivedListener): Unit = { + messageListeners += new WeakReference[OnMessageReceivedListener](listener) + } + + /** + * Registers a listener that is called whenever a new device is connected. + */ + def registerConnectionListener(listener: OnConnectionsChangedListener): Unit = { + connectionListeners += new WeakReference[OnConnectionsChangedListener](listener) + listener.onConnectionsChanged(BluetoothInterface.getConnections) + } + + /** + * Sends a new message to the given target address. + */ + def sendTo(target: Address, body: MessageBody): Unit = { + if (!BluetoothInterface.getConnections.contains(target)) + return + + val header = new MessageHeader(body.Type, MessageHeader.DefaultHopLimit, + Crypto.getLocalAddress, target, 0, 0) + + val msg = new Message(header, body) + val encrypted = Crypto.encrypt(Crypto.sign(msg)) + BluetoothInterface.send(encrypted) + onNewMessage(msg) + } + + /** + * Decrypts and verifies incoming messages, forwards valid ones to [[onNewMessage()]]. + */ + def onMessageReceived(msg: Message): Unit = { + val decrypted = Crypto.decrypt(msg) + if (!Crypto.verify(decrypted)) { + Log.i(Tag, "Ignoring message with invalid signature from " + msg.Header.Origin) + return + } + onNewMessage(decrypted) + } + + /** + * Calls all [[OnMessageReceivedListener]]s with the new message. + * + * This function is called both for locally and remotely sent messages. + */ + private def onNewMessage(msg: Message): Unit = { + Database.addMessage(msg) + MainHandler.post(new Runnable { + override def run(): Unit = + messageListeners + .filter(_.get.nonEmpty) + .foreach(_.apply().onMessageReceived(SortedSet(msg)(Message.Ordering))) + }) + } + + /** + * Opens connection to a direct neighbor. + * + * This adds the other node's public key if we don't have it. If we do, it validates the signature + * with the stored key. + * + * The caller must invoke [[callConnectionListeners()]] + * + * @param infoMsg The message containing [[ConnectionInfo]] to open the connection. + * @return True if the connection is valid + */ + def onConnectionOpened(infoMsg: Message): Option[Address] = { + val info = infoMsg.Body.asInstanceOf[ConnectionInfo] + val sender = Crypto.calculateAddress(info.key) + if (sender == Address.Broadcast || sender == Address.Null) { + Log.i(Tag, "Ignoring ConnectionInfo message with invalid sender " + sender) + None + } + + if (Crypto.havePublicKey(sender) && !Crypto.verify(infoMsg, Crypto.getPublicKey(sender))) { + Log.i(Tag, "Ignoring ConnectionInfo message with invalid signature") + None + } + + if (!Crypto.havePublicKey(sender)) { + Crypto.addPublicKey(sender, info.key) + Log.i(Tag, "Added public key for new device " + sender.toString) + } + + Log.i(Tag, "Node " + sender + " connected") + Some(sender) + } + + /** + * Calls all [[connectionListeners]] with the currently active connections. + * + * Should be called whenever a neighbor connects or disconnects. + */ + def callConnectionListeners(): Unit = + connectionListeners + .filter(_ != None) + .foreach(_.apply().onConnectionsChanged(BluetoothInterface.getConnections)) + +} \ No newline at end of file diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatServiceBinder.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/ChatServiceBinder.scala similarity index 75% rename from app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatServiceBinder.scala rename to app/src/main/scala/com/nutomic/ensichat/protocol/ChatServiceBinder.scala index 77dccd5..27ca6be 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatServiceBinder.scala +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/ChatServiceBinder.scala @@ -1,4 +1,4 @@ -package com.nutomic.ensichat.bluetooth +package com.nutomic.ensichat.protocol import android.os.Binder