Added user name.

This commit is contained in:
Felix Ableitner 2015-01-29 17:33:55 +01:00
parent 647f946586
commit 5a460c9527
20 changed files with 319 additions and 121 deletions

View file

@ -113,7 +113,7 @@ message.
/ / / /
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
/ / / /
\ Key (variable length) \ \ Key (variable length) \
/ / / /
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
@ -124,7 +124,6 @@ Signature is the cryptographic signature over the (unencrypted) message
header and message body. header and message body.
ConnectionInfo (Type = 0) ConnectionInfo (Type = 0)
--------- ---------
@ -142,8 +141,8 @@ that node can only be sent once a new ConnectionInfo message
for it has been received. for it has been received.
This key is to be used for message This key is to be used for message encryption when communicating
encryption when communicating with the sending node. with the sending node.
0 1 2 3 0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
@ -189,6 +188,7 @@ Accepted bit (A) is true if the user accepts the new contact, false
otherwise. Nodes should only add another node as a contact if both otherwise. Nodes should only add another node as a contact if both
users agreed. users agreed.
### Text (Type = 6) ### Text (Type = 6)
A simple chat message. A simple chat message.
@ -208,3 +208,17 @@ A simple chat message.
Time is the unix timestamp of message sending. Time is the unix timestamp of message sending.
Text the string to be transferred, encoded as UTF-8. Text the string to be transferred, encoded as UTF-8.
### UserName (Type = 7)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Name Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
/ /
\ Name (variable length) \
/ /
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Contains the sender's name, which should be used for display to users.

View file

@ -0,0 +1,9 @@
package com.nutomic.ensichat.protocol
object UserTest {
val u1 = new User(AddressTest.a1, "one")
val u2 = new User(AddressTest.a2, "two")
}

View file

@ -0,0 +1,15 @@
package com.nutomic.ensichat.protocol.messages
import android.test.AndroidTestCase
import junit.framework.Assert
class UserNameTest extends AndroidTestCase {
def testWriteRead(): Unit = {
val name = new UserName("name")
val bytes = name.write
val body = UserName.read(bytes)
Assert.assertEquals(name, body.asInstanceOf[UserName])
}
}

View file

