Add status message (fixes #14).

Also improved protocol definition.
This commit is contained in:
Felix Ableitner 2015-07-21 16:39:43 +02:00
parent 212bb8beba
commit 9959453c38
19 changed files with 157 additions and 119 deletions

View file

@ -8,7 +8,8 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT",
document are to be interpreted as described in RFC 2119.
A _node_ is a single device implementing this protocol. Each node has
exactly one node address based on its RSA key pair.
a 4096 bit RSA key pair. This key pair is used for message signing
and encryption. Every node has exactly one node address.
A _node address_ consists of 32 bytes and is the SHA-256 hash of the
node's public key.
@ -29,17 +30,12 @@ either address.
Crypto
------
Every node has a 4096 RSA key pair that is used for message signing
and encryption.
The message body is always are signed with 'SHA256withRSA'. The
signature is written to the 'Encryption Data' part.
All messages are signed with 'SHA256withRSA'. The signature is written
to the 'Encryption Data' part.
Content messages are encrypted using a random 256 bit AES key. The
key is then wrapped using RSA with the sender's private key, and
written to the 'Encryption Data' part.
The node address is the output of 'SHA-256' on the private key.
The body of content messages is encrypted using a random 256 bit
AES key. The key is then wrapped using RSA with the sender's
private key, and the result written to the 'Encryption Data' part.
Routing
@ -148,13 +144,12 @@ to avoid forwarding the same message multiple times.
Content-Type is one of those in section Content-Messages.
Message ID is unique for each message by the same sender. A device MUST NOT
ever send two messages with the same Message ID.
Message ID is unique for each message by the same sender. A device
MUST NOT ever send two messages with the same Message ID.
Time is the unix timestamp of message sending.
Only Content Messages have the Content-Type Message ID and Time
fields.
Only Content Messages have the Message ID and Time fields.
### Encryption Data
@ -277,7 +272,7 @@ A simple chat message.
Text the string to be transferred, encoded as UTF-8.
### UserName (Content-Type = 4)
### UserInfo (Content-Type = 4)
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
@ -288,5 +283,12 @@ Text the string to be transferred, encoded as UTF-8.
\ Name (variable length) \
/ /
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Status Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
/ /
\ Status (variable length) \
/ /
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Contains the sender's name, which should be used for display to users.
Contains the sender's name and status, which should be used for
display to users.

View file

@ -3,7 +3,7 @@ package com.nutomic.ensichat.protocol
import java.util.{Date, GregorianCalendar}
import android.test.AndroidTestCase
import com.nutomic.ensichat.protocol.body.{Text, UserName}
import com.nutomic.ensichat.protocol.body.{Text, UserInfo}
import com.nutomic.ensichat.protocol.header.ContentHeader
import junit.framework.Assert._
@ -97,9 +97,9 @@ class RouterTest extends AndroidTestCase {
}
private def generateMessage(sender: Address, receiver: Address, seqNum: Int): Message = {
val header = new ContentHeader(sender, receiver, seqNum, UserName.Type, 5,
val header = new ContentHeader(sender, receiver, seqNum, UserInfo.Type, 5,
new GregorianCalendar(2014, 6, 10).getTime)
new Message(header, new UserName(""))
new Message(header, new UserInfo("", ""))
}
}

View file

@ -2,10 +2,10 @@ package com.nutomic.ensichat.protocol
object UserTest {
val u1 = new User(AddressTest.a1, "one")
val u1 = new User(AddressTest.a1, "one", "s1")
val u2 = new User(AddressTest.a2, "two")
val u2 = new User(AddressTest.a2, "two", "s2")
val u3 = new User(AddressTest.a3, "three")
val u3 = new User(AddressTest.a3, "three", "s3")
}

View file

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

View file

@ -1,15 +0,0 @@
package com.nutomic.ensichat.protocol.body
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

@ -6,6 +6,8 @@
android:minHeight="?attr/listPreferredItemHeight"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:paddingLeft="?android:attr/listPreferredItemPaddingLeft"
android:paddingRight="?android:attr/listPreferredItemPaddingRight"
android:orientation="vertical" >
<TextView android:id="@android:id/text1"
@ -13,11 +15,15 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textStyle="bold" />
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end" />
<TextView android:id="@android:id/text2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItemSmall" />
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>

View file

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources translatable="false">
<string name="default_user_status">Let\'s chat!</string>
<string name="default_scan_interval">15</string>
<bool name="default_notification_sounds">true</bool>

View file

@ -75,6 +75,9 @@
<!-- Preference title -->
<string name="user_name">Name</string>
<!-- Preference title -->
<string name="user_status">Status</string>
<!-- Preference title -->
<string name="scan_interval_seconds">Scan Interval (seconds)</string>

View file

@ -6,6 +6,10 @@
android:title="@string/user_name"
android:key="user_name" />
<EditTextPreference
android:title="@string/user_status"
android:key="user_status" />
<CheckBoxPreference
android:title="@string/notification_sounds"
android:key="notification_sounds"

View file

@ -69,6 +69,7 @@ class FirstStartActivity extends AppCompatActivity with OnEditorActionListener w
.edit()
.putBoolean(KeyIsFirstStart, false)
.putString(SettingsFragment.KeyUserName, username.getText.toString.trim)
.putString(SettingsFragment.KeyUserStatus, getString(R.string.default_user_status))
.apply()
startMainActivity()

View file

@ -86,12 +86,12 @@ class ChatFragment extends ListFragment with OnClickListener
super.onCreate(savedInstanceState)
if (savedInstanceState != null)
address = new Address(savedInstanceState.getByteArray("device"))
address = new Address(savedInstanceState.getByteArray("address"))
}
override def onSaveInstanceState(outState: Bundle): Unit = {
super.onSaveInstanceState(outState)
outState.putByteArray("device", address.bytes)
outState.putByteArray("address", address.bytes)
}
/**

View file

@ -6,12 +6,13 @@ import android.preference.{Preference, PreferenceFragment, PreferenceManager}
import com.nutomic.ensichat.{BuildConfig, R}
import com.nutomic.ensichat.activities.EnsichatActivity
import com.nutomic.ensichat.fragments.SettingsFragment._
import com.nutomic.ensichat.protocol.body.UserName
import com.nutomic.ensichat.protocol.body.UserInfo
import com.nutomic.ensichat.util.Database
object SettingsFragment {
val KeyUserName = "user_name"
val KeyUserStatus = "user_status"
val KeyScanInterval = "scan_interval_seconds"
val MaxConnections = "max_connections"
val Version = "version"
@ -26,19 +27,22 @@ class SettingsFragment extends PreferenceFragment with OnPreferenceChangeListene
private lazy val database = new Database(getActivity)
private lazy val name = findPreference(KeyUserName)
private lazy val status = findPreference(KeyUserStatus)
private lazy val scanInterval = findPreference(KeyScanInterval)
private lazy val maxConnections = findPreference(MaxConnections)
private lazy val version = findPreference(Version)
private lazy val prefs = PreferenceManager.getDefaultSharedPreferences(getActivity)
override def onCreate(savedInstanceState: Bundle): Unit = {
super.onCreate(savedInstanceState)
addPreferencesFromResource(R.xml.settings)
val prefs = PreferenceManager.getDefaultSharedPreferences(getActivity)
name.setSummary(prefs.getString(KeyUserName, ""))
name.setOnPreferenceChangeListener(this)
status.setSummary(prefs.getString(KeyUserStatus, ""))
status.setOnPreferenceChangeListener(this)
scanInterval.setOnPreferenceChangeListener(this)
scanInterval.setSummary(prefs.getString(
@ -59,9 +63,11 @@ class SettingsFragment extends PreferenceFragment with OnPreferenceChangeListene
* 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
database.getContacts.foreach(c => service.sendTo(c.address, new UserName(newValue.toString)))
preference.getKey match {
case KeyUserName | KeyUserStatus =>
val service = getActivity.asInstanceOf[EnsichatActivity].service
val ui = new UserInfo(prefs.getString(KeyUserName, ""), prefs.getString(KeyUserStatus, ""))
database.getContacts.foreach(c => service.sendTo(c.address, ui))
}
preference.setSummary(newValue.toString)
true

View file

@ -13,7 +13,7 @@ import com.nutomic.ensichat.activities.MainActivity
import com.nutomic.ensichat.bluetooth.BluetoothInterface
import com.nutomic.ensichat.fragments.SettingsFragment
import com.nutomic.ensichat.protocol.ChatService.{OnConnectionsChangedListener, OnMessageReceivedListener}
import com.nutomic.ensichat.protocol.body.{ConnectionInfo, MessageBody, UserName}
import com.nutomic.ensichat.protocol.body.{ConnectionInfo, MessageBody, UserInfo}
import com.nutomic.ensichat.protocol.header.ContentHeader
import com.nutomic.ensichat.util.{AddContactsHandler, Database, NotificationHandler}
@ -192,11 +192,11 @@ class ChatService extends Service {
* Handles all (locally and remotely sent) new messages.
*/
private def onNewMessage(msg: Message): Unit = msg.body match {
case name: UserName =>
val contact = new User(msg.header.origin, name.name)
case ui: UserInfo =>
val contact = new User(msg.header.origin, ui.name, ui.status)
knownUsers += contact
if (database.getContact(msg.header.origin).nonEmpty)
database.changeContactName(contact)
database.updateContact(contact)
callConnectionListeners()
case _ =>
@ -243,8 +243,8 @@ class ChatService extends Service {
}
Log.i(Tag, "Node " + sender + " connected")
val name = preferences.getString(SettingsFragment.KeyUserName, "")
sendTo(sender, new UserName(name))
sendTo(sender, new UserInfo(preferences.getString(SettingsFragment.KeyUserName, ""),
preferences.getString(SettingsFragment.KeyUserStatus, "")))
callConnectionListeners()
true
}
@ -263,6 +263,6 @@ class ChatService extends Service {
btInterface.getConnections
def getUser(address: Address) =
knownUsers.find(_.address == address).getOrElse(new User(address, address.toString))
knownUsers.find(_.address == address).getOrElse(new User(address, address.toString, ""))
}

