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.
This commit is contained in:
Felix Ableitner 2014-11-16 16:31:02 +02:00
parent 8a1e9b4d5d
commit b12af56ea7
24 changed files with 545 additions and 199 deletions

View file

@ -8,9 +8,10 @@ import android.database.sqlite.SQLiteDatabase
import android.test.AndroidTestCase import android.test.AndroidTestCase
import android.test.mock.MockContext import android.test.mock.MockContext
import com.nutomic.ensichat.messages.MessageTest._ import com.nutomic.ensichat.messages.MessageTest._
import com.nutomic.ensichat.util.Database
import junit.framework.Assert._ import junit.framework.Assert._
class MessageStoreTest extends AndroidTestCase { class DatabaseTest extends AndroidTestCase {
private class TestContext(context: Context) extends MockContext { private class TestContext(context: Context) extends MockContext {
override def openOrCreateDatabase(file: String, mode: Int, factory: override def openOrCreateDatabase(file: String, mode: Int, factory:
@ -22,10 +23,10 @@ class MessageStoreTest extends AndroidTestCase {
private var dbFile: String = _ private var dbFile: String = _
private var MessageStore: MessageStore = _ private var MessageStore: Database = _
override def setUp(): Unit = { override def setUp(): Unit = {
MessageStore = new MessageStore(new TestContext(getContext)) MessageStore = new Database(new TestContext(getContext))
MessageStore.addMessage(m1) MessageStore.addMessage(m1)
MessageStore.addMessage(m2) MessageStore.addMessage(m2)
MessageStore.addMessage(m3) MessageStore.addMessage(m3)

View file

@ -23,6 +23,11 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".activities.AddContactsActivity"
android:label="@string/add_contacts"
android:parentActivityName=".activities.MainActivity" />
<activity <activity
android:name=".activities.SettingsActivity" android:name=".activities.SettingsActivity"
android:label="@string/settings" android:label="@string/settings"

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,18 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<ListView
android:id="@android:id/list"
android:layout_width="fill_parent"
android:layout_height="fill_parent" />
<TextView
android:id="@android:id/empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_devices_nearby"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true" />
</RelativeLayout>

View file

@ -2,6 +2,12 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"> <menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/add_contact"
android:title="@string/add_contacts"
android:icon="@drawable/ic_action_add_person"
android:showAsAction="ifRoom" />
<item <item
android:id="@+id/settings" android:id="@+id/settings"
android:icon="@drawable/ic_action_settings" android:icon="@drawable/ic_action_settings"
@ -10,6 +16,6 @@
<item <item
android:id="@+id/exit" android:id="@+id/exit"
android:title="Exit" /> android:title="@string/exit" />
</menu> </menu>

View file

@ -11,14 +11,41 @@
<!-- Toast shown if user denies request to enable bluetooth --> <!-- Toast shown if user denies request to enable bluetooth -->
<string name="bluetooth_required">Bluetooth is required for this app.</string> <string name="bluetooth_required">Bluetooth is required for this app.</string>
<!-- Menu item to close app and stop service -->
<string name="exit">Exit</string>
<!-- ContactsFragment --> <!-- ContactsFragment -->
<!-- Empty text for contacts list --> <!-- Empty text for contacts list -->
<string name="no_contacts_found">No contacts found :(</string> <string name="no_contacts_found">You haven\'t added any contacts yet</string>
<!-- Menu item to close app and stop service -->
<string name="exit">Exit</string>
<!-- ChatFragment -->
<!-- Toast shown when trying to send a message to an offline contact -->
<string name="contact_offline_toast">Contact is offline, message not sent</string>
<!-- AddContactsActivity -->
<!-- Activity title -->
<string name="add_contacts">Add Contacts</string>
<!-- Title of dialog shown when clicking a device -->
<string name="add_contact_dialog">Do you want to add %1$s as a new contact?</string>
<!-- Empty text for devices list -->
<string name="no_devices_nearby">No nearby devices found</string>
<!-- Toast shown when trying to add a contact again -->
<string name="contact_already_added">You already added this contact</string>
<!-- Toast shown when a new contact was added. Parameter is contact name -->
<string name="contact_added">%1$s was added as a contact</string>
<!-- Toast shown when a contact is not added because it was not accepted on the other device -->
<string name="contact_not_added">Contact not added (denied by other device)</string>
<!-- SettingsActivity --> <!-- SettingsActivity -->

View file

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

View file

@ -6,7 +6,7 @@ import android.content._
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import com.nutomic.ensichat.R 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} import com.nutomic.ensichat.fragments.{ChatFragment, ContactsFragment}
/** /**
@ -89,7 +89,7 @@ class MainActivity extends EnsiChatActivity {
if (currentChat != None) { if (currentChat != None) {
getFragmentManager getFragmentManager
.beginTransaction() .beginTransaction()
.remove(getFragmentManager().findFragmentById(android.R.id.content)) .remove(getFragmentManager.findFragmentById(android.R.id.content))
.attach(ContactsFragment) .attach(ContactsFragment)
.commit() .commit()
currentChat = None currentChat = None

View file

@ -1,5 +1,6 @@
package com.nutomic.ensichat.bluetooth package com.nutomic.ensichat.bluetooth
import java.security.InvalidParameterException
import java.util.{Date, UUID} import java.util.{Date, UUID}
import android.app.Service import android.app.Service
@ -8,12 +9,13 @@ import android.content.{BroadcastReceiver, Context, Intent, IntentFilter}
import android.os.Handler import android.os.Handler
import android.preference.PreferenceManager import android.preference.PreferenceManager
import android.util.Log import android.util.Log
import com.nutomic.ensichat.R
import com.nutomic.ensichat.bluetooth.ChatService.{OnConnectionChangedListener, OnMessageReceivedListener} import com.nutomic.ensichat.bluetooth.ChatService.{OnConnectionChangedListener, OnMessageReceivedListener}
import com.nutomic.ensichat.messages._ 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.immutable.{HashMap, HashSet, TreeSet}
import scala.collection.{SortedSet, mutable}
import scala.ref.WeakReference import scala.ref.WeakReference
object ChatService { object ChatService {
@ -53,9 +55,7 @@ class ChatService extends Service {
*/ */
private var connectionListeners = new HashSet[WeakReference[OnConnectionChangedListener]]() private var connectionListeners = new HashSet[WeakReference[OnConnectionChangedListener]]()
private val messageListeners = private var messageListeners = Set[WeakReference[OnMessageReceivedListener]]()
mutable.HashMap[Device.ID, mutable.Set[WeakReference[OnMessageReceivedListener]]]()
.withDefaultValue(mutable.Set[WeakReference[OnMessageReceivedListener]]())
private var devices = new HashMap[Device.ID, Device]() private var devices = new HashMap[Device.ID, Device]()
@ -67,7 +67,7 @@ class ChatService extends Service {
private val MainHandler = new Handler() private val MainHandler = new Handler()
private var MessageStore: MessageStore = _ private lazy val Database = new Database(this)
private lazy val Crypto = new Crypto(getFilesDir) private lazy val Crypto = new Crypto(getFilesDir)
@ -77,8 +77,6 @@ class ChatService extends Service {
override def onCreate(): Unit = { override def onCreate(): Unit = {
super.onCreate() super.onCreate()
MessageStore = new MessageStore(this)
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter
registerReceiver(DeviceDiscoveredReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND)) registerReceiver(DeviceDiscoveredReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND))
@ -137,7 +135,7 @@ class ChatService extends Service {
override def onReceive(context: Context, intent: Intent) { override def onReceive(context: Context, intent: Intent) {
val device: Device = val device: Device =
new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false) new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false)
devices += (device.id -> device) devices += (device.Id -> device)
new ConnectThread(device, onConnectionChanged).start() 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. * @param socket A socket for data transfer if device.connected is true, otherwise null.
*/ */
def onConnectionChanged(device: Device, socket: BluetoothSocket): Unit = { def onConnectionChanged(device: Device, socket: BluetoothSocket): Unit = {
devices += (device.id -> device) devices += (device.Id -> device)
if (device.connected) { if (device.Connected) {
connections += (device.id -> connections += (device.Id ->
new TransferThread(device, socket, this, Crypto, handleNewMessage)) new TransferThread(device, socket, this, Crypto, handleNewMessage))
connections(device.id).start() connections(device.Id).start()
send(new DeviceInfoMessage(localDeviceId, device.id, new Date(), Crypto.getLocalPublicKey)) connections.apply(device.Id).send(
new DeviceInfoMessage(localDeviceId, device.Id, new Date(), Crypto.getLocalPublicKey))
} }
connectionListeners.foreach(l => l.get match { connectionListeners.foreach(l => l.get match {
@ -210,6 +209,9 @@ class ChatService extends Service {
* Sends message to the device specified as receiver, * Sends message to the device specified as receiver,
*/ */
def send(message: Message): Unit = { 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) connections.apply(message.receiver).send(message)
handleNewMessage(message) handleNewMessage(message)
} }
@ -218,16 +220,22 @@ class ChatService extends Service {
* Saves the message to database and sends it to registered listeners. * Saves the message to database and sends it to registered listeners.
* *
* If you want to send a new message, use [[send]]. * 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 = { 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 { MainHandler.post(new Runnable {
override def run(): Unit = { override def run(): Unit = {
messageListeners(message.sender).foreach(l => messageListeners.foreach(l =>
if (l.get != null) if (l.get != null)
l.apply().onMessageReceived(new TreeSet[Message]()(Message.Ordering) + message) l.apply().onMessageReceived(new TreeSet[Message]()(Message.Ordering) + message)
else 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. * Registers a listener that is called whenever a new message is sent or received.
*/ */
def registerMessageListener(device: Device.ID, listener: OnMessageReceivedListener): Unit = { def registerMessageListener(listener: OnMessageReceivedListener): Unit = {
messageListeners(device) += new WeakReference[OnMessageReceivedListener](listener) messageListeners += new WeakReference[OnMessageReceivedListener](listener)
listener.onMessageReceived(MessageStore.getMessages(device, 10))
} }
/** /**
@ -245,4 +252,8 @@ class ChatService extends Service {
*/ */
def localDeviceId = new Device.ID(bluetoothAdapter.getAddress) def localDeviceId = new Device.ID(bluetoothAdapter.getAddress)
def isConnected(device: Device.ID): Boolean = connections.keySet.contains(device)
def database = Database
} }

View file

@ -32,7 +32,7 @@ class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Un
return 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) onConnected(new Device(device.bluetoothDevice, true), Socket)
} }

View file

@ -28,16 +28,13 @@ object Device {
/** /**
* Holds information about a remote bluetooth 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 bluetoothDevice = btDevice.get
def connected = Connected
def bluetoothDevice = BluetoothDevice
override def toString = "Device(" + name + ", " + bluetoothDevice.getAddress + ")"
} }

View file

@ -4,7 +4,7 @@ import java.io._
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.util.Log 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 import org.msgpack.ScalaMessagePack
/** /**
@ -22,16 +22,6 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi
private val Tag: String = "TransferThread" 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 = val InStream: InputStream =
try { try {
socket.getInputStream socket.getInputStream
@ -56,53 +46,54 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi
while (socket.isConnected) { while (socket.isConnected) {
try { try {
val up = new ScalaMessagePack().createUnpacker(InStream) val up = new ScalaMessagePack().createUnpacker(InStream)
val plain = up.readBoolean() match { val isEncrypted = up.readBoolean()
case MessageEncrypted => val plain =
if (isEncrypted) {
val encrypted = up.readByteArray() val encrypted = up.readByteArray()
val key = up.readByteArray() val key = up.readByteArray()
crypto.decrypt(encrypted, key) crypto.decrypt(encrypted, key)
case MessageUnencrypted => } else {
up.readByteArray() up.readByteArray()
} }
val (message, signature) = Message.read(plain) val (message, signature) = Message.read(plain)
var messageValid = true var messageValid = true
if (message.sender != device.id) { if (message.sender != device.Id) {
Log.i(Tag, "Dropping message with invalid sender from " + device.id) Log.i(Tag, "Dropping message with invalid sender from " + device.Id)
messageValid = false messageValid = false
} }
if (message.receiver != service.localDeviceId) { 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 messageValid = false
} }
// Add public key for new, directly connected device. // Add public key for new, directly connected device.
// Explicitly check that message was not forwarded or spoofed. // Explicitly check that message was not forwarded or spoofed.
if (message.isInstanceOf[DeviceInfoMessage] && !crypto.havePublicKey(message.sender) && if (message.isInstanceOf[DeviceInfoMessage] && !crypto.havePublicKey(message.sender) &&
message.sender == device.id) { message.sender == device.Id) {
val dim = message.asInstanceOf[DeviceInfoMessage] val dim = message.asInstanceOf[DeviceInfoMessage]
// Permanently store public key for new local devices (also check signature). // Permanently store public key for new local devices (also check signature).
if (crypto.isValidSignature(message, signature, dim.publicKey)) { if (crypto.isValidSignature(message, signature, dim.publicKey)) {
crypto.addPublicKey(device.id, dim.publicKey) crypto.addPublicKey(device.Id, dim.publicKey)
Log.i(Tag, "Added public key for new device " + device.name) Log.i(Tag, "Added public key for new device " + device.Name)
} }
} }
if (!crypto.isValidSignature(message, signature)) { 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 messageValid = false
} }
if (messageValid) { if (messageValid) {
message match { message match {
case m: TextMessage => onReceive(m)
case m: DeviceInfoMessage => crypto.addPublicKey(message.sender, m.publicKey) case m: DeviceInfoMessage => crypto.addPublicKey(message.sender, m.publicKey)
case _ => onReceive(message)
} }
} }
} catch { } catch {
case e: IOException => 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) service.onConnectionChanged(new Device(device.bluetoothDevice, false), null)
return return
} }
@ -118,11 +109,13 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi
message.messageType match { message.messageType match {
case Message.Type.Text => case Message.Type.Text =>
val (encrypted, key) = crypto.encrypt(message.receiver, plain) val (encrypted, key) = crypto.encrypt(message.receiver, plain)
packer.write(MessageEncrypted) // Message is encrypted.
packer.write(true)
.write(encrypted) .write(encrypted)
.write(key) .write(key)
case Message.Type.DeviceInfo => case _ =>
packer.write(MessageUnencrypted) // Message is not encrypted.
packer.write(false)
.write(plain) .write(plain)
} }
} catch { } catch {

View file

@ -3,8 +3,7 @@ package com.nutomic.ensichat.fragments
import java.util.Date import java.util.Date
import android.app.ListFragment import android.app.ListFragment
import android.content.{ComponentName, Context, Intent, ServiceConnection} import android.os.Bundle
import android.os.{Bundle, IBinder}
import android.view.View.OnClickListener import android.view.View.OnClickListener
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.{KeyEvent, LayoutInflater, View, ViewGroup} import android.view.{KeyEvent, LayoutInflater, View, ViewGroup}
@ -13,7 +12,7 @@ import android.widget._
import com.nutomic.ensichat.R import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.EnsiChatActivity import com.nutomic.ensichat.activities.EnsiChatActivity
import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener 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.messages.{Message, TextMessage}
import com.nutomic.ensichat.util.MessagesAdapter import com.nutomic.ensichat.util.MessagesAdapter
@ -51,7 +50,8 @@ class ChatFragment extends ListFragment with OnClickListener
// Read local device ID from service, // Read local device ID from service,
adapter = new MessagesAdapter(getActivity, chatService.localDeviceId) adapter = new MessagesAdapter(getActivity, chatService.localDeviceId)
chatService.registerMessageListener(device, ChatFragment.this) chatService.registerMessageListener(ChatFragment.this)
onMessageReceived(chatService.database.getMessages(device, 10))
if (listView != null) { if (listView != null) {
listView.setAdapter(adapter) listView.setAdapter(adapter)
@ -100,6 +100,10 @@ class ChatFragment extends ListFragment with OnClickListener
case R.id.send => case R.id.send =>
val text: String = messageText.getText.toString val text: String = messageText.getText.toString
if (!text.isEmpty) { if (!text.isEmpty) {
if (!chatService.isConnected(device)) {
Toast.makeText(getActivity, R.string.contact_offline_toast, Toast.LENGTH_SHORT).show()
return
}
chatService.send( chatService.send(
new TextMessage(chatService.localDeviceId, device, new Date(), text.toString)) new TextMessage(chatService.localDeviceId, device, new Date(), text.toString))
messageText.getText.clear() messageText.getText.clear()
@ -111,7 +115,8 @@ class ChatFragment extends ListFragment with OnClickListener
* Displays new messages in UI. * Displays new messages in UI.
*/ */
override def onMessageReceived(messages: SortedSet[Message]): Unit = { 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])) .foreach(m => adapter.add(m.asInstanceOf[TextMessage]))
} }

View file

@ -1,28 +1,36 @@
package com.nutomic.ensichat.fragments package com.nutomic.ensichat.fragments
import android.app.ListFragment import android.app.ListFragment
import android.content.{ComponentName, Context, Intent, ServiceConnection} import android.content.Intent
import android.os.{Bundle, IBinder} import android.os.Bundle
import android.view._ import android.view._
import android.widget.{ArrayAdapter, ListView} import android.widget.ListView
import com.nutomic.ensichat.R import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.{SettingsActivity, EnsiChatActivity, MainActivity} import com.nutomic.ensichat.activities.{AddContactsActivity, EnsiChatActivity, MainActivity, SettingsActivity}
import com.nutomic.ensichat.bluetooth.{ChatService, ChatServiceBinder, Device} import com.nutomic.ensichat.bluetooth.ChatService
import com.nutomic.ensichat.util.{MessagesAdapter, DevicesAdapter} import com.nutomic.ensichat.util.DevicesAdapter
/** /**
* Lists all nearby, connected devices. * 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 = { private lazy val Database = getActivity.asInstanceOf[EnsiChatActivity].service.database
super.onActivityCreated(savedInstanceState)
val activity = getActivity.asInstanceOf[EnsiChatActivity] override def onCreate(savedInstanceState: Bundle): Unit = {
activity.runOnServiceConnected(() => { super.onCreate(savedInstanceState)
activity.service.registerConnectionListener(ContactsFragment.this)
setListAdapter(Adapter)
setHasOptionsMenu(true)
getActivity.asInstanceOf[EnsiChatActivity].runOnServiceConnected(() => {
Database.getContacts.foreach(Adapter.add)
Database.runOnContactsUpdated(() => {
Adapter.clear()
Database.getContacts.foreach(Adapter.add)
})
}) })
} }
@ -30,13 +38,6 @@ class ContactsFragment extends ListFragment with ChatService.OnConnectionChanged
savedInstanceState: Bundle): View = savedInstanceState: Bundle): View =
inflater.inflate(R.layout.fragment_contacts, container, false) inflater.inflate(R.layout.fragment_contacts, container, false)
override def onCreate(savedInstanceState: Bundle): Unit = {
super.onCreate(savedInstanceState)
setListAdapter(adapter)
setHasOptionsMenu(true)
}
override def onCreateOptionsMenu(menu: Menu, inflater: MenuInflater): Unit = { override def onCreateOptionsMenu(menu: Menu, inflater: MenuInflater): Unit = {
super.onCreateOptionsMenu(menu, inflater) super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.main, menu) inflater.inflate(R.menu.main, menu)
@ -44,6 +45,9 @@ class ContactsFragment extends ListFragment with ChatService.OnConnectionChanged
override def onOptionsItemSelected(item: MenuItem): Boolean = { override def onOptionsItemSelected(item: MenuItem): Boolean = {
item.getItemId match { item.getItemId match {
case R.id.add_contact =>
startActivity(new Intent(getActivity, classOf[AddContactsActivity]))
true
case R.id.settings => case R.id.settings =>
startActivity(new Intent(getActivity, classOf[SettingsActivity])) startActivity(new Intent(getActivity, classOf[SettingsActivity]))
true 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. * Opens a chat with the clicked device.
*/ */
override def onListItemClick(l: ListView, v: View, position: Int, id: Long): Unit = 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)
} }

View file

@ -2,7 +2,6 @@ package com.nutomic.ensichat.fragments
import android.os.Bundle import android.os.Bundle
import android.preference.PreferenceFragment import android.preference.PreferenceFragment
import com.nutomic.ensichat.R import com.nutomic.ensichat.R
class SettingsFragment extends PreferenceFragment { class SettingsFragment extends PreferenceFragment {

View file

@ -11,11 +11,13 @@ object Message {
/** /**
* Types of messages that can be transfered. * 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 { object Type {
val Text = 1 val Text = 1
val DeviceInfo = 2 val DeviceInfo = 2
val RequestAddContact = 3
val ResultAddContact = 4
} }
/** /**
@ -42,6 +44,8 @@ object Message {
(messageType match { (messageType match {
case Type.Text => TextMessage.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.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) }, sig)
} }

View file

@ -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 = {
}
}

View file

@ -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 + ")"
}

View file

@ -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 + ")"
}

View file

@ -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 = {
}
}

View file

@ -14,7 +14,7 @@ class DevicesAdapter(context: Context) extends
override def getView(position: Int, convertView: View, parent: ViewGroup): View = { override def getView(position: Int, convertView: View, parent: ViewGroup): View = {
val view = super.getView(position, convertView, parent) val view = super.getView(position, convertView, parent)
val title: TextView = view.findViewById(android.R.id.text1).asInstanceOf[TextView] val title: TextView = view.findViewById(android.R.id.text1).asInstanceOf[TextView]
title.setText(getItem(position).name) title.setText(getItem(position).Name)
view view
} }