@ -1,6 +1,5 @@
package com.nutomic.ensichat.util package com.nutomic.ensichat.util
import java.io.File
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import android.content.Context import android.content.Context
@ -8,8 +7,7 @@ import android.database.DatabaseErrorHandler
import android.database.sqlite.SQLiteDatabase 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.protocol.AddressTest import com.nutomic.ensichat.protocol.UserTest
import com.nutomic.ensichat.protocol.messages.MessageTest
import com.nutomic.ensichat.protocol.messages.MessageTest._ import com.nutomic.ensichat.protocol.messages.MessageTest._
import junit.framework.Assert._ import junit.framework.Assert._
@ -25,9 +23,10 @@ class DatabaseTest extends AndroidTestCase {
private var dbFile: String = _ private var dbFile: String = _
private lazy val Database = new Database(new TestContext(getContext)) private var Database: Database = _
override def setUp(): Unit = { override def setUp(): Unit = {
Database = new Database(new TestContext(getContext))
Database.addMessage(m1) Database.addMessage(m1)
Database.addMessage(m2) Database.addMessage(m2)
Database.addMessage(m3) Database.addMessage(m3)
@ -35,7 +34,8 @@ class DatabaseTest extends AndroidTestCase {
override def tearDown(): Unit = { override def tearDown(): Unit = {
super.tearDown() super.tearDown()
new File(dbFile).delete() Database.close()
getContext.deleteDatabase(dbFile)
} }
def testMessageCount(): Unit = { def testMessageCount(): Unit = {
@ -58,11 +58,10 @@ class DatabaseTest extends AndroidTestCase {
} }
def testAddContact(): Unit = { def testAddContact(): Unit = {
Database.addContact(AddressTest.a1) Database.addContact(UserTest.u1)
assertTrue(Database.isContact(AddressTest.a1))
val contacts = Database.getContacts val contacts = Database.getContacts
assertEquals(1, contacts.size) assertEquals(1, contacts.size)
contacts.foreach{assertEquals(AddressTest.a1, _)} assertEquals(Some(UserTest.u1), Database.getContact(UserTest.u1.Address))
} }
def testAddContactCallback(): Unit = { def testAddContactCallback(): Unit = {
@ -70,8 +69,16 @@ class DatabaseTest extends AndroidTestCase {
Database.runOnContactsUpdated(() => { Database.runOnContactsUpdated(() => {
latch.countDown() latch.countDown()
}) })
Database.addContact(AddressTest.a1) Database.addContact(UserTest.u1)
latch.await() latch.await()
} }
def testGetContact(): Unit = {
Database.addContact(UserTest.u2)
assertTrue(Database.getContact(UserTest.u1.Address).isEmpty)
val c = Database.getContact(UserTest.u2.Address)
assertTrue(c.nonEmpty)
assertEquals(Some(UserTest.u2), c)
}
} }

View file

@ -57,6 +57,9 @@
<!-- Activity title --> <!-- Activity title -->
<string name="settings">Settings</string> <string name="settings">Settings</string>
<!-- Preference title -->
<string name="user_name">Name</string>
<!-- Preference title --> <!-- Preference title -->
<string name="scan_interval_seconds">Scan Interval (seconds)</string> <string name="scan_interval_seconds">Scan Interval (seconds)</string>

View file

@ -2,6 +2,10 @@
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"> <PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<EditTextPreference
android:title="@string/user_name"
android:key="user_name" />
<EditTextPreference <EditTextPreference
android:title="@string/scan_interval_seconds" android:title="@string/scan_interval_seconds"
android:key="scan_interval_seconds" android:key="scan_interval_seconds"

View file

@ -11,8 +11,8 @@ import android.widget.AdapterView.OnItemClickListener
import android.widget._ import android.widget._
import com.nutomic.ensichat.R import com.nutomic.ensichat.R
import com.nutomic.ensichat.protocol.messages.{Message, RequestAddContact, ResultAddContact} import com.nutomic.ensichat.protocol.messages.{Message, RequestAddContact, ResultAddContact}
import com.nutomic.ensichat.protocol.{Address, ChatService, Crypto} import com.nutomic.ensichat.protocol.{User, Address, ChatService, Crypto}
import com.nutomic.ensichat.util.{DevicesAdapter, IdenticonGenerator} import com.nutomic.ensichat.util.{UsersAdapter, IdenticonGenerator}
import scala.collection.SortedSet import scala.collection.SortedSet
@ -26,7 +26,7 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
private val Tag = "AddContactsActivity" private val Tag = "AddContactsActivity"
private lazy val Adapter = new DevicesAdapter(this) private lazy val Adapter = new UsersAdapter(this)
private lazy val Database = service.Database private lazy val Database = service.Database
@ -35,7 +35,7 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
/** /**
* Map of devices that should be added. * Map of devices that should be added.
*/ */
private var currentlyAdding = Map[Address, AddContactInfo]() private var currentlyAdding = Map[User, AddContactInfo]()
.withDefaultValue(new AddContactInfo(false, false)) .withDefaultValue(new AddContactInfo(false, false))
/** /**
@ -44,7 +44,7 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
* @param localConfirmed If true, the local user has accepted adding the contact. * @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. * @param remoteConfirmed If true, the remote contact has accepted adding this device as contact.
*/ */
private class AddContactInfo(val localConfirmed: Boolean, val remoteConfirmed: Boolean) private case class AddContactInfo(localConfirmed: Boolean, remoteConfirmed: Boolean)
/** /**
* Initializes layout, registers connection and message listeners. * Initializes layout, registers connection and message listeners.
@ -68,11 +68,11 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
/** /**
* Displays newly connected devices in the list. * Displays newly connected devices in the list.
*/ */
override def onConnectionsChanged(devices: Set[Address]): Unit = { override def onConnectionsChanged(contacts: Set[User]): Unit = {
runOnUiThread(new Runnable { runOnUiThread(new Runnable {
override def run(): Unit = { override def run(): Unit = {
Adapter.clear() Adapter.clear()
devices.foreach(Adapter.add) contacts.foreach(Adapter.add)
} }
}) })
} }
@ -81,32 +81,32 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
* Initiates adding the device as contact if it hasn't been added yet. * 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 = { override def onItemClick(parent: AdapterView[_], view: View, position: Int, id: Long): Unit = {
val address = Adapter.getItem(position) val contact = Adapter.getItem(position)
if (Database.isContact(address)) { if (Database.getContact(contact.Address).nonEmpty) {
Toast.makeText(this, R.string.contact_already_added, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.contact_already_added, Toast.LENGTH_SHORT).show()
return return
} }
service.sendTo(address, new RequestAddContact()) service.sendTo(contact.Address, new RequestAddContact())
addDeviceDialog(address) addDeviceDialog(contact)
} }
/** /**
* Shows a dialog to accept/deny adding a device as a new contact. * Shows a dialog to accept/deny adding a device as a new contact.
*/ */
private def addDeviceDialog(address: Address): Unit = { private def addDeviceDialog(contact: User): Unit = {
// Listener for dialog button clicks. // Listener for dialog button clicks.
val onClick = new OnClickListener { val onClick = new OnClickListener {
override def onClick(dialogInterface: DialogInterface, i: Int): Unit = i match { override def onClick(dialogInterface: DialogInterface, i: Int): Unit = i match {
case DialogInterface.BUTTON_POSITIVE => case DialogInterface.BUTTON_POSITIVE =>
// Local user accepted contact, update state and send info to other device. // Local user accepted contact, update state and send info to other device.
currentlyAdding += currentlyAdding +=
(address -> new AddContactInfo(currentlyAdding(address).localConfirmed, true)) (contact -> new AddContactInfo(currentlyAdding(contact).localConfirmed, true))
addContactIfBothConfirmed(address) addContactIfBothConfirmed(contact)
service.sendTo(address, new ResultAddContact(true)) service.sendTo(contact.Address, new ResultAddContact(true))
case DialogInterface.BUTTON_NEGATIVE => case DialogInterface.BUTTON_NEGATIVE =>
// Local user denied adding contact, send info to other device. // Local user denied adding contact, send info to other device.
service.sendTo(address, new ResultAddContact(false)) service.sendTo(contact.Address, new ResultAddContact(false))
} }
} }
@ -117,12 +117,12 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
local.setImageBitmap( local.setImageBitmap(
IdenticonGenerator.generate(Crypto.getLocalAddress, (150, 150), this)) IdenticonGenerator.generate(Crypto.getLocalAddress, (150, 150), this))
val remoteTitle = view.findViewById(R.id.remote_identicon_title).asInstanceOf[TextView] val remoteTitle = view.findViewById(R.id.remote_identicon_title).asInstanceOf[TextView]
remoteTitle.setText(getString(R.string.remote_fingerprint_title, address)) remoteTitle.setText(getString(R.string.remote_fingerprint_title, contact.Name))
val remote = view.findViewById(R.id.remote_identicon).asInstanceOf[ImageView] val remote = view.findViewById(R.id.remote_identicon).asInstanceOf[ImageView]
remote.setImageBitmap(IdenticonGenerator.generate(address, (150, 150), this)) remote.setImageBitmap(IdenticonGenerator.generate(contact.Address, (150, 150), this))
new AlertDialog.Builder(this) new AlertDialog.Builder(this)
.setTitle(getString(R.string.add_contact_dialog, address)) .setTitle(getString(R.string.add_contact_dialog, contact.Name))
.setView(view) .setView(view)
.setPositiveButton(android.R.string.yes, onClick) .setPositiveButton(android.R.string.yes, onClick)
.setNegativeButton(android.R.string.no, onClick) .setNegativeButton(android.R.string.no, onClick)
@ -140,19 +140,20 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
.foreach{ .foreach{
case m if m.Body.isInstanceOf[RequestAddContact] => case m if m.Body.isInstanceOf[RequestAddContact] =>
Log.i(Tag, "Remote device " + m.Header.Origin + " wants to add us as a contact, showing dialog") Log.i(Tag, "Remote device " + m.Header.Origin + " wants to add us as a contact, showing dialog")
addDeviceDialog(m.Header.Origin) service.getConnections.find(_.Address == m.Header.Origin).foreach(addDeviceDialog)
case m if m.Body.isInstanceOf[ResultAddContact] => case m if m.Body.isInstanceOf[ResultAddContact] =>
val origin = m.Header.Origin currentlyAdding.keys.find(_.Address == m.Header.Origin)foreach(contact =>
if (m.Body.asInstanceOf[ResultAddContact].Accepted) { if (m.Body.asInstanceOf[ResultAddContact].Accepted) {
Log.i(Tag, "Remote device " + origin + " accepted us as a contact, updating state") Log.i(Tag, contact.toString + " accepted us as a contact, updating state")
currentlyAdding += (origin -> currentlyAdding += (contact ->
new AddContactInfo(true, currentlyAdding(origin).remoteConfirmed)) new AddContactInfo(true, currentlyAdding(contact).remoteConfirmed))
addContactIfBothConfirmed(origin) addContactIfBothConfirmed(contact)
} else { } else {
Log.i(Tag, "Remote device " + origin + " denied us as a contact, showing toast") Log.i(Tag, contact.toString + " denied us as a contact, showing toast")
Toast.makeText(this, R.string.contact_not_added, Toast.LENGTH_LONG).show() Toast.makeText(this, R.string.contact_not_added, Toast.LENGTH_LONG).show()
currentlyAdding -= origin currentlyAdding -= contact
} }
)
case _ => case _ =>
} }
} }
@ -161,21 +162,21 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection
* Add the given device to contacts if [[AddContactInfo.localConfirmed]] and * Add the given device to contacts if [[AddContactInfo.localConfirmed]] and
* [[AddContactInfo.remoteConfirmed]] are true for it in [[currentlyAdding]]. * [[AddContactInfo.remoteConfirmed]] are true for it in [[currentlyAdding]].
*/ */
private def addContactIfBothConfirmed(address: Address): Unit = { private def addContactIfBothConfirmed(contact: User): Unit = {
val info = currentlyAdding(address) val info = currentlyAdding(contact)
if (info.localConfirmed && info.remoteConfirmed) { if (info.localConfirmed && info.remoteConfirmed) {
Log.i(Tag, "Adding new contact " + address.toString) Log.i(Tag, "Adding new contact " + contact.toString)
Database.addContact(address) Database.addContact(contact)
Toast.makeText(this, getString(R.string.contact_added, address.toString), Toast.LENGTH_SHORT) Toast.makeText(this, getString(R.string.contact_added, contact.Name), Toast.LENGTH_SHORT)
.show() .show()
currentlyAdding -= address currentlyAdding -= contact
} }
} }
override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match { override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match {
case android.R.id.home => case android.R.id.home =>
NavUtils.navigateUpFromSameTask(this) NavUtils.navigateUpFromSameTask(this)
true; true
case _ => case _ =>
super.onOptionsItemSelected(item); super.onOptionsItemSelected(item);
} }

View file

@ -96,6 +96,7 @@ class MainActivity extends EnsiChatActivity {
.commit() .commit()
currentChat = None currentChat = None
getActionBar.setDisplayHomeAsUpEnabled(false) getActionBar.setDisplayHomeAsUpEnabled(false)
setTitle(R.string.app_name)
} else } else
super.onBackPressed() super.onBackPressed()
} }

