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 @@
+
+
+
+
+
+
+
+
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 @@
\ 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
+ }
}