From b12af56ea73918340d2b5aa69d29a51f6d4c84b2 Mon Sep 17 00:00:00 2001 From: Felix Ableitner Date: Sun, 16 Nov 2014 16:31:02 +0200 Subject: [PATCH] Added functionality to add contacts (with new Activity). This requires confirmation from both devices involved, and allows opening the ChatFragment for a device that is not currently connected. --- ...sageStoreTest.scala => DatabaseTest.scala} | 7 +- app/src/main/AndroidManifest.xml | 5 + .../drawable-hdpi/ic_action_add_person.png | Bin 0 -> 616 bytes .../drawable-mdpi/ic_action_add_person.png | Bin 0 -> 469 bytes .../drawable-xhdpi/ic_action_add_person.png | Bin 0 -> 798 bytes .../drawable-xxhdpi/ic_action_add_person.png | Bin 0 -> 1088 bytes .../main/res/layout/activity_add_contacts.xml | 18 ++ app/src/main/res/menu/main.xml | 8 +- app/src/main/res/values/strings.xml | 35 +++- .../activities/AddContactsActivity.scala | 178 ++++++++++++++++++ .../ensichat/activities/MainActivity.scala | 4 +- .../ensichat/bluetooth/ChatService.scala | 51 +++-- .../ensichat/bluetooth/ConnectThread.scala | 2 +- .../nutomic/ensichat/bluetooth/Device.scala | 15 +- .../ensichat/bluetooth/TransferThread.scala | 47 ++--- .../ensichat/fragments/ChatFragment.scala | 15 +- .../ensichat/fragments/ContactsFragment.scala | 64 +++---- .../ensichat/fragments/SettingsFragment.scala | 1 - .../nutomic/ensichat/messages/Message.scala | 10 +- .../ensichat/messages/MessageStore.scala | 79 -------- .../messages/RequestAddContactMessage.scala | 30 +++ .../messages/ResultAddContactMessage.scala | 36 ++++ .../com/nutomic/ensichat/util/Database.scala | 127 +++++++++++++ .../ensichat/util/DevicesAdapter.scala | 12 +- 24 files changed, 545 insertions(+), 199 deletions(-) rename app/src/androidTest/scala/com.nutomic.ensichat.messages/{MessageStoreTest.scala => DatabaseTest.scala} (88%) create mode 100644 app/src/main/res/drawable-hdpi/ic_action_add_person.png create mode 100644 app/src/main/res/drawable-mdpi/ic_action_add_person.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_action_add_person.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_action_add_person.png create mode 100644 app/src/main/res/layout/activity_add_contacts.xml create mode 100644 app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala delete mode 100644 app/src/main/scala/com/nutomic/ensichat/messages/MessageStore.scala create mode 100644 app/src/main/scala/com/nutomic/ensichat/messages/RequestAddContactMessage.scala create mode 100644 app/src/main/scala/com/nutomic/ensichat/messages/ResultAddContactMessage.scala create mode 100644 app/src/main/scala/com/nutomic/ensichat/util/Database.scala diff --git a/app/src/androidTest/scala/com.nutomic.ensichat.messages/MessageStoreTest.scala b/app/src/androidTest/scala/com.nutomic.ensichat.messages/DatabaseTest.scala similarity index 88% rename from app/src/androidTest/scala/com.nutomic.ensichat.messages/MessageStoreTest.scala rename to app/src/androidTest/scala/com.nutomic.ensichat.messages/DatabaseTest.scala index bb9e99b..59cf65a 100644 --- a/app/src/androidTest/scala/com.nutomic.ensichat.messages/MessageStoreTest.scala +++ b/app/src/androidTest/scala/com.nutomic.ensichat.messages/DatabaseTest.scala @@ -8,9 +8,10 @@ import android.database.sqlite.SQLiteDatabase import android.test.AndroidTestCase import android.test.mock.MockContext import com.nutomic.ensichat.messages.MessageTest._ +import com.nutomic.ensichat.util.Database import junit.framework.Assert._ -class MessageStoreTest extends AndroidTestCase { +class DatabaseTest extends AndroidTestCase { private class TestContext(context: Context) extends MockContext { override def openOrCreateDatabase(file: String, mode: Int, factory: @@ -22,10 +23,10 @@ class MessageStoreTest extends AndroidTestCase { private var dbFile: String = _ - private var MessageStore: MessageStore = _ + private var MessageStore: Database = _ override def setUp(): Unit = { - MessageStore = new MessageStore(new TestContext(getContext)) + MessageStore = new Database(new TestContext(getContext)) MessageStore.addMessage(m1) MessageStore.addMessage(m2) MessageStore.addMessage(m3) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e039d72..bfff160 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,11 @@ + + 0`eKn&&(uNfd2fCU(! z43G>!2O#fF-;)8FzBdE31ElXs-pc|EkPMIkQd=_82>}P3?+z3!SqSC8{`_y^c@s^f zMS|lckH;gDZf`<}>)-)^(kr+mz+uJDH{dhyk7+QXhvKO49(dm3_?9nR_sPfE#d4*&>GTE@JJHm*GI$|NtOP_zdC1TL7YUr=n@ z19N7thpv*Ci)Ld7;Ef$X&iS%H+3AGHAd$-o+o~ARfIY6 z&XL@{B0;x?LO0@CFa3skzcpMBmgR0Pb@@ow0~_weqlVse#|yv4`Y#r@vzR-CVoTO9 zDA9R1s9EB(v8NO3a%W%gCVDpd7hnK{lIR`?0bJAo00005twHw6_%riuPs0{z5rfE zY&%b1TQ&x8`URG=w=*GLxfqZkrvY%unSu~mZ~^E70eBZ-`XHAL=xpik*U)O>)at6X z_^)H*5&(A43-mmeTi8n4m$Ub2I6Y|~Him5peJ6Vvs}m0NuC>}2^1Pz82K8|O7+o=^ zpc#QsT5$}d(R;0^Cnjw6CDn>#Wq=$O8qMG1mo(8*VA|DCy3GPW!H*$Lg=%E)Gn9m- z=)MAj|GW_ZASZtX^_36TPPmyFTuG)~SvfUVYyddKc{Ha6{hz<~QR>kzbSY>xYoHnC z1~-6<B3Edx+z`yTT}jLYnwJtvu0igI29&1>c=N3c68xV2%&;)?u zkMMZGQ;)$J)+-QEe>eg_d1K;x%gke8uEQn;37UcONlDO=${VGB2HbaFT!4TLB>`U| z_pL`z-XKS3%GUzW>HQi}rW&l^D`yK>jV#jxr4_^wupp4@i6sGO{uzN>3$7NBQ3-}* zvm+o^bjY~+fKP=`0E~AX`%PtL zvxq%MSX)Q%Z+%Y8Dz5C`V4De^)W7!D|I`)M_nvZWt*}>8c8P)sz>|J!sN!~!r*CSD z!use3`*IjaI5gb*!jORG?5q6NxlRY_9-;1eZ}*Nvyypt0*?DZ)o1^0P-V^NEDPr{P z3BE_dorp7r&I)}+j-K94bCW3Fn*fUl(;-^N#Cx=sL#j-0$hp8HlJKOu8>L3^1K#;s zC4?U1r8pgOCqq}f3YXNw5W#>^pg^Z~_xx4hJB(9YS?TYY{agSQ-#W>QT`V2jvN=@x z9u4NHeS;2E-gIl9mH#xTwkpVwZj!O69fhyZ0m|>*IrTB|+Llm}5s*lE5)!nh*}o)q z)*JUaI4$~Xr~~#YhVVz88APk5G`YYueBZ}9yQ;yp8gBgE2N|Z@bmcIfexdkaMM?lO z}>VHfjQx{A^Hj70e^-nNEdT%j07*qoM6N<$g64W_NB{r; literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_add_person.png b/app/src/main/res/drawable-xxhdpi/ic_action_add_person.png new file mode 100644 index 0000000000000000000000000000000000000000..e9a58eafc2870f380492f6bb1f1928a32bbbf9d3 GIT binary patch literal 1088 zcmV-G1i$-k7RCwC#T-|XRF%Z@v2w!5$#!ic{fdK!Lzjf?#%J8B^So3i1ZJ4r9vawY(;@SV*qbZ}8lSCD2(WEa~xU}v!c!iuw|ZLEH}`7-nV z2OnxjoeIEK@ZIw7KZRPZMPTw*#>YkP5%x(~i?A<0pI7}?m45^0U2SL=)zMXIM5zUd%FBKb3vGLDww;7Jp7i91vfW z7?T3BhNs{J;PCNL2`+xU!V6o&IS2tb^4Kc5eF3%;^iq-tgNEc2=^M|;S8S;(NiWp& zMr^C(_XOB5mGpB4g9fXA;fc>L!n~}9SpWac7-{bU@TR*^VPNf}1*}v!7_&9|Apo0T zs=~s&?*_g)7Lcj%Fj74?at|u5{yQbSRT?xXZ?aTCKLT*#>Hw;Qq#^(a5EPOaCaD97CO@NTp89Q9rbQ^d4jo3a&|IWY znnZ%7X%4s`>;VWvV?dLN2s2epm-==4&PsnIB*7ZAZrpFiN6R(}z2Jhd2S5M-0zd!& z0zd!&0zd!&0zd$uLkvNI6=nkO1WEt^00000fI#qDfB^t6wO<{j0;t{q0000 + + + + + + diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml index 6040564..47d2f61 100644 --- a/app/src/main/res/menu/main.xml +++ b/app/src/main/res/menu/main.xml @@ -2,6 +2,12 @@ + + + android:title="@string/exit" /> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 482d4cf..f0e3b9e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,14 +11,41 @@ Bluetooth is required for this app. - - Exit - - No contacts found :( + You haven\'t added any contacts yet + + + Exit + + + + + + Contact is offline, message not sent + + + + + + Add Contacts + + + Do you want to add %1$s as a new contact? + + + No nearby devices found + + + You already added this contact + + + %1$s was added as a contact + + + Contact not added (denied by other device) diff --git a/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala b/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala new file mode 100644 index 0000000..be6495d --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala @@ -0,0 +1,178 @@ +package com.nutomic.ensichat.activities + +import java.util.Date + +import android.app.AlertDialog +import android.content.DialogInterface +import android.content.DialogInterface.OnClickListener +import android.os.Bundle +import android.view._ +import android.widget.AdapterView.OnItemClickListener +import android.widget.{AdapterView, ListView, Toast} +import com.nutomic.ensichat.R +import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener +import com.nutomic.ensichat.bluetooth.{ChatService, Device} +import com.nutomic.ensichat.messages.{Message, RequestAddContactMessage, ResultAddContactMessage} +import com.nutomic.ensichat.util.DevicesAdapter + +import scala.collection.SortedSet + +/** + * Lists all nearby, connected devices and allows adding them to contacts. + * + * Adding a contact requires confirmation on both sides. + */ +class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnectionChangedListener + with OnItemClickListener with OnMessageReceivedListener { + + private lazy val Adapter = new DevicesAdapter(this) + + private lazy val Database = service.database + + /** + * Map of devices that should be added. + */ + private var currentlyAdding = Map[Device.ID, AddContactInfo]() + .withDefaultValue(new AddContactInfo(false, false)) + + /** + * Holds confirmation status for adding contacts. + * + * @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) { + } + + /** + * Initializes layout, registers connection and message listeners. + */ + override def onCreate(savedInstanceState: Bundle): Unit = { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_add_contacts) + val list = findViewById(android.R.id.list).asInstanceOf[ListView] + list.setAdapter(Adapter) + list.setOnItemClickListener(this) + + runOnServiceConnected(() => { + service.registerConnectionListener(AddContactsActivity.this) + service.registerMessageListener(this) + }) + } + + /** + * Displays newly connected devices in the list. + */ + override def onConnectionChanged(devices: Map[Device.ID, Device]): Unit = { + val filtered = devices.filter{ case (_, d) => d.Connected } + runOnUiThread(new Runnable { + override def run(): Unit = { + Adapter.clear() + filtered.values.foreach(f => Adapter.add(f)) + } + }) + } + + /** + * Initiates adding the device as contact if it hasn't been added yet. + */ + override def onItemClick(parent: AdapterView[_], view: View, position: Int, id: Long): Unit = { + val device = Adapter.getItem(position) + if (Database.isContact(device.Id)) { + Toast.makeText(this, R.string.contact_already_added, Toast.LENGTH_SHORT).show() + return + } + + service.send(new RequestAddContactMessage(service.localDeviceId, device.Id, new Date())) + addDeviceDialog(device) + } + + /** + * Shows a dialog to accept/deny adding a device as a new contact. + */ + private def addDeviceDialog(device: Device): Unit = { + val id = device.Id + // Listener for dialog button clicks. + val onClick = new OnClickListener { + override def onClick(dialogInterface: DialogInterface, i: Int): Unit = i match { + case DialogInterface.BUTTON_POSITIVE => + // Local user accepted contact, update state and send info to other device. + currentlyAdding += (id -> new AddContactInfo(currentlyAdding(id).localConfirmed, true)) + addContactIfBothConfirmed(device) + service.send( + new ResultAddContactMessage(service.localDeviceId, device.Id, new Date(), true)) + case DialogInterface.BUTTON_NEGATIVE => + // Local user denied adding contact, send info to other device. + service.send( + new ResultAddContactMessage(service.localDeviceId, device.Id, new Date(), false)) + } + } + + new AlertDialog.Builder(this) + .setTitle(getString(R.string.add_contact_dialog, device.Name)) + .setPositiveButton(android.R.string.yes, onClick) + .setNegativeButton(android.R.string.no, onClick) + .show() + } + + /** + * Handles incoming [[RequestAddContactMessage]] and [[ResultAddContactMessage]] messages. + * + * These are only handled here and require user action, so contacts can only be added if + * the user is in this activity. + */ + override def onMessageReceived(messages: SortedSet[Message]): Unit = { + messages.foreach(m => { + if (m.receiver == service.localDeviceId) { + m.messageType match { + case Message.Type.RequestAddContact => + // Remote device wants to add us as a contact, show dialog. + val sender = getDevice(m.sender) + addDeviceDialog(sender) + case Message.Type.ResultAddContact => + if (m.asInstanceOf[ResultAddContactMessage].Accepted) { + // Remote device accepted us as a contact, update state. + currentlyAdding += (m.sender -> + new AddContactInfo(true, currentlyAdding(m.sender).remoteConfirmed)) + addContactIfBothConfirmed(getDevice(m.sender)) + } else { + // Remote device denied us as a contact, show a toast + // and remove from [[currentlyAdding]]. + Toast.makeText(this, R.string.contact_not_added, Toast.LENGTH_LONG).show() + currentlyAdding -= m.sender + } + case _ => + } + } + }) + } + + /** + * Returns the [[Device]] for a given [[Device.ID]] that is stored in the [[Adapter]]. + */ + private def getDevice(id: Device.ID): Device = { + // ArrayAdapter does not return the underlying array so we have to access it manually. + for (i <- 0 until Adapter.getCount) { + if (Adapter.getItem(i).Id == id) { + return Adapter.getItem(i) + } + } + throw new RuntimeException("Device to add was not found") + } + + /** + * Add the given device to contacts if [[AddContactInfo.localConfirmed]] and + * [[AddContactInfo.remoteConfirmed]] are true for it in [[currentlyAdding]]. + */ + private def addContactIfBothConfirmed(device: Device): Unit = { + val info = currentlyAdding(device.Id) + if (info.localConfirmed && info.remoteConfirmed) { + Database.addContact(device) + Toast.makeText(this, getString(R.string.contact_added, device.Name), Toast.LENGTH_SHORT) + .show() + } + currentlyAdding -= device.Id + } + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/activities/MainActivity.scala b/app/src/main/scala/com/nutomic/ensichat/activities/MainActivity.scala index 3526b88..894d024 100644 --- a/app/src/main/scala/com/nutomic/ensichat/activities/MainActivity.scala +++ b/app/src/main/scala/com/nutomic/ensichat/activities/MainActivity.scala @@ -6,7 +6,7 @@ import android.content._ import android.os.Bundle import android.widget.Toast import com.nutomic.ensichat.R -import com.nutomic.ensichat.bluetooth.{ChatService, Device} +import com.nutomic.ensichat.bluetooth.Device import com.nutomic.ensichat.fragments.{ChatFragment, ContactsFragment} /** @@ -89,7 +89,7 @@ class MainActivity extends EnsiChatActivity { if (currentChat != None) { getFragmentManager .beginTransaction() - .remove(getFragmentManager().findFragmentById(android.R.id.content)) + .remove(getFragmentManager.findFragmentById(android.R.id.content)) .attach(ContactsFragment) .commit() currentChat = None diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala index 8cace06..f826317 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ChatService.scala @@ -1,5 +1,6 @@ package com.nutomic.ensichat.bluetooth +import java.security.InvalidParameterException import java.util.{Date, UUID} import android.app.Service @@ -8,12 +9,13 @@ import android.content.{BroadcastReceiver, Context, Intent, IntentFilter} import android.os.Handler import android.preference.PreferenceManager import android.util.Log -import com.nutomic.ensichat.R import com.nutomic.ensichat.bluetooth.ChatService.{OnConnectionChangedListener, OnMessageReceivedListener} import com.nutomic.ensichat.messages._ +import com.nutomic.ensichat.util.Database +import com.nutomic.ensichat.{BuildConfig, R} +import scala.collection.SortedSet import scala.collection.immutable.{HashMap, HashSet, TreeSet} -import scala.collection.{SortedSet, mutable} import scala.ref.WeakReference object ChatService { @@ -53,9 +55,7 @@ class ChatService extends Service { */ private var connectionListeners = new HashSet[WeakReference[OnConnectionChangedListener]]() - private val messageListeners = - mutable.HashMap[Device.ID, mutable.Set[WeakReference[OnMessageReceivedListener]]]() - .withDefaultValue(mutable.Set[WeakReference[OnMessageReceivedListener]]()) + private var messageListeners = Set[WeakReference[OnMessageReceivedListener]]() private var devices = new HashMap[Device.ID, Device]() @@ -67,7 +67,7 @@ class ChatService extends Service { private val MainHandler = new Handler() - private var MessageStore: MessageStore = _ + private lazy val Database = new Database(this) private lazy val Crypto = new Crypto(getFilesDir) @@ -77,8 +77,6 @@ class ChatService extends Service { override def onCreate(): Unit = { super.onCreate() - MessageStore = new MessageStore(this) - bluetoothAdapter = BluetoothAdapter.getDefaultAdapter registerReceiver(DeviceDiscoveredReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND)) @@ -137,7 +135,7 @@ class ChatService extends Service { override def onReceive(context: Context, intent: Intent) { val device: Device = new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false) - devices += (device.id -> device) + devices += (device.Id -> device) new ConnectThread(device, onConnectionChanged).start() } } @@ -191,13 +189,14 @@ class ChatService extends Service { * @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) + devices += (device.Id -> device) - if (device.connected) { - connections += (device.id -> + if (device.Connected) { + connections += (device.Id -> new TransferThread(device, socket, this, Crypto, handleNewMessage)) - connections(device.id).start() - send(new DeviceInfoMessage(localDeviceId, device.id, new Date(), Crypto.getLocalPublicKey)) + connections(device.Id).start() + connections.apply(device.Id).send( + new DeviceInfoMessage(localDeviceId, device.Id, new Date(), Crypto.getLocalPublicKey)) } connectionListeners.foreach(l => l.get match { @@ -210,6 +209,9 @@ class ChatService extends Service { * Sends message to the device specified as receiver, */ def send(message: Message): Unit = { + if (BuildConfig.DEBUG && message.sender != localDeviceId) { + throw new InvalidParameterException("Message must be sent from local device") + } connections.apply(message.receiver).send(message) handleNewMessage(message) } @@ -218,16 +220,22 @@ class ChatService extends Service { * Saves the message to database and sends it to registered listeners. * * If you want to send a new message, use [[send]]. + * + * Messages must always be sent between local device and a contact. */ private def handleNewMessage(message: Message): Unit = { - MessageStore.addMessage(message) + if (BuildConfig.DEBUG && message.sender != localDeviceId && message.receiver != localDeviceId) { + throw new InvalidParameterException("Message must be sent or received by local device") + } + + Database.addMessage(message) MainHandler.post(new Runnable { override def run(): Unit = { - messageListeners(message.sender).foreach(l => + messageListeners.foreach(l => if (l.get != null) l.apply().onMessageReceived(new TreeSet[Message]()(Message.Ordering) + message) else - messageListeners(message.sender) -= l) + messageListeners -= l) } }) } @@ -235,9 +243,8 @@ class ChatService extends Service { /** * Registers a listener that is called whenever a new message is sent or received. */ - def registerMessageListener(device: Device.ID, listener: OnMessageReceivedListener): Unit = { - messageListeners(device) += new WeakReference[OnMessageReceivedListener](listener) - listener.onMessageReceived(MessageStore.getMessages(device, 10)) + def registerMessageListener(listener: OnMessageReceivedListener): Unit = { + messageListeners += new WeakReference[OnMessageReceivedListener](listener) } /** @@ -245,4 +252,8 @@ class ChatService extends Service { */ def localDeviceId = new Device.ID(bluetoothAdapter.getAddress) + def isConnected(device: Device.ID): Boolean = connections.keySet.contains(device) + + def database = Database + } \ No newline at end of file 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 0c58250..f3e9d4b 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/ConnectThread.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/ConnectThread.scala @@ -32,7 +32,7 @@ class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Un return } - Log.i(Tag, "Successfully connected to device " + device.name) + Log.i(Tag, "Successfully connected to device " + device.Name) onConnected(new Device(device.bluetoothDevice, true), Socket) } diff --git a/app/src/main/scala/com/nutomic/ensichat/bluetooth/Device.scala b/app/src/main/scala/com/nutomic/ensichat/bluetooth/Device.scala index afc0f60..836fcd4 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/Device.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/Device.scala @@ -28,16 +28,13 @@ object Device { /** * Holds information about a remote bluetooth device. */ -class Device(BluetoothDevice: BluetoothDevice, Connected: Boolean) { +class Device(val Id: Device.ID, val Name: String, val Connected: Boolean, + btDevice: Option[BluetoothDevice] = None) { - def id = new Device.ID(bluetoothDevice.getAddress) + def this(btDevice: BluetoothDevice, connected: Boolean) { + this(new Device.ID(btDevice.getAddress), btDevice.getName, connected, Option(btDevice)) + } - def name = BluetoothDevice.getName - - def connected = Connected - - def bluetoothDevice = BluetoothDevice - - override def toString = "Device(" + name + ", " + bluetoothDevice.getAddress + ")" + def bluetoothDevice = btDevice.get } 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 05f35f2..3f4f69a 100644 --- a/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala +++ b/app/src/main/scala/com/nutomic/ensichat/bluetooth/TransferThread.scala @@ -4,7 +4,7 @@ import java.io._ import android.bluetooth.BluetoothSocket import android.util.Log -import com.nutomic.ensichat.messages.{Crypto, DeviceInfoMessage, Message, TextMessage} +import com.nutomic.ensichat.messages.{Crypto, DeviceInfoMessage, Message} import org.msgpack.ScalaMessagePack /** @@ -22,16 +22,6 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi private val Tag: String = "TransferThread" - /** - * First value in a message, indicates that content is not encrypted. - */ - private val MessageUnencrypted = false - - /** - * First value in a message, indicates that content is encrypted. - */ - private val MessageEncrypted = true - val InStream: InputStream = try { socket.getInputStream @@ -56,53 +46,54 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi while (socket.isConnected) { try { val up = new ScalaMessagePack().createUnpacker(InStream) - val plain = up.readBoolean() match { - case MessageEncrypted => + val isEncrypted = up.readBoolean() + val plain = + if (isEncrypted) { val encrypted = up.readByteArray() val key = up.readByteArray() crypto.decrypt(encrypted, key) - case MessageUnencrypted => + } else { up.readByteArray() - } + } val (message, signature) = Message.read(plain) var messageValid = true - if (message.sender != device.id) { - Log.i(Tag, "Dropping message with invalid sender from " + device.id) + if (message.sender != device.Id) { + Log.i(Tag, "Dropping message with invalid sender from " + device.Id) messageValid = false } if (message.receiver != service.localDeviceId) { - Log.i(Tag, "Dropping message with different receiver from " + device.id) + Log.i(Tag, "Dropping message with different receiver from " + device.Id) messageValid = false } // Add public key for new, directly connected device. // Explicitly check that message was not forwarded or spoofed. if (message.isInstanceOf[DeviceInfoMessage] && !crypto.havePublicKey(message.sender) && - message.sender == device.id) { + message.sender == device.Id) { val dim = message.asInstanceOf[DeviceInfoMessage] // Permanently store public key for new local devices (also check signature). if (crypto.isValidSignature(message, signature, dim.publicKey)) { - crypto.addPublicKey(device.id, dim.publicKey) - Log.i(Tag, "Added public key for new device " + device.name) + crypto.addPublicKey(device.Id, dim.publicKey) + Log.i(Tag, "Added public key for new device " + device.Name) } } if (!crypto.isValidSignature(message, signature)) { - Log.i(Tag, "Dropping message with invalid signature from " + device.id) + Log.i(Tag, "Dropping message with invalid signature from " + device.Id) messageValid = false } if (messageValid) { message match { - case m: TextMessage => onReceive(m) case m: DeviceInfoMessage => crypto.addPublicKey(message.sender, m.publicKey) + case _ => onReceive(message) } } } catch { case e: IOException => - Log.w(Tag, "Connection to " + device.name + " closed with exception", e) + Log.w(Tag, "Connection to " + device.Name + " closed with exception", e) service.onConnectionChanged(new Device(device.bluetoothDevice, false), null) return } @@ -118,11 +109,13 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi message.messageType match { case Message.Type.Text => val (encrypted, key) = crypto.encrypt(message.receiver, plain) - packer.write(MessageEncrypted) + // Message is encrypted. + packer.write(true) .write(encrypted) .write(key) - case Message.Type.DeviceInfo => - packer.write(MessageUnencrypted) + case _ => + // Message is not encrypted. + packer.write(false) .write(plain) } } catch { 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 876ece4..f92af90 100644 --- a/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala +++ b/app/src/main/scala/com/nutomic/ensichat/fragments/ChatFragment.scala @@ -3,8 +3,7 @@ package com.nutomic.ensichat.fragments import java.util.Date import android.app.ListFragment -import android.content.{ComponentName, Context, Intent, ServiceConnection} -import android.os.{Bundle, IBinder} +import android.os.Bundle import android.view.View.OnClickListener import android.view.inputmethod.EditorInfo import android.view.{KeyEvent, LayoutInflater, View, ViewGroup} @@ -13,7 +12,7 @@ import android.widget._ import com.nutomic.ensichat.R import com.nutomic.ensichat.activities.EnsiChatActivity import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener -import com.nutomic.ensichat.bluetooth.{ChatService, ChatServiceBinder, Device} +import com.nutomic.ensichat.bluetooth.{ChatService, Device} import com.nutomic.ensichat.messages.{Message, TextMessage} import com.nutomic.ensichat.util.MessagesAdapter @@ -51,7 +50,8 @@ class ChatFragment extends ListFragment with OnClickListener // Read local device ID from service, adapter = new MessagesAdapter(getActivity, chatService.localDeviceId) - chatService.registerMessageListener(device, ChatFragment.this) + chatService.registerMessageListener(ChatFragment.this) + onMessageReceived(chatService.database.getMessages(device, 10)) if (listView != null) { listView.setAdapter(adapter) @@ -100,6 +100,10 @@ class ChatFragment extends ListFragment with OnClickListener case R.id.send => val text: String = messageText.getText.toString if (!text.isEmpty) { + if (!chatService.isConnected(device)) { + Toast.makeText(getActivity, R.string.contact_offline_toast, Toast.LENGTH_SHORT).show() + return + } chatService.send( new TextMessage(chatService.localDeviceId, device, new Date(), text.toString)) messageText.getText.clear() @@ -111,7 +115,8 @@ class ChatFragment extends ListFragment with OnClickListener * Displays new messages in UI. */ override def onMessageReceived(messages: SortedSet[Message]): Unit = { - messages.filter(_.isInstanceOf[TextMessage]) + messages.filter(m => m.sender == device || m.receiver == device) + .filter(_.isInstanceOf[TextMessage]) .foreach(m => adapter.add(m.asInstanceOf[TextMessage])) } 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 c437ec8..47155d2 100644 --- a/app/src/main/scala/com/nutomic/ensichat/fragments/ContactsFragment.scala +++ b/app/src/main/scala/com/nutomic/ensichat/fragments/ContactsFragment.scala @@ -1,42 +1,43 @@ package com.nutomic.ensichat.fragments import android.app.ListFragment -import android.content.{ComponentName, Context, Intent, ServiceConnection} -import android.os.{Bundle, IBinder} +import android.content.Intent +import android.os.Bundle import android.view._ -import android.widget.{ArrayAdapter, ListView} +import android.widget.ListView import com.nutomic.ensichat.R -import com.nutomic.ensichat.activities.{SettingsActivity, EnsiChatActivity, MainActivity} -import com.nutomic.ensichat.bluetooth.{ChatService, ChatServiceBinder, Device} -import com.nutomic.ensichat.util.{MessagesAdapter, DevicesAdapter} +import com.nutomic.ensichat.activities.{AddContactsActivity, EnsiChatActivity, MainActivity, SettingsActivity} +import com.nutomic.ensichat.bluetooth.ChatService +import com.nutomic.ensichat.util.DevicesAdapter /** * Lists all nearby, connected devices. */ -class ContactsFragment extends ListFragment with ChatService.OnConnectionChangedListener { +class ContactsFragment extends ListFragment { - private lazy val adapter = new DevicesAdapter(getActivity) + private lazy val Adapter = new DevicesAdapter(getActivity) - override def onActivityCreated(savedInstanceState: Bundle): Unit = { - super.onActivityCreated(savedInstanceState) - - val activity = getActivity.asInstanceOf[EnsiChatActivity] - activity.runOnServiceConnected(() => { - activity.service.registerConnectionListener(ContactsFragment.this) - }) - } - - override def onCreateView(inflater: LayoutInflater, container: ViewGroup, - savedInstanceState: Bundle): View = - inflater.inflate(R.layout.fragment_contacts, container, false) + private lazy val Database = getActivity.asInstanceOf[EnsiChatActivity].service.database override def onCreate(savedInstanceState: Bundle): Unit = { super.onCreate(savedInstanceState) - setListAdapter(adapter) + setListAdapter(Adapter) setHasOptionsMenu(true) + + getActivity.asInstanceOf[EnsiChatActivity].runOnServiceConnected(() => { + Database.getContacts.foreach(Adapter.add) + Database.runOnContactsUpdated(() => { + Adapter.clear() + Database.getContacts.foreach(Adapter.add) + }) + }) } + override def onCreateView(inflater: LayoutInflater, container: ViewGroup, + savedInstanceState: Bundle): View = + inflater.inflate(R.layout.fragment_contacts, container, false) + override def onCreateOptionsMenu(menu: Menu, inflater: MenuInflater): Unit = { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.main, menu) @@ -44,6 +45,9 @@ class ContactsFragment extends ListFragment with ChatService.OnConnectionChanged override def onOptionsItemSelected(item: MenuItem): Boolean = { item.getItemId match { + case R.id.add_contact => + startActivity(new Intent(getActivity, classOf[AddContactsActivity])) + true case R.id.settings => startActivity(new Intent(getActivity, classOf[SettingsActivity])) true @@ -56,26 +60,10 @@ class ContactsFragment extends ListFragment with ChatService.OnConnectionChanged } } - /** - * Displays newly connected devices in the list. - */ - override def onConnectionChanged(devices: Map[Device.ID, Device]): Unit = { - if (getActivity == null) - return - - val filtered = devices.filter{ case (_, d) => d.connected } - getActivity.runOnUiThread(new Runnable { - override def run(): Unit = { - adapter.clear() - filtered.values.foreach(f => adapter.add(f)) - } - }) - } - /** * Opens a chat with the clicked device. */ override def onListItemClick(l: ListView, v: View, position: Int, id: Long): Unit = - getActivity.asInstanceOf[MainActivity].openChat(adapter.getItem(position).id) + getActivity.asInstanceOf[MainActivity].openChat(Adapter.getItem(position).Id) } diff --git a/app/src/main/scala/com/nutomic/ensichat/fragments/SettingsFragment.scala b/app/src/main/scala/com/nutomic/ensichat/fragments/SettingsFragment.scala index cbb67e3..2fd3b2a 100644 --- a/app/src/main/scala/com/nutomic/ensichat/fragments/SettingsFragment.scala +++ b/app/src/main/scala/com/nutomic/ensichat/fragments/SettingsFragment.scala @@ -2,7 +2,6 @@ package com.nutomic.ensichat.fragments import android.os.Bundle import android.preference.PreferenceFragment - import com.nutomic.ensichat.R class SettingsFragment extends PreferenceFragment { diff --git a/app/src/main/scala/com/nutomic/ensichat/messages/Message.scala b/app/src/main/scala/com/nutomic/ensichat/messages/Message.scala index 409f496..4f29bd3 100644 --- a/app/src/main/scala/com/nutomic/ensichat/messages/Message.scala +++ b/app/src/main/scala/com/nutomic/ensichat/messages/Message.scala @@ -11,11 +11,13 @@ object Message { /** * Types of messages that can be transfered. * - * There must be one type for each implementation. + * There must be one type for each implementation and vice versa. */ object Type { val Text = 1 val DeviceInfo = 2 + val RequestAddContact = 3 + val ResultAddContact = 4 } /** @@ -40,8 +42,10 @@ object Message { val date = new Date(up.readLong()) val sig = up.readByteArray() (messageType match { - case Type.Text => TextMessage.read(sender, receiver, date, up) - case Type.DeviceInfo => DeviceInfoMessage.read(sender, receiver, date, up) + case Type.Text => TextMessage.read(sender, receiver, date, up) + case Type.DeviceInfo => DeviceInfoMessage.read(sender, receiver, date, up) + case Type.RequestAddContact => RequestAddContactMessage.read(sender, receiver, date, up) + case Type.ResultAddContact => ResultAddContactMessage.read(sender, receiver, date, up) }, sig) } diff --git a/app/src/main/scala/com/nutomic/ensichat/messages/MessageStore.scala b/app/src/main/scala/com/nutomic/ensichat/messages/MessageStore.scala deleted file mode 100644 index a5901f3..0000000 --- a/app/src/main/scala/com/nutomic/ensichat/messages/MessageStore.scala +++ /dev/null @@ -1,79 +0,0 @@ -package com.nutomic.ensichat.messages - -import java.util.Date - -import android.content.{ContentValues, Context} -import android.database.Cursor -import android.database.sqlite.{SQLiteDatabase, SQLiteOpenHelper} -import com.nutomic.ensichat.bluetooth.Device - -import scala.collection.SortedSet -import scala.collection.immutable.TreeSet - -object MessageStore { - - private val DatabaseName = "message_store.db" - - private val DatabaseVersion = 1 - - private val DatabaseCreate = "CREATE TABLE messages(" + - "_id integer primary key autoincrement," + - "sender string not null," + - "receiver string not null," + - "text blob not null," + - "date integer not null);" // Unix timestamp of message. - -} - -/** - * Stores all messages in SQL database. - */ -class MessageStore(context: Context) extends SQLiteOpenHelper(context, MessageStore.DatabaseName, - null, MessageStore.DatabaseVersion) { - - private val Tag = "MessageStore" - - override def onCreate(db: SQLiteDatabase): Unit = { - db.execSQL(MessageStore.DatabaseCreate) - } - - /** - * Returns the count last messages for device. - */ - def getMessages(device: Device.ID, count: Int): SortedSet[Message] = { - val c: Cursor = getReadableDatabase.query(true, - "messages", Array("sender", "receiver", "text", "date"), - "sender = ? OR receiver = ?", Array(device.toString, device.toString), - null, null, "date DESC", count.toString) - var messages: SortedSet[Message] = new TreeSet[Message]()(Message.Ordering) - while (c.moveToNext()) { - val m: TextMessage = new TextMessage( - new Device.ID(c.getString(c.getColumnIndex("sender"))), - new Device.ID(c.getString(c.getColumnIndex("receiver"))), - new Date(c.getLong(c.getColumnIndex("date"))), - new String(c.getString(c.getColumnIndex ("text")))) - messages += m - } - c.close() - messages - } - - /** - * Inserts the given new message into the database. - */ - def addMessage(message: Message): Unit = message match { - case msg: TextMessage => - val cv: ContentValues = new ContentValues() - cv.put("sender", msg.sender.toString) - cv.put("receiver", msg.receiver.toString) - // toString used as workaround for compile error with Long. - cv.put("date", msg.date.getTime.toString) - cv.put("text", msg.text) - getWritableDatabase.insert("messages", null, cv) - case msg: DeviceInfoMessage => // Never stored. - } - - override def onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int): Unit = { - } - -} \ No newline at end of file diff --git a/app/src/main/scala/com/nutomic/ensichat/messages/RequestAddContactMessage.scala b/app/src/main/scala/com/nutomic/ensichat/messages/RequestAddContactMessage.scala new file mode 100644 index 0000000..fc8f77f --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/messages/RequestAddContactMessage.scala @@ -0,0 +1,30 @@ +package com.nutomic.ensichat.messages + +import java.util.Date + +import com.nutomic.ensichat.activities.AddContactsActivity +import com.nutomic.ensichat.bluetooth.Device +import com.nutomic.ensichat.messages.Message._ +import org.msgpack.packer.Packer +import org.msgpack.unpacker.Unpacker + +object RequestAddContactMessage { + + def read(sender: Device.ID, receiver: Device.ID, date: Date, up: Unpacker) = + new RequestAddContactMessage(sender, receiver, date) + +} + +/** + * Message sent by [[AddContactsActivity]] to notify a device that it should be added as a contact. + */ +class RequestAddContactMessage(override val sender: Device.ID, override val receiver: Device.ID, + override val date: Date) extends Message(Type.RequestAddContact) { + + override def doWrite(packer: Packer) = { + } + + override def toString = "RequestAddContactMessage(" + sender.toString + ", " + receiver.toString + + ", " + date.toString + ")" + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/messages/ResultAddContactMessage.scala b/app/src/main/scala/com/nutomic/ensichat/messages/ResultAddContactMessage.scala new file mode 100644 index 0000000..1fe2259 --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/messages/ResultAddContactMessage.scala @@ -0,0 +1,36 @@ +package com.nutomic.ensichat.messages + +import java.util.Date + +import com.nutomic.ensichat.activities.AddContactsActivity +import com.nutomic.ensichat.bluetooth.Device +import com.nutomic.ensichat.messages.Message._ +import org.msgpack.packer.Packer +import org.msgpack.unpacker.Unpacker + +object ResultAddContactMessage { + + def read(sender: Device.ID, receiver: Device.ID, date: Date, up: Unpacker) = + new ResultAddContactMessage(sender, receiver, date, up.readBoolean()) + +} + +/** + * Message sent by [[AddContactsActivity]] to tell a device whether the user confirmed adding it + * to contacts. + */ +class ResultAddContactMessage(override val sender: Device.ID, override val receiver: Device.ID, + override val date: Date, val Accepted: Boolean) + extends Message(Type.ResultAddContact) { + + override def doWrite(packer: Packer) = packer.write(Accepted) + + override def equals(a: Any) = + super.equals(a) && a.asInstanceOf[ResultAddContactMessage].Accepted == Accepted + + override def hashCode = super.hashCode + Accepted.hashCode + + override def toString = "ResultAddContactMessage(" + sender.toString + ", " + receiver.toString + + ", " + date.toString + ", " + Accepted + ")" + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/util/Database.scala b/app/src/main/scala/com/nutomic/ensichat/util/Database.scala new file mode 100644 index 0000000..727baa8 --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/util/Database.scala @@ -0,0 +1,127 @@ +package com.nutomic.ensichat.util + +import java.util.Date + +import android.content.{ContentValues, Context} +import android.database.sqlite.{SQLiteDatabase, SQLiteOpenHelper} +import com.nutomic.ensichat.bluetooth.Device +import com.nutomic.ensichat.messages.{Message, TextMessage} + +import scala.collection.SortedSet +import scala.collection.immutable.TreeSet + +object Database { + + private val DatabaseName = "message_store.db" + + private val DatabaseVersion = 1 + + private val CreateMessagesTable = "CREATE TABLE messages(" + + "_id integer primary key autoincrement," + + "sender string not null," + + "receiver string not null," + + "text blob not null," + + "date integer not null);" // Unix timestamp of message. + + private val CreateContactsTable = "CREATE TABLE contacts(" + + "_id integer primary key autoincrement," + + "device_id string not null," + + "name string not null)" + +} + +/** + * Stores all messages and contacts in SQL database. + */ +class Database(context: Context) extends SQLiteOpenHelper(context, Database.DatabaseName, + null, Database.DatabaseVersion) { + + private val Tag = "MessageStore" + + private var contactsUpdatedListeners = Set[() => Unit]() + + override def onCreate(db: SQLiteDatabase): Unit = { + db.execSQL(Database.CreateContactsTable) + db.execSQL(Database.CreateMessagesTable) + } + + /** + * Returns the count last messages for device. + */ + def getMessages(device: Device.ID, count: Int): SortedSet[Message] = { + val c = getReadableDatabase.query(true, + "messages", Array("sender", "receiver", "text", "date"), + "sender = ? OR receiver = ?", Array(device.toString, device.toString), + null, null, "date DESC", count.toString) + var messages = new TreeSet[Message]()(Message.Ordering) + while (c.moveToNext()) { + val m = new TextMessage( + new Device.ID(c.getString(c.getColumnIndex("sender"))), + new Device.ID(c.getString(c.getColumnIndex("receiver"))), + new Date(c.getLong(c.getColumnIndex("date"))), + new String(c.getString(c.getColumnIndex ("text")))) + messages += m + } + c.close() + messages + } + + /** + * Inserts the given new message into the database. + */ + def addMessage(message: Message): Unit = message match { + case msg: TextMessage => + val cv = new ContentValues() + cv.put("sender", msg.sender.toString) + cv.put("receiver", msg.receiver.toString) + // toString used as workaround for compile error with Long. + cv.put("date", msg.date.getTime.toString) + cv.put("text", msg.text) + getWritableDatabase.insert("messages", null, cv) + case _ => // Never stored. + } + + /** + * Returns a list of all contacts of this device. + */ + def getContacts: Set[Device] = { + val c = getReadableDatabase.query(true, "contacts", Array("device_id", "name"), "", Array(), + null, null, "name DESC", null) + var contacts = Set[Device]() + while (c.moveToNext()) { + contacts += new Device(new Device.ID(c.getString(c.getColumnIndex("device_id"))), + c.getString(c.getColumnIndex("name")), false) + } + c.close() + contacts + } + + /** + * Returns true if a contact with the given device ID exists. + */ + def isContact(device: Device.ID): Boolean = { + val c = getReadableDatabase.query(true, "contacts", Array("_id"), "device_id = ?", + Array(device.toString), null, null, null, null) + c.getCount != 0 + } + + /** + * Inserts the given device into contacts. + */ + def addContact(device: Device): Unit = { + val cv = new ContentValues() + cv.put("device_id", device.Id.toString) + cv.put("name", device.Name) + getWritableDatabase.insert("contacts", null, cv) + contactsUpdatedListeners.foreach(_()) + } + + /** + * Pass a callback that is called whenever a new contact is added. + */ + def runOnContactsUpdated(l: () => Unit): Unit = contactsUpdatedListeners += l + + override def onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int): Unit = { + } + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/util/DevicesAdapter.scala b/app/src/main/scala/com/nutomic/ensichat/util/DevicesAdapter.scala index 79b9c28..df9d882 100644 --- a/app/src/main/scala/com/nutomic/ensichat/util/DevicesAdapter.scala +++ b/app/src/main/scala/com/nutomic/ensichat/util/DevicesAdapter.scala @@ -11,11 +11,11 @@ import com.nutomic.ensichat.bluetooth.Device class DevicesAdapter(context: Context) extends ArrayAdapter[Device](context, android.R.layout.simple_list_item_1) { - override def getView(position: Int, convertView: View, parent: ViewGroup): View = { - val view = super.getView(position, convertView, parent) - val title: TextView = view.findViewById(android.R.id.text1).asInstanceOf[TextView] - title.setText(getItem(position).name) - view - } + override def getView(position: Int, convertView: View, parent: ViewGroup): View = { + val view = super.getView(position, convertView, parent) + val title: TextView = view.findViewById(android.R.id.text1).asInstanceOf[TextView] + title.setText(getItem(position).Name) + view + } }