View file

@ -171,13 +171,11 @@ class BluetoothInterface(Service: ChatService, Crypto: Crypto) extends Interface
*/ */
private def onReceiveMessage(msg: Message, device: Device.ID): Unit = msg.Body match { private def onReceiveMessage(msg: Message, device: Device.ID): Unit = msg.Body match {
case info: ConnectionInfo => case info: ConnectionInfo =>
val sender = Service.onConnectionOpened(msg) val address = Crypto.calculateAddress(info.key)
sender match { // Service.onConnectionOpened sends message, so mapping already needs to be in place.
case Some(s) => AddressDeviceMap.put(address, device)
AddressDeviceMap.put(Crypto.calculateAddress(info.key), device) if (!Service.onConnectionOpened(msg))
Service.callConnectionListeners() AddressDeviceMap.remove(address)
case None =>
}
case _ => case _ =>
Service.onMessageReceived(msg) Service.onMessageReceived(msg)
} }

View file

@ -9,7 +9,7 @@ import android.widget.TextView.OnEditorActionListener
import android.widget._ 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.protocol.{ChatService, Address} import com.nutomic.ensichat.protocol.{User, ChatService, Address}
import com.nutomic.ensichat.protocol.ChatService.OnMessageReceivedListener import com.nutomic.ensichat.protocol.ChatService.OnMessageReceivedListener
import com.nutomic.ensichat.protocol.messages.{Message, Text} import com.nutomic.ensichat.protocol.messages.{Message, Text}
import com.nutomic.ensichat.util.{Database, MessagesAdapter} import com.nutomic.ensichat.util.{Database, MessagesAdapter}
@ -49,6 +49,9 @@ class ChatFragment extends ListFragment with OnClickListener
activity.runOnServiceConnected(() => { activity.runOnServiceConnected(() => {
chatService = activity.service chatService = activity.service
chatService.Database.getContact(address)
.foreach(c => getActivity.setTitle(c.Name))
// Read local device ID from service, // Read local device ID from service,
adapter = new MessagesAdapter(getActivity, address) adapter = new MessagesAdapter(getActivity, address)
chatService.registerMessageListener(ChatFragment.this) chatService.registerMessageListener(ChatFragment.this)
@ -114,9 +117,4 @@ class ChatFragment extends ListFragment with OnClickListener
.foreach(adapter.add) .foreach(adapter.add)
} }
/**
* Returns the device that this fragment shows chats for.
*/
def getDevice = address
} }

