diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7f6f5fa..d032373 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,11 @@ android:value=".activities.MainActivity" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cd0ee1f..03f5562 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,12 @@ Add Contacts + + No nearby devices found + + + + Do you want to add %1$s as a new contact? @@ -39,15 +45,12 @@ Before accepting, make sure the images match on both devices - - No nearby devices found + + Contact not added (denied by other device) %1$s was added as a contact - - Contact not added (denied by other device) - @@ -60,4 +63,10 @@ Scan Interval (seconds) + + + + + New friend request! + diff --git a/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala b/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala index f125063..102a1bb 100644 --- a/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala +++ b/app/src/main/scala/com/nutomic/ensichat/activities/AddContactsActivity.scala @@ -1,51 +1,26 @@ package com.nutomic.ensichat.activities -import android.app.AlertDialog -import android.content.DialogInterface.OnClickListener -import android.content.{Intent, Context, DialogInterface} +import android.content.Intent import android.os.Bundle import android.support.v4.app.NavUtils -import android.util.Log import android.view._ import android.widget.AdapterView.OnItemClickListener import android.widget._ import com.nutomic.ensichat.R -import com.nutomic.ensichat.protocol.messages.{Message, RequestAddContact, ResultAddContact} -import com.nutomic.ensichat.protocol.{User, Address, ChatService, Crypto} -import com.nutomic.ensichat.util.{Database, UsersAdapter, IdenticonGenerator} - -import scala.collection.SortedSet +import com.nutomic.ensichat.protocol.ChatService +import com.nutomic.ensichat.protocol.messages.RequestAddContact +import com.nutomic.ensichat.util.UsersAdapter /** - * Lists all nearby, connected devices and allows adding them to contacts. - * - * Adding a contact requires confirmation on both sides. + * Lists all nearby, connected devices and allows adding them to be added as contacts. */ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnectionsChangedListener - with OnItemClickListener with ChatService.OnMessageReceivedListener { + with OnItemClickListener { private val Tag = "AddContactsActivity" private lazy val Adapter = new UsersAdapter(this) - private lazy val Database = service.Database - - private lazy val Crypto = new Crypto(this) - - /** - * Map of devices that should be added. - */ - private var currentlyAdding = Map[User, 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 case class AddContactInfo(localConfirmed: Boolean, remoteConfirmed: Boolean) - /** * Initializes layout, registers connection and message listeners. */ @@ -61,8 +36,7 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection runOnServiceConnected(() => { service.registerConnectionListener(AddContactsActivity.this) - service.registerMessageListener(this) - Database.runOnContactsUpdated(updateList) + service.Database.runOnContactsUpdated(updateList) }) } @@ -74,95 +48,9 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnection override def onItemClick(parent: AdapterView[_], view: View, position: Int, id: Long): Unit = { val contact = Adapter.getItem(position) service.sendTo(contact.Address, new RequestAddContact()) - addDeviceDialog(contact) - } - - /** - * Shows a dialog to accept/deny adding a device as a new contact. - */ - private def addDeviceDialog(contact: User): Unit = { - // 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 += - (contact -> new AddContactInfo(currentlyAdding(contact).localConfirmed, true)) - addContactIfBothConfirmed(contact) - service.sendTo(contact.Address, new ResultAddContact(true)) - case DialogInterface.BUTTON_NEGATIVE => - // Local user denied adding contact, send info to other device. - service.sendTo(contact.Address, new ResultAddContact(false)) - } - } - - val inflater = getSystemService(Context.LAYOUT_INFLATER_SERVICE).asInstanceOf[LayoutInflater] - val view = inflater.inflate(R.layout.dialog_add_contact, null) - - val local = view.findViewById(R.id.local_identicon).asInstanceOf[ImageView] - local.setImageBitmap( - IdenticonGenerator.generate(Crypto.getLocalAddress, (150, 150), this)) - val remoteTitle = view.findViewById(R.id.remote_identicon_title).asInstanceOf[TextView] - remoteTitle.setText(getString(R.string.remote_fingerprint_title, contact.Name)) - val remote = view.findViewById(R.id.remote_identicon).asInstanceOf[ImageView] - remote.setImageBitmap(IdenticonGenerator.generate(contact.Address, (150, 150), this)) - - new AlertDialog.Builder(this) - .setTitle(getString(R.string.add_contact_dialog, contact.Name)) - .setView(view) - .setPositiveButton(android.R.string.yes, onClick) - .setNegativeButton(android.R.string.no, onClick) - .show() - } - - /** - * Handles incoming [[RequestAddContact]] and [[ResultAddContact]] 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(msg: Message): Unit = { - if (msg.Header.Target != Crypto.getLocalAddress) - return - - msg.Body match { - case _: RequestAddContact => - Log.i(Tag, "Remote device " + msg.Header.Origin + " wants to add us as a contact, showing dialog") - service.getConnections.find(_.Address == msg.Header.Origin).foreach(addDeviceDialog) - case m: ResultAddContact => - currentlyAdding.keys.find(_.Address == msg.Header.Origin).foreach(contact => - if (m.Accepted) { - Log.i(Tag, contact.toString + " accepted us as a contact, updating state") - currentlyAdding += (contact -> - new AddContactInfo(true, currentlyAdding(contact).remoteConfirmed)) - addContactIfBothConfirmed(contact) - val intent = new Intent(this, classOf[MainActivity]) - intent.setAction(MainActivity.ActionOpenChat) - intent.putExtra(MainActivity.ExtraAddress, msg.Header.Origin.toString) - startActivity(intent) - finish() - } else { - Log.i(Tag, contact.toString + " denied us as a contact, showing toast") - Toast.makeText(this, R.string.contact_not_added, Toast.LENGTH_LONG).show() - currentlyAdding -= contact - }) - case _ => - } - } - - /** - * Add the given device to contacts if [[AddContactInfo.localConfirmed]] and - * [[AddContactInfo.remoteConfirmed]] are true for it in [[currentlyAdding]]. - */ - private def addContactIfBothConfirmed(contact: User): Unit = { - val info = currentlyAdding(contact) - if (info.localConfirmed && info.remoteConfirmed) { - Log.i(Tag, "Adding new contact " + contact.toString) - Database.addContact(contact) - Toast.makeText(this, getString(R.string.contact_added, contact.Name), Toast.LENGTH_SHORT) - .show() - currentlyAdding -= contact - } + val intent = new Intent(this, classOf[ConfirmAddContactDialog]) + intent.putExtra(ConfirmAddContactDialog.ExtraContactAddress, contact.Address.toString) + startActivity(intent) } override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match { diff --git a/app/src/main/scala/com/nutomic/ensichat/activities/ConfirmAddContactDialog.scala b/app/src/main/scala/com/nutomic/ensichat/activities/ConfirmAddContactDialog.scala new file mode 100644 index 0000000..3810bcd --- /dev/null +++ b/app/src/main/scala/com/nutomic/ensichat/activities/ConfirmAddContactDialog.scala @@ -0,0 +1,121 @@ +package com.nutomic.ensichat.activities + +import android.app.AlertDialog +import android.content.DialogInterface.OnClickListener +import android.content.{Context, DialogInterface} +import android.os.Bundle +import android.util.Log +import android.view.{ContextThemeWrapper, LayoutInflater} +import android.widget.{ImageView, TextView, Toast} +import com.nutomic.ensichat.R +import com.nutomic.ensichat.protocol.ChatService.OnMessageReceivedListener +import com.nutomic.ensichat.protocol.messages.{Message, RequestAddContact, ResultAddContact} +import com.nutomic.ensichat.protocol.{Address, Crypto} +import com.nutomic.ensichat.util.IdenticonGenerator + +object ConfirmAddContactDialog { + + val ExtraContactAddress = "contact_address" + +} + +/** + * Shows a dialog for adding a new contact (including key fingerprints). + */ +class ConfirmAddContactDialog extends EnsiChatActivity with OnMessageReceivedListener + with OnClickListener { + + private val Tag = "ConfirmAddContactDialog" + + private lazy val User = service.getUser( + new Address(getIntent.getStringExtra(ConfirmAddContactDialog.ExtraContactAddress))) + + private var localConfirmed = false + + private var remoteConfirmed = false + + override def onCreate(savedInstanceState: Bundle): Unit = { + super.onCreate(savedInstanceState) + + runOnServiceConnected(() => { + showDialog() + service.registerMessageListener(this) + }) + } + + /** + * Shows a dialog to accept/deny adding a device as a new contact. + */ + private def showDialog(): Unit = { + val inflater = getSystemService(Context.LAYOUT_INFLATER_SERVICE).asInstanceOf[LayoutInflater] + val view = inflater.inflate(R.layout.dialog_add_contact, null) + + val local = view.findViewById(R.id.local_identicon).asInstanceOf[ImageView] + val remote = view.findViewById(R.id.remote_identicon).asInstanceOf[ImageView] + val remoteTitle = view.findViewById(R.id.remote_identicon_title).asInstanceOf[TextView] + + local.setImageBitmap(IdenticonGenerator.generate(Crypto.getLocalAddress(this), (150, 150), this)) + remote.setImageBitmap(IdenticonGenerator.generate(User.Address, (150, 150), this)) + remoteTitle.setText(getString(R.string.remote_fingerprint_title, User.Name)) + + new AlertDialog.Builder(new ContextThemeWrapper(this, R.style.AppTheme)) + .setTitle(getString(R.string.add_contact_dialog, User.Name)) + .setView(view) + .setCancelable(false) + .setPositiveButton(android.R.string.yes, this) + .setNegativeButton(android.R.string.no, this) + .show() + } + + override def onClick(dialogInterface: DialogInterface, i: Int): Unit = { + val result = i match { + case DialogInterface.BUTTON_POSITIVE => + localConfirmed = true + addContactIfBothConfirmed() + true + case DialogInterface.BUTTON_NEGATIVE => + false + } + service.sendTo(User.Address, new ResultAddContact(result)) + } + + /** + * Add the user to contacts if [[localConfirmed]] and [[remoteConfirmed]] are true. + */ + private def addContactIfBothConfirmed(): Unit = { + if (localConfirmed && remoteConfirmed) { + Log.i(Tag, "Adding new contact " + User.toString) + // Get the user again, in case + service.Database.addContact(service.getUser(User.Address)) + Toast.makeText(this, getString(R.string.contact_added, User.Name), Toast.LENGTH_SHORT) + .show() + finish() + } + } + + /** + * Handles incoming [[RequestAddContact]] and [[ResultAddContact]] 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(msg: Message): Unit = { + if (msg.Header.Origin != User.Address || msg.Header.Target != Crypto.getLocalAddress(this)) + return + + msg.Body match { + case m: ResultAddContact => + if (m.Accepted) { + Log.i(Tag, User.toString + " accepted us as a contact, updating state") + remoteConfirmed = true + addContactIfBothConfirmed() + } else { + Log.i(Tag, User.toString + " denied us as a contact, showing toast") + Toast.makeText(this, R.string.contact_not_added, Toast.LENGTH_LONG).show() + finish() + } + case _ => + } + } + +} diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala index 1ed8812..e65d37f 100644 --- a/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/ChatService.scala @@ -1,19 +1,19 @@ package com.nutomic.ensichat.protocol -import android.app.Service +import android.app.{Notification, NotificationManager, PendingIntent, Service} import android.bluetooth.BluetoothAdapter -import android.content.Intent +import android.content.{Context, Intent} import android.os.Handler import android.preference.PreferenceManager import android.util.Log +import com.nutomic.ensichat.R +import com.nutomic.ensichat.activities.ConfirmAddContactDialog 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.{OnConnectionsChangedListener, OnMessageReceivedListener} import com.nutomic.ensichat.protocol.messages._ import com.nutomic.ensichat.util.Database -import scala.collection.SortedSet -import scala.collection.immutable.HashMap import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future import scala.ref.WeakReference @@ -57,9 +57,11 @@ class ChatService extends Service { private lazy val Binder = new ChatServiceBinder(this) - private lazy val Crypto = new Crypto(this) + private lazy val crypto = new Crypto(this) - private lazy val BluetoothInterface = new BluetoothInterface(this, Crypto) + private lazy val bluetoothInterface = new BluetoothInterface(this, crypto) + + private val notificationIdGenerator = Stream.from(100) /** * For this (and [[messageListeners]], functions would be useful instead of instances, @@ -91,15 +93,15 @@ class ChatService extends Service { registerMessageListener(Database) Future { - Crypto.generateLocalKeys() + crypto.generateLocalKeys() - BluetoothInterface.create() - Log.i(Tag, "Service started, address is " + Crypto.getLocalAddress) + bluetoothInterface.create() + Log.i(Tag, "Service started, address is " + Crypto.getLocalAddress(this)) } } override def onDestroy(): Unit = { - BluetoothInterface.destroy() + bluetoothInterface.destroy() } override def onStartCommand(intent: Intent, flags: Int, startId: Int) = Service.START_STICKY @@ -125,15 +127,15 @@ class ChatService extends Service { * Sends a new message to the given target address. */ def sendTo(target: Address, body: MessageBody): Unit = { - if (!BluetoothInterface.getConnections.contains(target)) + if (!bluetoothInterface.getConnections.contains(target)) return val header = new MessageHeader(body.Type, MessageHeader.DefaultHopLimit, - Crypto.getLocalAddress, target, 0, 0) + Crypto.getLocalAddress(this), target, 0, 0) val msg = new Message(header, body) - val encrypted = Crypto.encrypt(Crypto.sign(msg)) - BluetoothInterface.send(encrypted) + val encrypted = crypto.encrypt(crypto.sign(msg)) + bluetoothInterface.send(encrypted) onNewMessage(msg) } @@ -141,8 +143,8 @@ class ChatService extends Service { * Decrypts and verifies incoming messages, forwards valid ones to [[onNewMessage()]]. */ def onMessageReceived(msg: Message): Unit = { - val decrypted = Crypto.decrypt(msg) - if (!Crypto.verify(decrypted)) { + val decrypted = crypto.decrypt(msg) + if (!crypto.verify(decrypted)) { Log.i(Tag, "Ignoring message with invalid signature from " + msg.Header.Origin) return } @@ -160,6 +162,25 @@ class ChatService extends Service { Database.changeContactName(contact) callConnectionListeners() + case _: RequestAddContact => + if (msg.Header.Origin == Crypto.getLocalAddress(this)) + return + + Log.i(Tag, "Remote device " + msg.Header.Origin + + " wants to add us as a contact, showing notification") + val intent = new Intent(this, classOf[ConfirmAddContactDialog]) + intent.putExtra(ConfirmAddContactDialog.ExtraContactAddress, msg.Header.Origin.toString) + val pi = PendingIntent.getActivity(this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT) + + val notification = new Notification.Builder(this) + .setContentTitle(getString(R.string.notification_friend_request, getUser(msg.Header.Origin))) + .setSmallIcon(R.drawable.ic_launcher) + .setContentIntent(pi) + .setAutoCancel(true) + .build() + val nm = getSystemService(Context.NOTIFICATION_SERVICE).asInstanceOf[NotificationManager] + nm.notify(notificationIdGenerator.iterator.next(), notification) case _ => MainHandler.post(new Runnable { override def run(): Unit = @@ -182,19 +203,19 @@ class ChatService extends Service { */ def onConnectionOpened(msg: Message): Boolean = { 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) { Log.i(Tag, "Ignoring ConnectionInfo message with invalid sender " + sender) false } - if (Crypto.havePublicKey(sender) && !Crypto.verify(msg, Crypto.getPublicKey(sender))) { + if (crypto.havePublicKey(sender) && !crypto.verify(msg, crypto.getPublicKey(sender))) { Log.i(Tag, "Ignoring ConnectionInfo message with invalid signature") false } - if (!Crypto.havePublicKey(sender)) { - Crypto.addPublicKey(sender, info.key) + if (!crypto.havePublicKey(sender)) { + crypto.addPublicKey(sender, info.key) Log.i(Tag, "Added public key for new device " + sender.toString) } @@ -220,7 +241,7 @@ class ChatService extends Service { * Returns all direct neighbors. */ def getConnections: Set[User] = { - BluetoothInterface.getConnections.map{ address => + bluetoothInterface.getConnections.map{ address => (Database.getContacts ++ connections).find(_.Address == address) match { case Some(contact) => contact case None => new User(address, address.toString) @@ -228,4 +249,7 @@ class ChatService extends Service { } } + def getUser(address: Address) = + getConnections.find(_.Address == address).getOrElse(new User(address, address.toString)) + } diff --git a/app/src/main/scala/com/nutomic/ensichat/protocol/Crypto.scala b/app/src/main/scala/com/nutomic/ensichat/protocol/Crypto.scala index d1d7b1c..01ea1d7 100644 --- a/app/src/main/scala/com/nutomic/ensichat/protocol/Crypto.scala +++ b/app/src/main/scala/com/nutomic/ensichat/protocol/Crypto.scala @@ -308,6 +308,4 @@ class Crypto(Context: Context) { new Address(hash) } - def getLocalAddress = Crypto.getLocalAddress(Context) - }