View file

@ -257,7 +257,7 @@ class Crypto(context: Context) {
case RequestAddContact.Type => RequestAddContact.read(decrypted)
case ResultAddContact.Type => ResultAddContact.read(decrypted)
case Text.Type => Text.read(decrypted)
case UserName.Type => UserName.read(decrypted)
case UserInfo.Type => UserInfo.read(decrypted)
}
new Message(msg.header, msg.crypto, body)
}

View file

@ -1,3 +1,3 @@
package com.nutomic.ensichat.protocol
case class User(address: Address, name: String)
case class User(address: Address, name: String, status: String)

View file

@ -0,0 +1,53 @@
package com.nutomic.ensichat.protocol.body
import java.nio.ByteBuffer
import com.nutomic.ensichat.protocol.Message
import com.nutomic.ensichat.util.BufferUtils
object UserInfo {
val Type = 7
/**
* Constructs [[UserInfo]] instance from byte array.
*/
def read(array: Array[Byte]): UserInfo = {
val bb = ByteBuffer.wrap(array)
new UserInfo(getValue(bb), getValue(bb))
}
private def getValue(bb: ByteBuffer): String = {
val length = BufferUtils.getUnsignedInt(bb).toInt
val bytes = new Array[Byte](length)
bb.get(bytes, 0, length)
new String(bytes, Message.Charset)
}
}
/**
* Holds display name and status of the sender.
*/
case class UserInfo(name: String, status: String) extends MessageBody {
override def protocolType = -1
override def contentType = UserInfo.Type
override def write: Array[Byte] = {
val b = ByteBuffer.allocate(length)
put(b, name)
put(b, status)
b.array()
}
def put(b: ByteBuffer, value: String): ByteBuffer = {
val bytes = value.getBytes(Message.Charset)
BufferUtils.putUnsignedInt(b, bytes.length)
b.put(bytes)
}
override def length = 8 + name.getBytes(Message.Charset).length +
status.getBytes(Message.Charset).length
}