View file

@ -8,14 +8,14 @@ import android.widget.ListView
import com.nutomic.ensichat.R import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.{AddContactsActivity, EnsiChatActivity, MainActivity, SettingsActivity} import com.nutomic.ensichat.activities.{AddContactsActivity, EnsiChatActivity, MainActivity, SettingsActivity}
import com.nutomic.ensichat.protocol.ChatService import com.nutomic.ensichat.protocol.ChatService
import com.nutomic.ensichat.util.DevicesAdapter import com.nutomic.ensichat.util.UsersAdapter
/** /**
* Lists all nearby, connected devices. * Lists all nearby, connected devices.
*/ */
class ContactsFragment extends ListFragment { class ContactsFragment extends ListFragment {
private lazy val Adapter = new DevicesAdapter(getActivity) private lazy val Adapter = new UsersAdapter(getActivity)
private lazy val Database = getActivity.asInstanceOf[EnsiChatActivity].service.Database private lazy val Database = getActivity.asInstanceOf[EnsiChatActivity].service.Database
@ -28,8 +28,12 @@ class ContactsFragment extends ListFragment {
getActivity.asInstanceOf[EnsiChatActivity].runOnServiceConnected(() => { getActivity.asInstanceOf[EnsiChatActivity].runOnServiceConnected(() => {
Database.getContacts.foreach(Adapter.add) Database.getContacts.foreach(Adapter.add)
Database.runOnContactsUpdated(() => { Database.runOnContactsUpdated(() => {
Adapter.clear() getActivity.runOnUiThread(new Runnable {
Database.getContacts.foreach(Adapter.add) override def run(): Unit = {
Adapter.clear()
Database.getContacts.foreach(Adapter.add)
}
})
}) })
}) })
} }
@ -64,6 +68,6 @@ class ContactsFragment extends ListFragment {
* 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)) getActivity.asInstanceOf[MainActivity].openChat(Adapter.getItem(position).Address)
} }

View file

@ -1,15 +1,54 @@
package com.nutomic.ensichat.fragments package com.nutomic.ensichat.fragments
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.preference.PreferenceFragment import android.preference.Preference.OnPreferenceChangeListener
import android.preference.{PreferenceManager, Preference, PreferenceFragment}
import android.util.Log
import com.nutomic.ensichat.R import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.EnsiChatActivity
import com.nutomic.ensichat.protocol.Address
import com.nutomic.ensichat.protocol.messages.UserName
import com.nutomic.ensichat.fragments.SettingsFragment._
class SettingsFragment extends PreferenceFragment { object SettingsFragment {
val KeyUserName = "user_name"
val KeyScanInterval = "scan_interval_seconds"
}
/**
* Settings screen.
*/
class SettingsFragment extends PreferenceFragment with OnPreferenceChangeListener {
override def onCreate(savedInstanceState: Bundle): Unit = { override def onCreate(savedInstanceState: Bundle): Unit = {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
addPreferencesFromResource(R.xml.settings) addPreferencesFromResource(R.xml.settings)
val name = findPreference(KeyUserName)
name.setOnPreferenceChangeListener(this)
val scanInterval = findPreference(KeyScanInterval)
scanInterval.setOnPreferenceChangeListener(this)
val pm = PreferenceManager.getDefaultSharedPreferences(getActivity)
name.setSummary(pm.getString(KeyUserName, ""))
scanInterval.setSummary(pm.getString(KeyScanInterval, "15"))
}
/**
* Updates summary, sends updated name to contacts.
*/
override def onPreferenceChange(preference: Preference, newValue: AnyRef): Boolean = {
if (preference.getKey == KeyUserName) {
val service = getActivity.asInstanceOf[EnsiChatActivity].service
service.Database.getContacts
.foreach(c => service.sendTo(c.Address, new UserName(newValue.toString)))
}
preference.setSummary(newValue.toString)
true
} }
} }

View file