View file

@ -1,43 +0,0 @@
package com.nutomic.ensichat.protocol.body
import java.nio.ByteBuffer
import com.nutomic.ensichat.protocol.Message
import com.nutomic.ensichat.util.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 protocolType = -1
override def contentType = 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

@ -19,7 +19,7 @@ object Database {
private val DatabaseName = "message_store.db"
private val DatabaseVersion = 1
private val DatabaseVersion = 2
// NOTE: We could make origin/target foreign keys to contacts, but:
// - they don't change anyway
@ -35,7 +35,8 @@ object Database {
private val CreateContactsTable = "CREATE TABLE contacts(" +
"_id INTEGER PRIMARY KEY," +
"address TEXT NOT NULL," +
"name TEXT NOT NULL)"
"name TEXT NOT NULL," +
"status TEXT NOT NULL)"
}
@ -97,12 +98,13 @@ class Database(context: Context)
* Returns all contacts of this user.
*/
def getContacts: Set[User] = {
val c = getReadableDatabase.query(true, "contacts", Array("address", "name"), "", Array(),
val c = getReadableDatabase.query(true, "contacts", Array("address", "name", "status"), "", Array(),
null, null, null, null)
var contacts = Set[User]()
while (c.moveToNext()) {
contacts += new User(new Address(c.getString(c.getColumnIndex("address"))),
c.getString(c.getColumnIndex("name")))
c.getString(c.getColumnIndex("name")),
c.getString(c.getColumnIndex("status")))
}
c.close()
contacts
@ -112,12 +114,13 @@ class Database(context: Context)
* Returns the contact with the given address if it exists.
*/
def getContact(address: Address): Option[User] = {
val c = getReadableDatabase.query(true, "contacts", Array("address", "name"), "address = ?",
val c = getReadableDatabase.query(true, "contacts", Array("address", "name", "status"), "address = ?",
Array(address.toString), null, null, null, null)
if (c.getCount != 0) {
c.moveToNext()
val s = Option(new User(new Address(c.getString(c.getColumnIndex("address"))),
c.getString(c.getColumnIndex("name"))))
c.getString(c.getColumnIndex("name")),
c.getString(c.getColumnIndex("status"))))
c.close()
s
} else {
@ -132,14 +135,16 @@ class Database(context: Context)
def addContact(contact: User): Unit = {
val cv = new ContentValues()
cv.put("address", contact.address.toString)
cv.put("name", contact.name.toString)
cv.put("name", contact.name)
cv.put("status", contact.status)
getWritableDatabase.insert("contacts", null, cv)
contactsUpdated()
}
def changeContactName(contact: User): Unit = {
def updateContact(contact: User): Unit = {
val cv = new ContentValues()
cv.put("name", contact.name.toString)
cv.put("name", contact.name)
cv.put("status", contact.status)
getWritableDatabase.update("contacts", cv, "address = ?", Array(contact.address.toString))
contactsUpdated()
}
@ -150,6 +155,12 @@ class Database(context: Context)
}
override def onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int): Unit = {
if (oldVersion < 2) {
db.execSQL("ALTER TABLE contacts ADD COLUMN status TEXT")
val cv = new ContentValues()
cv.put("status", "")
db.update("contacts", cv, null, null)
}
}
}

View file

@ -3,10 +3,9 @@ package com.nutomic.ensichat.util
import android.content.Context
import android.view.{LayoutInflater, View, ViewGroup}
import android.widget.{ArrayAdapter, TextView}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.bluetooth.Device
import com.nutomic.ensichat.protocol.User
import com.nutomic.ensichat.protocol.body.Text
import com.nutomic.ensichat.R
/**
* Displays [[Device]]s in ListView.
@ -14,8 +13,6 @@ import com.nutomic.ensichat.R
class UsersAdapter(context: Context) extends
ArrayAdapter[User](context, 0) {
private lazy val database = new Database(context)
override def getView(position: Int, convertView: View, parent: ViewGroup): View = {
val view =
if (convertView == null) {
@ -28,11 +25,7 @@ class UsersAdapter(context: Context) extends
val summary = view.findViewById(android.R.id.text2).asInstanceOf[TextView]
val item = getItem(position)
title.setText(item.name)
database.getMessages(item.address, 1)
.map(_.body)
.foreach {
case m: Text => summary.setText(m.text)
}
summary.setText(item.status)
view
}