@ -1,15 +1,19 @@
package com.nutomic.ensichat.protocol package com.nutomic.ensichat.protocol
import android.app.Service import android.app.Service
import android.bluetooth.BluetoothAdapter
import android.content.Intent import android.content.Intent
import android.os.Handler import android.os.Handler
import android.preference.PreferenceManager
import android.util.Log import android.util.Log
import com.nutomic.ensichat.bluetooth.BluetoothInterface import com.nutomic.ensichat.bluetooth.BluetoothInterface
import com.nutomic.ensichat.fragments.SettingsFragment
import com.nutomic.ensichat.protocol.ChatService.{OnMessageReceivedListener, OnConnectionsChangedListener} import com.nutomic.ensichat.protocol.ChatService.{OnMessageReceivedListener, OnConnectionsChangedListener}
import com.nutomic.ensichat.protocol.messages.{ConnectionInfo, Message, MessageBody, MessageHeader} import com.nutomic.ensichat.protocol.messages._
import com.nutomic.ensichat.util.Database import com.nutomic.ensichat.util.Database
import scala.collection.SortedSet import scala.collection.SortedSet
import scala.collection.immutable.HashMap
import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future import scala.concurrent.Future
import scala.ref.WeakReference import scala.ref.WeakReference
@ -35,7 +39,7 @@ object ChatService {
* connects or disconnects * connects or disconnects
*/ */
trait OnConnectionsChangedListener { trait OnConnectionsChangedListener {
def onConnectionsChanged(devices: Set[Address]): Unit def onConnectionsChanged(contacts: Set[User]): Unit
} }
} }
@ -66,17 +70,29 @@ class ChatService extends Service {
private var messageListeners = Set[WeakReference[OnMessageReceivedListener]]() private var messageListeners = Set[WeakReference[OnMessageReceivedListener]]()
/**
* Holds all known users.
*
* This is for user names that were received during runtime, and is not persistent.
*/
private var connections = Set[User]()
/** /**
* Generates keys and starts Bluetooth interface. * Generates keys and starts Bluetooth interface.
*/ */
override def onCreate(): Unit = { override def onCreate(): Unit = {
super.onCreate() super.onCreate()
val pm = PreferenceManager.getDefaultSharedPreferences(this)
if (pm.getString(SettingsFragment.KeyUserName, null) == null)
pm.edit().putString(SettingsFragment.KeyUserName,
BluetoothAdapter.getDefaultAdapter.getName).apply()
Future { Future {
Crypto.generateLocalKeys() Crypto.generateLocalKeys()
Log.i(Tag, "Service started, address is " + Crypto.getLocalAddress)
BluetoothInterface.create() BluetoothInterface.create()
Log.i(Tag, "Service started, address is " + Crypto.getLocalAddress)
} }
} }
@ -100,7 +116,7 @@ class ChatService extends Service {
*/ */
def registerConnectionListener(listener: OnConnectionsChangedListener): Unit = { def registerConnectionListener(listener: OnConnectionsChangedListener): Unit = {
connectionListeners += new WeakReference[OnConnectionsChangedListener](listener) connectionListeners += new WeakReference[OnConnectionsChangedListener](listener)
listener.onConnectionsChanged(BluetoothInterface.getConnections) listener.onConnectionsChanged(getConnections)
} }
/** /**
@ -132,17 +148,23 @@ class ChatService extends Service {
} }
/** /**
* Calls all [[OnMessageReceivedListener]]s with the new message. * Handles all (locally and remotely sent) new messages.
*
* This function is called both for locally and remotely sent messages.
*/ */
private def onNewMessage(msg: Message): Unit = { private def onNewMessage(msg: Message): Unit = msg.Body match {
Database.addMessage(msg) case name: UserName =>
MainHandler.post(new Runnable { val contact = new User(msg.Header.Origin, name.Name)
override def run(): Unit = connections += contact
messageListeners if (Database.getContact(msg.Header.Origin).nonEmpty)
.filter(_.get.nonEmpty) Database.changeContactName(contact)
.foreach(_.apply().onMessageReceived(SortedSet(msg)(Message.Ordering)))
callConnectionListeners()
case _ =>
Database.addMessage(msg)
MainHandler.post(new Runnable {
override def run(): Unit =
messageListeners
.filter(_.get.nonEmpty)
.foreach(_.apply().onMessageReceived(SortedSet(msg)(Message.Ordering)))
}) })
} }
@ -154,20 +176,20 @@ class ChatService extends Service {
* *
* The caller must invoke [[callConnectionListeners()]] * The caller must invoke [[callConnectionListeners()]]
* *
* @param infoMsg The message containing [[ConnectionInfo]] to open the connection. * @param msg The message containing [[ConnectionInfo]] to open the connection.
* @return True if the connection is valid * @return True if the connection is valid
*/ */
def onConnectionOpened(infoMsg: Message): Option[Address] = { def onConnectionOpened(msg: Message): Boolean = {
val info = infoMsg.Body.asInstanceOf[ConnectionInfo] val info = msg.Body.asInstanceOf[ConnectionInfo]
val sender = Crypto.calculateAddress(info.key) val sender = Crypto.calculateAddress(info.key)
if (sender == Address.Broadcast || sender == Address.Null) { if (sender == Address.Broadcast || sender == Address.Null) {
Log.i(Tag, "Ignoring ConnectionInfo message with invalid sender " + sender) Log.i(Tag, "Ignoring ConnectionInfo message with invalid sender " + sender)
None false
} }
if (Crypto.havePublicKey(sender) && !Crypto.verify(infoMsg, Crypto.getPublicKey(sender))) { if (Crypto.havePublicKey(sender) && !Crypto.verify(msg, Crypto.getPublicKey(sender))) {
Log.i(Tag, "Ignoring ConnectionInfo message with invalid signature") Log.i(Tag, "Ignoring ConnectionInfo message with invalid signature")
None false
} }
if (!Crypto.havePublicKey(sender)) { if (!Crypto.havePublicKey(sender)) {
@ -176,7 +198,10 @@ class ChatService extends Service {
} }
Log.i(Tag, "Node " + sender + " connected") Log.i(Tag, "Node " + sender + " connected")
Some(sender) val name = PreferenceManager.getDefaultSharedPreferences(this).getString("user_name", null)
sendTo(sender, new UserName(name))
callConnectionListeners()
true
} }
/** /**
@ -184,9 +209,22 @@ class ChatService extends Service {
* *
* Should be called whenever a neighbor connects or disconnects. * Should be called whenever a neighbor connects or disconnects.
*/ */
def callConnectionListeners(): Unit = def callConnectionListeners(): Unit = {
connectionListeners connectionListeners
.filter(_ != None) .filter(_.get.nonEmpty)
.foreach(_.apply().onConnectionsChanged(BluetoothInterface.getConnections)) .foreach(_.apply().onConnectionsChanged(getConnections))
}
/**
* Returns all direct neighbors.
*/
def getConnections: Set[User] = {
BluetoothInterface.getConnections.map{ address =>
(Database.getContacts ++ connections).find(_.Address == address) match {
case Some(contact) => contact
case None => new User(address, address.toString)
}
}
}
} }

View file

@ -257,11 +257,12 @@ class Crypto(Context: Context) {
// Symmetric decryption of data // Symmetric decryption of data
val symmetricCipher = Cipher.getInstance(SymmetricCipherAlgorithm) val symmetricCipher = Cipher.getInstance(SymmetricCipherAlgorithm)
symmetricCipher.init(Cipher.DECRYPT_MODE, key) symmetricCipher.init(Cipher.DECRYPT_MODE, key)
val decryped = copyThroughCipher(symmetricCipher, msg.Body.asInstanceOf[EncryptedBody].Data) val decrypted = copyThroughCipher(symmetricCipher, msg.Body.asInstanceOf[EncryptedBody].Data)
val body = msg.Header.MessageType match { val body = msg.Header.MessageType match {
case RequestAddContact.Type => RequestAddContact.read(decryped) case RequestAddContact.Type => RequestAddContact.read(decrypted)
case ResultAddContact.Type => ResultAddContact.read(decryped) case ResultAddContact.Type => ResultAddContact.read(decrypted)
case Text.Type => Text.read(decryped) case Text.Type => Text.read(decrypted)
case UserName.Type => UserName.read(decrypted)
} }
new Message(msg.Header, msg.Crypto, body) new Message(msg.Header, msg.Crypto, body)
} }

View file

@ -0,0 +1,4 @@
package com.nutomic.ensichat.protocol
case class User(Address: Address, Name: String)

View file

@ -14,6 +14,8 @@ object Message {
} }
} }
val Charset = "UTF-8"
def read(stream: InputStream): Message = { def read(stream: InputStream): Message = {
val headerBytes = new Array[Byte](MessageHeader.Length) val headerBytes = new Array[Byte](MessageHeader.Length)
stream.read(headerBytes, 0, MessageHeader.Length) stream.read(headerBytes, 0, MessageHeader.Length)

View file

@ -9,8 +9,6 @@ object Text {
val Type = 6 val Type = 6
val Charset = "UTF-8"
/** /**
* Constructs [[Text]] instance from byte array. * Constructs [[Text]] instance from byte array.
*/ */
@ -20,7 +18,7 @@ object Text {
val length = BufferUtils.getUnsignedInt(b).toInt val length = BufferUtils.getUnsignedInt(b).toInt
val bytes = new Array[Byte](length) val bytes = new Array[Byte](length)
b.get(bytes, 0, length) b.get(bytes, 0, length)
new Text(new String(bytes, Text.Charset), time) new Text(new String(bytes, Message.Charset), time)
} }
} }
@ -33,15 +31,15 @@ case class Text(text: String, time: Date = new Date()) extends MessageBody {
override def Type = Text.Type override def Type = Text.Type
override def write: Array[Byte] = { override def write: Array[Byte] = {
val bytes = text.getBytes(Text.Charset)
val b = ByteBuffer.allocate(length) val b = ByteBuffer.allocate(length)
BufferUtils.putUnsignedInt(b, time.getTime / 1000) BufferUtils.putUnsignedInt(b, time.getTime / 1000)
val bytes = text.getBytes(Message.Charset)
BufferUtils.putUnsignedInt(b, bytes.length) BufferUtils.putUnsignedInt(b, bytes.length)
b.put(bytes) b.put(bytes)
b.array() b.array()
} }
override def length = 8 + text.getBytes(Text.Charset).length override def length = 8 + text.getBytes(Message.Charset).length
override def equals(a: Any): Boolean = a match { override def equals(a: Any): Boolean = a match {
case o: Text => text == text && time.getTime / 1000 == o.time.getTime / 1000 case o: Text => text == text && time.getTime / 1000 == o.time.getTime / 1000

View file

@ -0,0 +1,42 @@
package com.nutomic.ensichat.protocol.messages
import java.nio.ByteBuffer
import com.nutomic.ensichat.protocol.BufferUtils
object UserName {
val Type = 7
/**
* Constructs [[UserName]] instance from byte array.
*/
def read(array: Array[Byte]): UserName = {
val b = ByteBuffer.wrap(array)
val length = BufferUtils.getUnsignedInt(b).toInt
val bytes = new Array[Byte](length)
b.get(bytes, 0, length)
new UserName(new String(bytes, Message.Charset))
}
}
/**
* Holds the display name of the sender.
*/
case class UserName(Name: String) extends MessageBody {
override def Type = UserName.Type
override def write: Array[Byte] = {
val b = ByteBuffer.allocate(length)
val bytes = Name.getBytes(Message.Charset)
BufferUtils.putUnsignedInt(b, bytes.length)
b.put(bytes)
b.array()
}
override def length = 4 + Name.getBytes(Message.Charset).length
}

View file

@ -4,6 +4,7 @@ import java.util.Date
import android.content.{ContentValues, Context} import android.content.{ContentValues, Context}
import android.database.sqlite.{SQLiteDatabase, SQLiteOpenHelper} import android.database.sqlite.{SQLiteDatabase, SQLiteOpenHelper}
import android.util.Log
import com.nutomic.ensichat.protocol._ import com.nutomic.ensichat.protocol._
import com.nutomic.ensichat.protocol.messages._ import com.nutomic.ensichat.protocol.messages._
@ -25,7 +26,8 @@ object Database {
private val CreateContactsTable = "CREATE TABLE contacts(" + private val CreateContactsTable = "CREATE TABLE contacts(" +
"_id integer primary key autoincrement," + "_id integer primary key autoincrement," +
"address text not null)" "address text not null," +
"name text not null)"
} }
@ -79,42 +81,60 @@ class Database(context: Context) extends SQLiteOpenHelper(context, Database.Data
cv.put("date", text.time.getTime.toString) cv.put("date", text.time.getTime.toString)
cv.put("text", text.text) cv.put("text", text.text)
getWritableDatabase.insert("messages", null, cv) getWritableDatabase.insert("messages", null, cv)
case _: ConnectionInfo | _: RequestAddContact | _: ResultAddContact => case _: ConnectionInfo | _: RequestAddContact | _: ResultAddContact | _: UserName =>
// Never stored. // Never stored.
} }
/** /**
* Returns a list of all contacts of this device. * Returns all contacts of this user.
*/ */
def getContacts: Set[Address] = { def getContacts: Set[User] = {
val c = getReadableDatabase.query(true, "contacts", Array("address"), "", Array(), val c = getReadableDatabase.query(true, "contacts", Array("address", "name"), "", Array(),
null, null, null, null) null, null, null, null)
var contacts = Set[Address]() var contacts = Set[User]()
while (c.moveToNext()) { while (c.moveToNext()) {
contacts += new Address(c.getString(c.getColumnIndex("address"))) contacts += new User(new Address(c.getString(c.getColumnIndex("address"))),
c.getString(c.getColumnIndex("name")))
} }
c.close() c.close()
contacts contacts
} }
/** /**
* Returns true if a contact with the given device ID exists. * Returns the contact with the given address if it exists.
*/ */
def isContact(address: Address): Boolean = { def getContact(address: Address): Option[User] = {
val c = getReadableDatabase.query(true, "contacts", Array("_id"), "address = ?", val c = getReadableDatabase.query(true, "contacts", Array("address", "name"), "address = ?",
Array(address.toString), null, null, null, null) Array(address.toString), null, null, null, null)
c.getCount != 0 if (c.getCount != 0) {
c.moveToNext()
val s = Some(new User(new Address(c.getString(c.getColumnIndex("address"))),
c.getString(c.getColumnIndex("name"))))
c.close()
s
} else {
c.close()
None
}
} }
/** /**
* Inserts the given device into contacts. * Inserts the given device into contacts.
*/ */
def addContact(address: Address): Unit = { def addContact(contact: User): Unit = {
val cv = new ContentValues() val cv = new ContentValues()
cv.put("address", address.toString) cv.put("address", contact.Address.toString)
cv.put("name", contact.Name.toString)
getWritableDatabase.insert("contacts", null, cv) getWritableDatabase.insert("contacts", null, cv)
contactsUpdatedListeners.foreach(_()) contactsUpdatedListeners.foreach(_())
} }
def changeContactName(contact: User): Unit = {
val cv = new ContentValues()
cv.put("name", contact.Name.toString)
getWritableDatabase.update("contacts", cv, "address = ?", Array(contact.Address.toString))
contactsUpdatedListeners.foreach(_())
}
/** /**
* Pass a callback that is called whenever a new contact is added. * Pass a callback that is called whenever a new contact is added.

View file

@ -4,18 +4,18 @@ import android.content.Context
import android.view.{View, ViewGroup} import android.view.{View, ViewGroup}
import android.widget.{ArrayAdapter, TextView} import android.widget.{ArrayAdapter, TextView}
import com.nutomic.ensichat.bluetooth.Device import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.protocol.Address import com.nutomic.ensichat.protocol.{User, Address}
/** /**
* Displays [[Device]]s in ListView. * Displays [[Device]]s in ListView.
*/ */
class DevicesAdapter(context: Context) extends class UsersAdapter(context: Context) extends
ArrayAdapter[Address](context, android.R.layout.simple_list_item_1) { ArrayAdapter[User](context, android.R.layout.simple_list_item_1) {
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).toString) title.setText(getItem(position).Name)
view view
} }