Merge branch 'internet-transport'

This commit is contained in:
Felix Ableitner 2016-01-25 22:19:09 +01:00
commit 6e12494c2c
68 changed files with 959 additions and 219 deletions

View file

@ -13,11 +13,13 @@ buildscript {
dependencies {
compile 'com.android.support:design:23.1.1'
compile 'com.android.support:multidex:1.0.1'
androidTestCompile 'com.android.support:multidex-instrumentation:1.0.1',
{ exclude module: 'multidex' }
compile 'org.scala-lang:scala-library:2.11.7'
compile 'com.mobsandgeeks:adapter-kit:0.5.3'
compile 'com.google.zxing:android-integration:3.2.1'
compile 'com.google.zxing:core:3.2.1'
compile project(path: ':core')
androidTestCompile 'com.android.support:multidex-instrumentation:1.0.1',
{ exclude module: 'multidex' }
}
// RtlHardcoded behaviour differs between target API versions. We only care about API 15.
@ -37,8 +39,8 @@ android {
defaultConfig {
applicationId "com.nutomic.ensichat"
targetSdkVersion 23
versionCode 8
versionName "0.1.7"
versionCode project.properties.versionCode.toInteger()
versionName project.properties.versionName
multiDexEnabled true
testInstrumentationRunner "com.android.test.runner.MultiDexTestRunner"
}

View file

@ -6,12 +6,14 @@
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<application
android:name="android.support.multidex.MultiDexApplication"
android:name=".App"
android:allowBackup="true"
android:fullBackupContent="true"
android:icon="@drawable/ic_launcher"

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

View file

@ -1,20 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="25dp">
android:layout_height="match_parent">
<ImageView
android:id="@+id/identicon"
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_marginBottom="25dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:padding="25dp">
<TextView
android:id="@+id/address"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<ImageView
android:id="@+id/identicon"
android:layout_width="150dp"
android:layout_height="150dp"
android:scaleType="fitCenter" />
</LinearLayout>
<TextView
android:id="@+id/address"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="25dp"
android:layout_marginBottom="25dp" />
<ImageView
android:id="@+id/qr_code"
android:layout_width="150dp"
android:layout_height="150dp"
android:scaleType="fitCenter" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/add_contact"
android:title="Add Contact"
android:icon="@drawable/ic_person_add_white_24dp"
app:showAsAction="ifRoom" />
<item
android:id="@+id/scan_qr"
android:title="Scan QR-Code"
android:icon="@drawable/ic_qrcode_white_24dp"
app:showAsAction="ifRoom" />
</menu>

View file

@ -22,7 +22,7 @@
<!-- MainActivity -->
<!-- Toast shown if user denies request to enable bluetooth -->
<string name="bluetooth_required">Bluetooth is required for this app.</string>
<string name="toast_bluetooth_denied">Please enable Bluetooth to access the complete app functionality.</string>
<!-- ContactsFragment -->
@ -60,6 +60,10 @@
<!-- Toast shown when clicking a user that is already a contact -->
<string name="contact_already_added">You have already added %1$s as a contact</string>
<string name="enter_id">Enter user ID</string>
<string name="invalid_address">Invalid address</string>
<!-- SettingsActivity -->
@ -87,6 +91,9 @@
<!-- Preference title (debug only)-->
<string name="max_connections" translatable="false">Maximum Number of Connections</string>
<!-- Preference title -->
<string name="servers">Servers</string>
<!-- Preference title -->
<string name="report_issue">Report Issue</string>

View file

@ -32,6 +32,10 @@
android:inputType="number"
android:numeric="integer" />
<EditTextPreference
android:title="@string/servers"
android:key="servers" />
<Preference
android:title="@string/report_issue"
android:summary="@string/report_issue_summary" >

View file

@ -0,0 +1,15 @@
package com.nutomic.ensichat
import android.support.multidex.MultiDexApplication
import com.nutomic.ensichat.core.interfaces.Log
import com.nutomic.ensichat.util.{Logging, PRNGFixes}
class App extends MultiDexApplication {
override def onCreate(): Unit = {
super.onCreate()
Log.setLogInstance(new Logging())
PRNGFixes.apply()
}
}

View file

@ -1,5 +1,6 @@
package com.nutomic.ensichat.activities
import android.app.Activity
import android.app.AlertDialog.Builder
import android.content.DialogInterface.OnClickListener
import android.content._
@ -9,7 +10,9 @@ import android.support.v4.content.LocalBroadcastManager
import android.view._
import android.widget.AdapterView.OnItemClickListener
import android.widget._
import com.google.zxing.integration.android.IntentIntegrator
import com.nutomic.ensichat.R
import com.nutomic.ensichat.core.Address
import com.nutomic.ensichat.service.CallbackHandler
import com.nutomic.ensichat.util.Database
import com.nutomic.ensichat.views.UsersAdapter
@ -19,8 +22,6 @@ import com.nutomic.ensichat.views.UsersAdapter
*/
class ConnectionsActivity extends EnsichatActivity with OnItemClickListener {
private val Tag = "AddContactsActivity"
private lazy val database = new Database(this)
private lazy val adapter = new UsersAdapter(this)
@ -57,22 +58,79 @@ class ConnectionsActivity extends EnsichatActivity with OnItemClickListener {
LocalBroadcastManager.getInstance(this).unregisterReceiver(onContactsUpdatedReceiver)
}
override def onCreateOptionsMenu(menu: Menu): Boolean = {
getMenuInflater.inflate(R.menu.connections, menu)
true
}
override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match {
case R.id.add_contact =>
val et = new EditText(this)
new Builder(this)
.setTitle(R.string.enter_id)
.setView(et)
.setPositiveButton(android.R.string.ok, new OnClickListener {
override def onClick(dialog: DialogInterface, which: Int): Unit = {
addContact(et.getText.toString)
}
})
.setNegativeButton(android.R.string.cancel, null)
.show()
true
case R.id.scan_qr =>
new IntentIntegrator(this).initiateScan
true
case android.R.id.home =>
NavUtils.navigateUpFromSameTask(this)
true
case _ =>
super.onOptionsItemSelected(item)
}
/**
* 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 contact = adapter.getItem(position)
if (database.getContacts.contains(contact)) {
val text = getString(R.string.contact_already_added, contact.name)
override def onItemClick(parent: AdapterView[_], view: View, position: Int, id: Long): Unit =
addContact(adapter.getItem(position).address.toString)
/**
* Receives value of scanned QR code and sets it as device ID.
*/
override def onActivityResult(requestCode: Int, resultCode: Int, intent: Intent) {
val scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent)
if (scanResult != null && resultCode == Activity.RESULT_OK) {
addContact(scanResult.getContents)
}
}
/**
* Parses the address, and shows a dialog to add the user as a contact.
*
* Displays a warning toast if the address is invalid or if the user is already a contact.
*/
private def addContact(address: String): Unit = {
val parsedAddress =
try {
new Address(address)
} catch {
case e: IllegalArgumentException =>
Toast.makeText(this, R.string.invalid_address, Toast.LENGTH_LONG).show()
return
}
val user = service.get.getUser(parsedAddress)
if (database.getContacts.map(_.address).contains(user.address)) {
val text = getString(R.string.contact_already_added, user.name)
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
return
}
new Builder(this)
.setMessage(getString(R.string.dialog_add_contact, contact.name))
.setMessage(getString(R.string.dialog_add_contact, user.name))
.setPositiveButton(android.R.string.yes, new OnClickListener {
override def onClick(dialog: DialogInterface, which: Int): Unit = {
database.addContact(contact)
database.addContact(user)
Toast.makeText(ConnectionsActivity.this, R.string.toast_contact_added, Toast.LENGTH_SHORT)
.show()
}
@ -81,14 +139,6 @@ class ConnectionsActivity extends EnsichatActivity with OnItemClickListener {
.show()
}
override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match {
case android.R.id.home =>
NavUtils.navigateUpFromSameTask(this)
true
case _ =>
super.onOptionsItemSelected(item);
}
/**
* Fetches connections and displays them (excluding contacts).
*/

View file

@ -11,8 +11,8 @@ import android.view.{KeyEvent, View}
import android.widget.TextView.OnEditorActionListener
import android.widget.{Button, EditText, TextView}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.core.interfaces.Settings
import com.nutomic.ensichat.core.interfaces.Settings._
import com.nutomic.ensichat.core.interfaces.SettingsInterface
import com.nutomic.ensichat.core.interfaces.SettingsInterface._
/**
* Shown on first start, lets the user enter their name.
@ -69,11 +69,12 @@ class FirstStartActivity extends AppCompatActivity with OnEditorActionListener w
preferences
.edit()
.putBoolean(KeyIsFirstStart, false)
.putString(Settings.KeyUserName, username.getText.toString.trim)
.putString(Settings.KeyUserStatus, Settings.DefaultUserStatus)
.putBoolean(Settings.KeyNotificationSoundsOn, DefaultNotificationSoundsOn)
.putString(Settings.KeyScanInterval, DefaultScanInterval.toString)
.putString(Settings.KeyMaxConnections, DefaultMaxConnections.toString)
.putString(KeyUserName, username.getText.toString.trim)
.putString(KeyUserStatus, SettingsInterface.DefaultUserStatus)
.putBoolean(KeyNotificationSoundsOn, DefaultNotificationSoundsOn)
.putString(KeyScanInterval, DefaultScanInterval.toString)
.putString(KeyMaxConnections, DefaultMaxConnections.toString)
.putString(KeyServers, DefaultServers.mkString(", "))
.apply()
startMainActivity()

View file

@ -44,7 +44,8 @@ class MainActivity extends EnsichatActivity {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (getIntent.getAction == MainActivity.ActionRequestBluetooth) {
if (getIntent.getAction == MainActivity.ActionRequestBluetooth &&
Option(BluetoothAdapter.getDefaultAdapter).isDefined) {
val intent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 0)
startActivityForResult(intent, RequestSetDiscoverable)
@ -89,8 +90,7 @@ class MainActivity extends EnsichatActivity {
requestCode match {
case RequestSetDiscoverable =>
if (resultCode == Activity.RESULT_CANCELED) {
Toast.makeText(this, R.string.bluetooth_required, Toast.LENGTH_LONG).show()
finish()
Toast.makeText(this, R.string.toast_bluetooth_denied, Toast.LENGTH_LONG).show()
}
}
@ -120,7 +120,8 @@ class MainActivity extends EnsichatActivity {
currentChat = None
getSupportActionBar.setDisplayHomeAsUpEnabled(false)
setTitle(R.string.app_name)
}
} else
super.onBackPressed()
}
override def onOptionsItemSelected(item: MenuItem): Boolean = item.getItemId match {

View file

@ -8,7 +8,7 @@ import android.util.Log
/**
* Attempts to connect to another device and calls [[onConnected]] on success.
*/
class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Unit) extends Thread {
class BluetoothConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Unit) extends Thread {
private val Tag = "ConnectThread"

View file

@ -9,7 +9,7 @@ import android.preference.PreferenceManager
import android.util.Log
import com.nutomic.ensichat.R
import com.nutomic.ensichat.core.body.ConnectionInfo
import com.nutomic.ensichat.core.interfaces.{Settings, TransmissionInterface}
import com.nutomic.ensichat.core.interfaces.{SettingsInterface, TransmissionInterface}
import com.nutomic.ensichat.core.{Address, ConnectionHandler, Message}
import com.nutomic.ensichat.service.ChatService
@ -38,9 +38,9 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
private var devices = new HashMap[Device.ID, Device]()
private var connections = new HashMap[Device.ID, TransferThread]()
private var connections = new HashMap[Device.ID, BluetoothTransferThread]()
private var listenThread: Option[ListenThread] = None
private var listenThread: Option[BluetoothListenThread] = None
private var cancelDiscovery = false
@ -68,7 +68,7 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
* Stops discovery and listening.
*/
override def destroy(): Unit = {
listenThread.get.cancel()
listenThread.foreach(_.cancel())
listenThread = None
cancelDiscovery = true
try {
@ -86,7 +86,7 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
* Starts discovery and listening.
*/
private def startBluetoothConnections(): Unit = {
listenThread = Some(new ListenThread(context.getString(R.string.app_name), btAdapter, connectionOpened))
listenThread = Some(new BluetoothListenThread(context.getString(R.string.app_name), btAdapter, connectionOpened))
listenThread.get.start()
cancelDiscovery = false
discover()
@ -106,7 +106,7 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
val pm = PreferenceManager.getDefaultSharedPreferences(context)
val scanInterval =
pm.getString(Settings.KeyScanInterval, Settings.DefaultScanInterval.toString).toInt * 1000
pm.getString(SettingsInterface.KeyScanInterval, SettingsInterface.DefaultScanInterval.toString).toInt * 1000
mainHandler.postDelayed(new Runnable {
override def run(): Unit = discover()
}, scanInterval)
@ -128,7 +128,7 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
override def onReceive(context: Context, intent: Intent): Unit = {
discovered.filterNot(d => connections.keySet.contains(d.id))
.foreach { d =>
new ConnectThread(d, connectionOpened).start()
new BluetoothConnectThread(d, connectionOpened).start()
devices += (d.id -> d)
}
discovered = Set[Device]()
@ -162,7 +162,7 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
def connectionOpened(device: Device, socket: BluetoothSocket): Unit = {
devices += (device.id -> device)
connections += (device.id ->
new TransferThread(context, device, socket, this, crypto, onReceiveMessage))
new BluetoothTransferThread(context, device, socket, this, crypto, onReceiveMessage))
connections(device.id).start()
}
@ -198,13 +198,18 @@ class BluetoothInterface(context: Context, mainHandler: Handler,
/**
* Sends the message to nextHop.
*/
override def send(nextHop: Address, msg: Message): Unit =
connections.get(addressDeviceMap(nextHop)).foreach(_.send(msg))
override def send(nextHop: Address, msg: Message): Unit = {
addressDeviceMap
.find(_._1 == nextHop)
.map(i => connections.get(i._2))
.getOrElse(None)
.foreach(_.send(msg))
}
/**
* Returns all active Bluetooth connections.
*/
def getConnections: Set[Address] =
override def getConnections: Set[Address] =
connections.map(x => addressDeviceMap.find(_._2 == x._1).get._1).toSet
}

View file

@ -10,7 +10,7 @@ import android.util.Log
*
* @param name Service name to broadcast.
*/
class ListenThread(name: String, adapter: BluetoothAdapter,
class BluetoothListenThread(name: String, adapter: BluetoothAdapter,
onConnected: (Device, BluetoothSocket) => Unit) extends Thread {
private val Tag = "ListenThread"

View file

@ -17,7 +17,7 @@ import com.nutomic.ensichat.core.{Address, Crypto, Message}
* @param socket An open socket to the given device.
* @param onReceive Called when a message was received from the other device.
*/
class TransferThread(context: Context, device: Device, socket: BluetoothSocket, handler: BluetoothInterface,
class BluetoothTransferThread(context: Context, device: Device, socket: BluetoothSocket, handler: BluetoothInterface,
crypto: Crypto, onReceive: (Message, Device.ID) => Unit) extends Thread {
private val Tag = "TransferThread"
@ -30,6 +30,7 @@ class TransferThread(context: Context, device: Device, socket: BluetoothSocket,
} catch {
case e: IOException =>
Log.e(Tag, "Failed to open stream", e)
close()
null
}
@ -39,6 +40,7 @@ class TransferThread(context: Context, device: Device, socket: BluetoothSocket,
} catch {
case e: IOException =>
Log.e(Tag, "Failed to open stream", e)
close()
null
}

View file

@ -15,7 +15,7 @@ import android.view._
import android.widget.{ListView, TextView}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.{ConnectionsActivity, EnsichatActivity, MainActivity, SettingsActivity}
import com.nutomic.ensichat.core.interfaces.Settings
import com.nutomic.ensichat.core.interfaces.SettingsInterface
import com.nutomic.ensichat.service.{CallbackHandler, ChatService}
import com.nutomic.ensichat.util.Database
import com.nutomic.ensichat.views.UsersAdapter
@ -97,12 +97,13 @@ class ContactsFragment extends ListFragment with OnClickListener {
true
case R.id.my_address =>
val prefs = PreferenceManager.getDefaultSharedPreferences(getActivity)
val fragment = new IdenticonFragment()
val fragment = new UserInfoFragment()
val bundle = new Bundle()
bundle.putString(
IdenticonFragment.ExtraAddress, ChatService.newCrypto(getActivity).localAddress.toString)
UserInfoFragment.ExtraAddress, ChatService.newCrypto(getActivity).localAddress.toString)
bundle.putString(
IdenticonFragment.ExtraUserName, prefs.getString(Settings.KeyUserName, ""))
UserInfoFragment.ExtraUserName, prefs.getString(SettingsInterface.KeyUserName, ""))
bundle.putBoolean(UserInfoFragment.ExtraShowQr, true)
fragment.setArguments(bundle)
fragment.show(getFragmentManager, "dialog")
true

View file

@ -1,42 +0,0 @@
package com.nutomic.ensichat.fragments
import android.app.{AlertDialog, Dialog, DialogFragment}
import android.os.Bundle
import android.view.LayoutInflater
import android.widget.{ImageView, TextView}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.core.Address
import com.nutomic.ensichat.util.IdenticonGenerator
object IdenticonFragment {
val ExtraAddress = "address"
val ExtraUserName = "user_name"
}
/**
* Displays identicon, username and address for a user.
*
* Use [[IdenticonFragment#getInstance]] to invoke.
*/
class IdenticonFragment extends DialogFragment {
private lazy val address = new Address(getArguments.getString(IdenticonFragment.ExtraAddress))
private lazy val userName = getArguments.getString(IdenticonFragment.ExtraUserName)
override def onCreateDialog(savedInstanceState: Bundle): Dialog = {
val view = LayoutInflater.from(getActivity).inflate(R.layout.fragment_identicon, null)
view.findViewById(R.id.identicon)
.asInstanceOf[ImageView]
.setImageBitmap(IdenticonGenerator.generate(address, (150, 150), getActivity))
view.findViewById(R.id.address)
.asInstanceOf[TextView]
.setText(getString(R.string.address_colon, address.toString))
new AlertDialog.Builder(getActivity)
.setTitle(userName)
.setView(view)
.setPositiveButton(android.R.string.ok, null)
.create()
}
}

View file

@ -1,16 +1,16 @@
package com.nutomic.ensichat.fragments
import android.content.SharedPreferences
import android.content.{Intent, SharedPreferences}
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Bundle
import android.preference.Preference.OnPreferenceChangeListener
import android.preference.{Preference, PreferenceFragment, PreferenceManager}
import com.nutomic.ensichat.{BuildConfig, R}
import android.preference.{PreferenceFragment, PreferenceManager}
import com.nutomic.ensichat.activities.EnsichatActivity
import com.nutomic.ensichat.core.body.UserInfo
import com.nutomic.ensichat.core.interfaces.Settings._
import com.nutomic.ensichat.core.interfaces.SettingsInterface._
import com.nutomic.ensichat.fragments.SettingsFragment._
import com.nutomic.ensichat.service.ChatService
import com.nutomic.ensichat.util.Database
import com.nutomic.ensichat.{BuildConfig, R}
object SettingsFragment {
val Version = "version"
@ -55,6 +55,10 @@ class SettingsFragment extends PreferenceFragment with OnSharedPreferenceChangeL
val service = getActivity.asInstanceOf[EnsichatActivity].service
val ui = new UserInfo(prefs.getString(KeyUserName, ""), prefs.getString(KeyUserStatus, ""))
database.getContacts.foreach(c => service.get.sendTo(c.address, ui))
case KeyServers =>
val intent = new Intent(getActivity, classOf[ChatService])
intent.setAction(ChatService.ActionNetworkChanged)
getActivity.startService(intent)
case _ =>
}
}

View file

@ -0,0 +1,76 @@
package com.nutomic.ensichat.fragments
import android.app.{AlertDialog, Dialog, DialogFragment}
import android.graphics.{Bitmap, Color}
import android.os.Bundle
import android.view.LayoutInflater
import android.widget.{ImageView, TextView}
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import com.nutomic.ensichat.R
import com.nutomic.ensichat.core.Address
import com.nutomic.ensichat.util.IdenticonGenerator
object UserInfoFragment {
val ExtraAddress = "address"
val ExtraUserName = "user_name"
val ExtraShowQr = "show_qr"
}
/**
* Displays identicon, username and address for a user.
*
* Use [[UserInfoFragment#getInstance]] to invoke.
*/
class UserInfoFragment extends DialogFragment {
private lazy val address = new Address(getArguments.getString(UserInfoFragment.ExtraAddress))
private lazy val userName = getArguments.getString(UserInfoFragment.ExtraUserName)
private lazy val showQr = getArguments.getBoolean(UserInfoFragment.ExtraShowQr)
override def onCreateDialog(savedInstanceState: Bundle): Dialog = {
val view = LayoutInflater.from(getActivity).inflate(R.layout.fragment_identicon, null)
view.findViewById(R.id.identicon)
.asInstanceOf[ImageView]
.setImageBitmap(IdenticonGenerator.generate(address, (150, 150), getActivity))
view.findViewById(R.id.address)
.asInstanceOf[TextView]
.setText(getString(R.string.address_colon, address.toString()))
if (showQr) {
val matrix = new QRCodeWriter().encode(address.toString(), BarcodeFormat.QR_CODE, 150, 150)
view.findViewById(R.id.qr_code)
.asInstanceOf[ImageView]
.setImageBitmap(renderMatrix(matrix))
}
new AlertDialog.Builder(getActivity)
.setTitle(userName)
.setView(view)
.setPositiveButton(android.R.string.ok, null)
.create()
}
/**
* Converts a [[BitMatrix]] instance into a [[Bitmap]].
*/
private def renderMatrix(bitMatrix: BitMatrix): Bitmap = {
val height = bitMatrix.getHeight
val width = bitMatrix.getWidth
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x <- 0 until width) {
for (y <- 0 until height) {
val color =
if (bitMatrix.get(x,y))
Color.BLACK
else
Color.WHITE
bmp.setPixel(x, y, color)
}
}
bmp
}
}

View file

@ -2,8 +2,8 @@ package com.nutomic.ensichat.service
import android.content.{Context, Intent}
import android.support.v4.content.LocalBroadcastManager
import com.nutomic.ensichat.core.{ConnectionHandler, Message}
import com.nutomic.ensichat.core.interfaces.CallbackInterface
import com.nutomic.ensichat.core.{ConnectionHandler, Message}
import com.nutomic.ensichat.service.CallbackHandler._
object CallbackHandler {

View file

@ -3,12 +3,13 @@ package com.nutomic.ensichat.service
import java.io.File
import android.app.Service
import android.content.{Context, Intent}
import android.bluetooth.BluetoothAdapter
import android.content.{Context, Intent, IntentFilter}
import android.net.ConnectivityManager
import android.os.Handler
import com.nutomic.ensichat.bluetooth.BluetoothInterface
import com.nutomic.ensichat.core.interfaces.Log
import com.nutomic.ensichat.core.{ConnectionHandler, Crypto}
import com.nutomic.ensichat.util.{Database, PRNGFixes, SettingsWrapper}
import com.nutomic.ensichat.util.{Database, NetworkChangedReceiver, SettingsWrapper}
object ChatService {
@ -17,6 +18,8 @@ object ChatService {
private def keyFolder(context: Context) = new File(context.getFilesDir, "keys")
def newCrypto(context: Context) = new Crypto(new SettingsWrapper(context), keyFolder(context))
val ActionNetworkChanged = "network_changed"
}
class ChatService extends Service {
@ -29,28 +32,39 @@ class ChatService extends Service {
private lazy val connectionHandler =
new ConnectionHandler(new SettingsWrapper(this), new Database(this), callbackHandler,
ChatService.keyFolder(this))
ChatService.newCrypto(this))
private val networkReceiver = new NetworkChangedReceiver()
override def onBind(intent: Intent) = binder
override def onStartCommand(intent: Intent, flags: Int, startId: Int) = Service.START_STICKY
override def onStartCommand(intent: Intent, flags: Int, startId: Int): Int = {
Option(intent).foreach { i =>
if (i.getAction == ChatService.ActionNetworkChanged)
connectionHandler.internetConnectionChanged()
}
Service.START_STICKY
}
/**
* Generates keys and starts Bluetooth interface.
*/
override def onCreate(): Unit = {
super.onCreate()
PRNGFixes.apply()
Log.setLogClass(classOf[android.util.Log])
notificationHandler.showPersistentNotification()
if (Option(BluetoothAdapter.getDefaultAdapter).isDefined) {
connectionHandler.addTransmissionInterface(new BluetoothInterface(this, new Handler(),
connectionHandler))
}
connectionHandler.start()
connectionHandler.setTransmissionInterface(new BluetoothInterface(this, new Handler(),
connectionHandler))
registerReceiver(networkReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
}
override def onDestroy(): Unit = {
notificationHandler.cancelPersistentNotification()
connectionHandler.stop()
unregisterReceiver(networkReceiver)
}
def getConnectionHandler = connectionHandler

View file

@ -8,7 +8,7 @@ import com.nutomic.ensichat.R
import com.nutomic.ensichat.activities.MainActivity
import com.nutomic.ensichat.core.Message
import com.nutomic.ensichat.core.body.Text
import com.nutomic.ensichat.core.interfaces.Settings
import com.nutomic.ensichat.core.interfaces.SettingsInterface
import com.nutomic.ensichat.service.NotificationHandler._
object NotificationHandler {
@ -65,7 +65,7 @@ class NotificationHandler(context: Context) {
*/
private def defaults(): Int = {
val sp = PreferenceManager.getDefaultSharedPreferences(context)
if (sp.getBoolean(Settings.KeyNotificationSoundsOn, Settings.DefaultNotificationSoundsOn))
if (sp.getBoolean(SettingsInterface.KeyNotificationSoundsOn, SettingsInterface.DefaultNotificationSoundsOn))
Notification.DEFAULT_ALL
else
Notification.DEFAULT_VIBRATE | Notification.DEFAULT_LIGHTS

View file

@ -128,7 +128,7 @@ class Database(context: Context) extends DatabaseInterface {
def addContact(contact: User): Unit = {
val cv = new ContentValues()
cv.put("address", contact.address.toString)
cv.put("address", contact.address.toString())
cv.put("name", contact.name)
cv.put("status", contact.status)
helper.getWritableDatabase.insert("contacts", null, cv)
@ -139,7 +139,7 @@ class Database(context: Context) extends DatabaseInterface {
val cv = new ContentValues()
cv.put("name", contact.name)
cv.put("status", contact.status)
helper.getWritableDatabase.update("contacts", cv, "address = ?", Array(contact.address.toString))
helper.getWritableDatabase.update("contacts", cv, "address = ?", Array(contact.address.toString()))
contactsUpdated()
}

View file

@ -0,0 +1,13 @@
package com.nutomic.ensichat.util
import android.util
import com.nutomic.ensichat.core.interfaces.Log
class Logging extends Log {
def v(tag: String, message: String, tr: Throwable = null) = util.Log.v(tag, message, tr)
def d(tag: String, message: String, tr: Throwable = null) = util.Log.d(tag, message, tr)
def i(tag: String, message: String, tr: Throwable = null) = util.Log.i(tag, message, tr)
def w(tag: String, message: String, tr: Throwable = null) = util.Log.w(tag, message, tr)
def e(tag: String, message: String, tr: Throwable = null) = util.Log.e(tag, message, tr)
}

View file

@ -0,0 +1,14 @@
package com.nutomic.ensichat.util
import android.content.{BroadcastReceiver, Context, Intent}
import com.nutomic.ensichat.service.ChatService
class NetworkChangedReceiver extends BroadcastReceiver {
override def onReceive(context: Context, intent: Intent): Unit = {
val intent = new Intent(context, classOf[ChatService])
intent.setAction(ChatService.ActionNetworkChanged)
context.startService(intent)
}
}

View file

@ -2,9 +2,9 @@ package com.nutomic.ensichat.util
import android.content.Context
import android.preference.PreferenceManager
import com.nutomic.ensichat.core.interfaces.Settings
import com.nutomic.ensichat.core.interfaces.SettingsInterface
class SettingsWrapper(context: Context) extends Settings {
class SettingsWrapper(context: Context) extends SettingsInterface {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)

View file

@ -8,7 +8,7 @@ import android.view.{LayoutInflater, View, ViewGroup}
import android.widget.{ArrayAdapter, ImageView, TextView}
import com.nutomic.ensichat.R
import com.nutomic.ensichat.core.User
import com.nutomic.ensichat.fragments.IdenticonFragment
import com.nutomic.ensichat.fragments.UserInfoFragment
import com.nutomic.ensichat.util.IdenticonGenerator
/**
@ -40,10 +40,10 @@ class UsersAdapter(activity: Activity) extends ArrayAdapter[User](activity, 0) w
override def onClick (v: View): Unit = {
val user = v.getTag.asInstanceOf[User]
val fragment = new IdenticonFragment()
val fragment = new UserInfoFragment()
val bundle = new Bundle()
bundle.putString(IdenticonFragment.ExtraAddress, user.address.toString)
bundle.putString(IdenticonFragment.ExtraUserName, user.name)
bundle.putString(UserInfoFragment.ExtraAddress, user.address.toString)
bundle.putString(UserInfoFragment.ExtraUserName, user.name)
fragment.setArguments(bundle)
fragment.show(activity.getFragmentManager, "dialog")
}

View file

@ -24,7 +24,7 @@ object Address {
*
* @param bytes SHA-256 hash of the node's public key.
*/
case class Address(bytes: Array[Byte]) {
final case class Address(bytes: Array[Byte]) {
require(bytes.length == Address.Length, "Invalid address length (was " + bytes.length + ")")

View file

@ -6,6 +6,7 @@ import java.util.Date
import com.nutomic.ensichat.core.body.{ConnectionInfo, MessageBody, UserInfo}
import com.nutomic.ensichat.core.header.ContentHeader
import com.nutomic.ensichat.core.interfaces._
import com.nutomic.ensichat.core.internet.InternetInterface
import com.nutomic.ensichat.core.util.FutureHelper
import scala.concurrent.ExecutionContext.Implicits.global
@ -13,14 +14,12 @@ import scala.concurrent.ExecutionContext.Implicits.global
/**
* High-level handling of all message transfers and callbacks.
*/
class ConnectionHandler(settings: Settings, database: DatabaseInterface,
callbacks: CallbackInterface, keyFolder: File) {
final class ConnectionHandler(settings: SettingsInterface, database: DatabaseInterface,
callbacks: CallbackInterface, crypto: Crypto) {
private val Tag = "ConnectionHandler"
private lazy val crypto = new Crypto(settings, keyFolder)
private var transmissionInterface: TransmissionInterface = _
private var transmissionInterfaces = Set[TransmissionInterface]()
private lazy val router = new Router(connections, sendVia)
@ -40,16 +39,22 @@ class ConnectionHandler(settings: Settings, database: DatabaseInterface,
FutureHelper {
crypto.generateLocalKeys()
Log.i(Tag, "Service started, address is " + crypto.localAddress)
Log.i(Tag, "Local user is " + settings.get(SettingsInterface.KeyUserName, "none") +
" with status '" + settings.get(SettingsInterface.KeyUserStatus, "") + "'")
transmissionInterfaces += new InternetInterface(this, crypto, settings)
transmissionInterfaces.foreach(_.create())
}
}
def stop(): Unit = {
transmissionInterface.destroy()
transmissionInterfaces.foreach(_.destroy())
}
def setTransmissionInterface(interface: TransmissionInterface) = {
transmissionInterface = interface
transmissionInterface.create()
/**
* NOTE: This *must* be called before [[start()]], or it will have no effect.
*/
def addTransmissionInterface(interface: TransmissionInterface) = {
transmissionInterfaces += interface
}
/**
@ -70,7 +75,7 @@ class ConnectionHandler(settings: Settings, database: DatabaseInterface,
}
private def sendVia(nextHop: Address, msg: Message) =
transmissionInterface.send(nextHop, msg)
transmissionInterfaces.foreach(_.send(nextHop, msg))
/**
* Decrypts and verifies incoming messages, forwards valid ones to [[onNewMessage()]].
@ -78,10 +83,8 @@ class ConnectionHandler(settings: Settings, database: DatabaseInterface,
def onMessageReceived(msg: Message): Unit = {
if (msg.header.target == crypto.localAddress) {
crypto.verifyAndDecrypt(msg) match {
case Some(msg) => onNewMessage(msg)
case None =>
Log.i(Tag, "Ignoring message with invalid signature from " + msg.header.origin)
return
case Some(m) => onNewMessage(m)
case None => Log.i(Tag, "Ignoring message with invalid signature from " + msg.header.origin)
}
} else {
router.onReceive(msg)
@ -118,7 +121,8 @@ class ConnectionHandler(settings: Settings, database: DatabaseInterface,
* @return True if the connection is valid
*/
def onConnectionOpened(msg: Message): Boolean = {
val maxConnections = settings.get(Settings.KeyMaxConnections, Settings.DefaultMaxConnections.toString).toInt
val maxConnections = settings.get(SettingsInterface.KeyMaxConnections,
SettingsInterface.DefaultMaxConnections.toString).toInt
if (connections().size == maxConnections) {
Log.i(Tag, "Maximum number of connections reached")
return false
@ -143,18 +147,35 @@ class ConnectionHandler(settings: Settings, database: DatabaseInterface,
}
}
Log.i(Tag, "Node " + sender + " connected")
sendTo(sender, new UserInfo(settings.get(Settings.KeyUserName, ""),
settings.get(Settings.KeyUserStatus, "")))
// Log with username if we know it.
if (allKnownUsers().map(_.address).contains(sender))
Log.i(Tag, "Node " + getUser(sender).name + " (" + sender + ") connected")
else
Log.i(Tag, "Node " + sender + " connected")
sendTo(sender, new UserInfo(settings.get(SettingsInterface.KeyUserName, ""),
settings.get(SettingsInterface.KeyUserStatus, "")))
callbacks.onConnectionsChanged()
true
}
def onConnectionClosed() = callbacks.onConnectionsChanged()
def connections() = transmissionInterface.getConnections
def connections(): Set[Address] = transmissionInterfaces.flatMap(_.getConnections)
private def allKnownUsers() = database.getContacts ++ knownUsers
/**
* Returns [[User]] object containing the user's name (if we know it).
*/
def getUser(address: Address) =
knownUsers.find(_.address == address).getOrElse(new User(address, address.toString, ""))
allKnownUsers()
.find(_.address == address)
.getOrElse(new User(address, address.toString(), ""))
def internetConnectionChanged(): Unit = {
transmissionInterfaces
.find(_.isInstanceOf[InternetInterface])
.foreach(_.asInstanceOf[InternetInterface].connectionChanged())
}
}

View file

@ -9,7 +9,7 @@ import javax.crypto.{Cipher, CipherOutputStream, KeyGenerator, SecretKey}
import com.nutomic.ensichat.core.Crypto._
import com.nutomic.ensichat.core.body._
import com.nutomic.ensichat.core.header.ContentHeader
import com.nutomic.ensichat.core.interfaces.{Log, Settings}
import com.nutomic.ensichat.core.interfaces.{Log, SettingsInterface}
object Crypto {
@ -20,6 +20,11 @@ object Crypto {
*/
val PublicKeyAlgorithm = "RSA"
/**
* Algorithm used to read public keys.
*/
val CipherAlgorithm = "RSA/ECB/PKCS1Padding"
/**
* Length of the local public/private keypair in bits.
*/
@ -50,7 +55,7 @@ object Crypto {
/**
* Name of the preference where the local address is stored.
*/
private val LocalAddressKey = "local_address"
val LocalAddressKey = "local_address"
/**
* Filename of the local private key in [[Crypto.keyFolder]].
@ -60,7 +65,7 @@ object Crypto {
/**
* Filename of the local public key in [[Crypto.keyFolder]].
*/
private val PublicKeyAlias = "local-public"
val PublicKeyAlias = "local-public"
}
@ -69,17 +74,17 @@ object Crypto {
*
* @param keyFolder Folder where private and public keys are stored.
*/
class Crypto(settings: Settings, keyFolder: File) {
class Crypto(settings: SettingsInterface, keyFolder: File) {
private val Tag = "Crypto"
/**
* Generates a new key pair using [[ [[Crypto.keyFolder]].]] with [[KeySize]] bits and stores the
* Generates a new key pair using [[keyFolder]] with [[PublicKeySize]] bits and stores the
* keys.
*
* Does nothing if the key pair already exists.
*/
def generateLocalKeys(): Unit = {
private[core] def generateLocalKeys(): Unit = {
if (localKeysExist)
return
@ -92,7 +97,7 @@ class Crypto(settings: Settings, keyFolder: File) {
address = calculateAddress(keyPair.getPublic)
// The hash must have at least one bit set to not collide with the broadcast address.
// Never generate an invalid address.
} while(address == Address.Broadcast || address == Address.Null)
settings.put(LocalAddressKey, address.toString)
@ -105,7 +110,7 @@ class Crypto(settings: Settings, keyFolder: File) {
/**
* Returns true if we have a public key stored for the given device.
*/
def havePublicKey(address: Address): Boolean = new File(keyFolder, address.toString).exists()
private[core] def havePublicKey(address: Address) = new File(keyFolder, address.toString).exists()
/**
* Returns the public key for the given device.
@ -113,7 +118,7 @@ class Crypto(settings: Settings, keyFolder: File) {
* @throws RuntimeException If the key does not exist.
*/
@throws[RuntimeException]
def getPublicKey(address: Address): PublicKey = {
private[core] def getPublicKey(address: Address): PublicKey = {
loadKey(address.toString, classOf[PublicKey])
}
@ -123,7 +128,7 @@ class Crypto(settings: Settings, keyFolder: File) {
* @throws RuntimeException If a key already exists for this address.
*/
@throws[RuntimeException]
def addPublicKey(address: Address, key: PublicKey): Unit = {
private[core] def addPublicKey(address: Address, key: PublicKey): Unit = {
if (havePublicKey(address))
throw new RuntimeException("Already have key for " + address + ", not overwriting")
@ -138,7 +143,8 @@ class Crypto(settings: Settings, keyFolder: File) {
new Message(msg.header, new CryptoData(Option(sig.sign), msg.crypto.key), msg.body)
}
def verify(msg: Message, key: Option[PublicKey] = None): Boolean = {
@throws[InvalidKeyException]
private[core] def verify(msg: Message, key: Option[PublicKey] = None): Boolean = {
val sig = Signature.getInstance(SigningAlgorithm)
lazy val defaultKey = loadKey(msg.header.origin.toString, classOf[PublicKey])
sig.initVerify(key.getOrElse(defaultKey))
@ -149,7 +155,7 @@ class Crypto(settings: Settings, keyFolder: File) {
/**
* Returns true if the local private and public key exist.
*/
def localKeysExist = new File(keyFolder, PublicKeyAlias).exists()
private[core] def localKeysExist = new File(keyFolder, PublicKeyAlias).exists()
/**
* Returns the local public key.
@ -193,7 +199,7 @@ class Crypto(settings: Settings, keyFolder: File) {
* @return The key read from storage.
* @throws RuntimeException If the key does not exist.
*/
private def loadKey[T](alias: String, keyType: Class[T]): T = {
private[core] def loadKey[T](alias: String, keyType: Class[T]): T = {
val path = new File(keyFolder, alias)
if (!path.exists()) {
throw new RuntimeException("The requested key with alias " + alias + " does not exist")
@ -221,15 +227,22 @@ class Crypto(settings: Settings, keyFolder: File) {
}
}
def encryptAndSign(msg: Message, key: Option[PublicKey] = None): Message = {
private[core] def encryptAndSign(msg: Message, key: Option[PublicKey] = None): Message = {
sign(encrypt(msg, key))
}
def verifyAndDecrypt(msg: Message, key: Option[PublicKey] = None): Option[Message] = {
if (verify(msg, key))
Option(decrypt(msg))
else
None
private[core] def verifyAndDecrypt(msg: Message, key: Option[PublicKey] = None): Option[Message] = {
// Catch exception to avoid crash if we receive invalid message.
try {
if (verify(msg, key))
Option(decrypt(msg))
else
None
} catch {
case e: InvalidKeyException =>
Log.w(Tag, "Failed to verify or decrypt message", e)
None
}
}
private def encrypt(msg: Message, key: Option[PublicKey] = None): Message = {
@ -240,7 +253,7 @@ class Crypto(settings: Settings, keyFolder: File) {
val encrypted = new EncryptedBody(copyThroughCipher(symmetricCipher, msg.body.write))
// Asymmetric encryption of secret key
val asymmetricCipher = Cipher.getInstance(PublicKeyAlgorithm)
val asymmetricCipher = Cipher.getInstance(CipherAlgorithm)
lazy val defaultKey = loadKey(msg.header.target.toString, classOf[PublicKey])
asymmetricCipher.init(Cipher.WRAP_MODE, key.getOrElse(defaultKey))
@ -248,9 +261,10 @@ class Crypto(settings: Settings, keyFolder: File) {
new CryptoData(None, Option(asymmetricCipher.wrap(secretKey))), encrypted)
}
@throws[InvalidKeyException]
private def decrypt(msg: Message): Message = {
// Asymmetric decryption of secret key
val asymmetricCipher = Cipher.getInstance(PublicKeyAlgorithm)
val asymmetricCipher = Cipher.getInstance(CipherAlgorithm)
asymmetricCipher.init(Cipher.UNWRAP_MODE, loadKey(PrivateKeyAlias, classOf[PrivateKey]))
val key = asymmetricCipher.unwrap(msg.crypto.key.get, SymmetricKeyAlgorithm, Cipher.SECRET_KEY)

View file

@ -5,7 +5,7 @@ import com.nutomic.ensichat.core.header.{ContentHeader, MessageHeader}
/**
* Forwards messages to all connected devices.
*/
class Router(activeConnections: () => Set[Address], send: (Address, Message) => Unit) {
final private[core] class Router(activeConnections: () => Set[Address], send: (Address, Message) => Unit) {
private var messageSeen = Set[(Address, Int)]()

View file

@ -1,12 +1,12 @@
package com.nutomic.ensichat.core
import com.nutomic.ensichat.core.header.ContentHeader
import com.nutomic.ensichat.core.interfaces.Settings
import com.nutomic.ensichat.core.interfaces.SettingsInterface
/**
* Generates sequence numbers according to protocol, which are stored persistently.
*/
class SeqNumGenerator(preferences: Settings) {
final private[core] class SeqNumGenerator(preferences: SettingsInterface) {
private val KeySequenceNumber = "sequence_number"

View file

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

View file

@ -32,7 +32,7 @@ object ConnectionInfo {
/**
* Holds a node's public key.
*/
case class ConnectionInfo(key: PublicKey) extends MessageBody {
final case class ConnectionInfo(key: PublicKey) extends MessageBody {
override def protocolType = ConnectionInfo.Type

View file

@ -35,7 +35,7 @@ object CryptoData {
/**
* Holds the signature and (optional) key that are stored in a message.
*/
case class CryptoData(signature: Option[Array[Byte]], key: Option[Array[Byte]]) {
final case class CryptoData(signature: Option[Array[Byte]], key: Option[Array[Byte]]) {
override def equals(a: Any): Boolean = a match {
case o: CryptoData => util.Arrays.equals(signature.orNull, o.signature.orNull) &&

View file

@ -3,7 +3,7 @@ package com.nutomic.ensichat.core.body
/**
* Represents the data in an encrypted message body.
*/
case class EncryptedBody(data: Array[Byte]) extends MessageBody {
final case class EncryptedBody(data: Array[Byte]) extends MessageBody {
override def protocolType = -1

View file

@ -25,7 +25,7 @@ object Text {
/**
* Holds a plain text message.
*/
case class Text(text: String) extends MessageBody {
final case class Text(text: String) extends MessageBody {
override def protocolType = -1

View file

@ -29,7 +29,7 @@ object UserInfo {
/**
* Holds display name and status of the sender.
*/
case class UserInfo(name: String, status: String) extends MessageBody {
final case class UserInfo(name: String, status: String) extends MessageBody {
override def protocolType = -1

View file

@ -39,7 +39,7 @@ object ContentHeader {
*
* This is [[AbstractHeader]] with messageId and time fields set.
*/
case class ContentHeader(override val origin: Address,
final case class ContentHeader(override val origin: Address,
override val target: Address,
override val seqNum: Int,
contentType: Int,

View file

@ -41,7 +41,7 @@ object MessageHeader {
*
* This is the same as [[AbstractHeader]].
*/
case class MessageHeader(override val protocolType: Int,
final case class MessageHeader(override val protocolType: Int,
override val origin: Address,
override val target: Address,
override val seqNum: Int,

View file

@ -7,4 +7,5 @@ trait CallbackInterface {
def onMessageReceived(msg: Message): Unit
def onConnectionsChanged(): Unit
}

View file

@ -2,28 +2,24 @@ package com.nutomic.ensichat.core.interfaces
object Log {
def setLogClass[T](logClass: Class[T]) = {
this.logClass = Option(logClass)
}
def setLogInstance(log: Log) = instance = Option(log)
private var logClass: Option[Class[_]] = None
private var instance: Option[Log] = None
def v(tag: String, message: String, tr: Throwable = null) = log("v", tag, message, tr)
def d(tag: String, message: String, tr: Throwable = null) = log("d", tag, message, tr)
def i(tag: String, message: String, tr: Throwable = null) = log("i", tag, message, tr)
def w(tag: String, message: String, tr: Throwable = null) = log("w", tag, message, tr)
def e(tag: String, message: String, tr: Throwable = null) = log("e", tag, message, tr)
private def log(level: String, tag: String, message: String, throwable: Throwable) = logClass match {
case Some(l) =>
l.getMethod(level, classOf[String], classOf[String], classOf[Throwable])
.invoke(null, tag, message, throwable)
case None =>
System.out.println(level + tag + message + throwable)
}
def v(tag: String, message: String, tr: Throwable = null) = instance.foreach(_.v(tag, message, tr))
def d(tag: String, message: String, tr: Throwable = null) = instance.foreach(_.d(tag, message, tr))
def i(tag: String, message: String, tr: Throwable = null) = instance.foreach(_.i(tag, message, tr))
def w(tag: String, message: String, tr: Throwable = null) = instance.foreach(_.w(tag, message, tr))
def e(tag: String, message: String, tr: Throwable = null) = instance.foreach(_.e(tag, message, tr))
}
trait Log {
def v(tag: String, message: String, tr: Throwable = null)
def d(tag: String, message: String, tr: Throwable = null)
def i(tag: String, message: String, tr: Throwable = null)
def w(tag: String, message: String, tr: Throwable = null)
def e(tag: String, message: String, tr: Throwable = null)
}

View file

@ -1,34 +1,40 @@
package com.nutomic.ensichat.core.interfaces
object Settings {
object SettingsInterface {
val KeyUserName = "user_name"
val KeyUserStatus = "user_status"
val KeyNotificationSoundsOn = "notification_sounds"
/**
* NOTE: Stored as string in settings.
* NOTE: Stored as string.
*/
val KeyScanInterval = "scan_interval_seconds"
/**
* NOTE: Stored as string in settings.
* NOTE: Stored as string.
*/
val KeyMaxConnections = "max_connections"
/**
* NOTE: Stored as comma separated string.
*/
val KeyServers = "servers"
val DefaultUserStatus = "Let's chat!"
val DefaultScanInterval = 15
val DefaultNotificationSoundsOn = true
val DefaultMaxConnections = 1000000
val DefaultServers = Set("46.101.249.188:26344")
}
/**
* Interface for persistent storage of key value pairs.
*
* Must support at least storage of strings and integers.
* Must support at least storage of String, Int, Long.
*/
trait Settings {
trait SettingsInterface {
def put[T](key: String, value: T): Unit
def get[T](key: String, default: T): T

View file

@ -0,0 +1,87 @@
package com.nutomic.ensichat.core.internet
import java.io.{IOException, InputStream, OutputStream}
import java.net.{InetAddress, Socket}
import com.nutomic.ensichat.core.Message.ReadMessageException
import com.nutomic.ensichat.core.body.ConnectionInfo
import com.nutomic.ensichat.core.header.MessageHeader
import com.nutomic.ensichat.core.interfaces.Log
import com.nutomic.ensichat.core.{Address, Crypto, Message}
/**
* Encapsulates an active connection to another node.
*/
class InternetConnectionThread(socket: Socket, crypto: Crypto, onDisconnected: (InternetConnectionThread) => Unit,
onReceive: (Message, InternetConnectionThread) => Unit) extends Thread {
private val Tag = "InternetConnectionThread"
private val inStream: InputStream =
try {
socket.getInputStream
} catch {
case e: IOException =>
Log.e(Tag, "Failed to open stream", e)
close()
null
}
private val outStream: OutputStream =
try {
socket.getOutputStream
} catch {
case e: IOException =>
Log.e(Tag, "Failed to open stream", e)
close()
null
}
def internetAddress(): InetAddress = {
socket.getInetAddress
}
override def run(): Unit = {
Log.i(Tag, "Connection opened to " + socket.getInetAddress)
send(crypto.sign(new Message(new MessageHeader(ConnectionInfo.Type,
Address.Null, Address.Null, 0), new ConnectionInfo(crypto.getLocalPublicKey))))
socket.setKeepAlive(true)
while (socket.isConnected) {
try {
if (inStream.available() > 0) {
val msg = Message.read(inStream)
onReceive(msg, this)
}
} catch {
case e @ (_: ReadMessageException | _: IOException) =>
Log.w(Tag, "Failed to read incoming message", e)
close()
return
}
}
close()
Log.d(Tag, "exited: " + socket.isConnected)
}
def send(msg: Message): Unit = {
try {
outStream.write(msg.write)
} catch {
case e: IOException => Log.e(Tag, "Failed to write message", e)
}
}
def close(): Unit = {
try {
socket.close()
} catch {
case e: IOException => Log.w(Tag, "Failed to close socket", e)
}
Log.d(Tag, "Connection to " + socket.getInetAddress + " closed")
onDisconnected(this)
}
}

View file

@ -0,0 +1,132 @@
package com.nutomic.ensichat.core.internet
import java.io.IOException
import java.net.{InetAddress, Socket}
import com.nutomic.ensichat.core.body.ConnectionInfo
import com.nutomic.ensichat.core.interfaces.{SettingsInterface, Log, TransmissionInterface}
import com.nutomic.ensichat.core.util.FutureHelper
import com.nutomic.ensichat.core.{Address, ConnectionHandler, Crypto, Message}
import scala.concurrent.ExecutionContext.Implicits.global
object InternetInterface {
val ServerPort = 26344
}
/**
* Handles all Internet connectivity.
*/
class InternetInterface(connectionHandler: ConnectionHandler, crypto: Crypto,
settings: SettingsInterface) extends TransmissionInterface {
private val Tag = "InternetInterface"
private lazy val serverThread =
new InternetServerThread(crypto, onConnected, onDisconnected, onReceiveMessage)
private var connections = Set[InternetConnectionThread]()
private var addressDeviceMap = Map[Address, InternetConnectionThread]()
/**
* Initializes and starts discovery and listening.
*/
override def create(): Unit = {
FutureHelper {
serverThread.start()
openAllConnections()
}
}
/**
* Stops discovery and listening.
*/
override def destroy(): Unit = {
serverThread.cancel()
connections.foreach(_.close())
}
private def openAllConnections(): Unit =
settings.get(SettingsInterface.KeyServers, "")
.split(",")
.map(_.trim())
.foreach(openConnection)
private def openConnection(addressPort: String): Unit = {
val split = addressPort.split(":")
if (split.length >= 2) {
Log.d(Tag, "Attempting connection to " + addressPort)
openConnection(split(0), split(1).toInt)
}
}
/**
* Opens connection to the specified IP address in client mode.
*/
private def openConnection(nodeAddress: String, port: Int): Unit = {
try {
val socket = new Socket(InetAddress.getByName(nodeAddress), port)
val ct = new InternetConnectionThread(socket, crypto, onDisconnected, onReceiveMessage)
connections += ct
ct.start()
} catch {
case e: IOException =>
Log.w(Tag, "Failed to open connection to " + nodeAddress + ":" + port, e)
}
}
private def onConnected(connectionThread: InternetConnectionThread): Unit = {
connections += connectionThread
}
private def onDisconnected(connectionThread: InternetConnectionThread): Unit = {
addressDeviceMap.find(_._2 == connectionThread).foreach { ad =>
Log.d(Tag, "Connection closed to " + ad._1)
connections -= connectionThread
addressDeviceMap -= ad._1
connectionHandler.onConnectionClosed()
}
}
private def onReceiveMessage(msg: Message, thread: InternetConnectionThread): Unit = msg.body match {
case info: ConnectionInfo =>
val address = crypto.calculateAddress(info.key)
if (address == crypto.localAddress) {
Log.i(Tag, "Address " + address + " is me, not connecting to myself")
thread.close()
return
}
// Service.onConnectionOpened sends message, so mapping already needs to be in place.
addressDeviceMap += (address -> thread)
if (!connectionHandler.onConnectionOpened(msg))
addressDeviceMap -= address
case _ =>
connectionHandler.onMessageReceived(msg)
}
/**
* Sends the message to nextHop.
*/
override def send(nextHop: Address, msg: Message): Unit = {
addressDeviceMap
.find(_._1 == nextHop)
.foreach(_._2.send(msg))
}
/**
* Returns all active Internet connections.
*/
override def getConnections = addressDeviceMap.keySet
def connectionChanged(): Unit = {
FutureHelper {
Log.i(Tag, "Network has changed. Close all connections and connect to bootstrap nodes again")
connections.foreach(_.close())
openAllConnections()
}
}
}

View file

@ -0,0 +1,44 @@
package com.nutomic.ensichat.core.internet
import java.io.{IOException, PrintStream}
import java.net.{Socket, ServerSocket}
import com.nutomic.ensichat.core.{Message, Crypto}
import com.nutomic.ensichat.core.interfaces.Log
import scala.io.BufferedSource
class InternetServerThread(crypto: Crypto, onConnected: (InternetConnectionThread) => Unit,
onDisconnected: (InternetConnectionThread) => Unit, onReceive: (Message, InternetConnectionThread) => Unit) extends Thread {
private val Tag = "InternetServerThread"
private lazy val socket: Option[ServerSocket] = try {
Option(new ServerSocket(InternetInterface.ServerPort))
} catch {
case e: IOException =>
Log.w(Tag, "Failed to create server socket", e)
None
}
override def run(): Unit = {
try {
while (socket.get.isBound) {
val connection = new InternetConnectionThread(socket.get.accept(), crypto, onDisconnected, onReceive)
onConnected(connection)
connection.start()
}
} catch {
case e: IOException => Log.w(Tag, "Failed to accept connection", e)
}
}
def cancel(): Unit = {
try {
socket.get.close()
} catch {
case e: IOException => Log.w(Tag, "Failed to close socket", e)
}
}
}

View file

@ -5,7 +5,7 @@ import java.nio.ByteBuffer
/**
* Provides various helper methods for [[ByteBuffer]].
*/
object BufferUtils {
private[core] object BufferUtils {
def getUnsignedByte(bb: ByteBuffer): Short = (bb.get & 0xff).toShort

View file

@ -21,7 +21,7 @@ object FutureHelper {
// cross-platform way to execute on the foreground thread.
// We use this to make sure exceptions are not hidden in the logs.
Log.e(Tag, "Exception in Future", e)
System.exit(-1)
//System.exit(-1)
}
f
}

View file

@ -2,13 +2,13 @@ package com.nutomic.ensichat.core
import java.io.File
import com.nutomic.ensichat.core.interfaces.Settings
import com.nutomic.ensichat.core.interfaces.SettingsInterface
import junit.framework.TestCase
import org.junit.Assert._
object CryptoTest {
class TestSettings extends Settings {
class TestSettings extends SettingsInterface {
private var map = Map[String, Any]()
override def get[T](key: String, default: T): T = map.getOrElse(key, default).asInstanceOf[T]
override def put[T](key: String, value: T): Unit = map += (key -> value)

View file

@ -15,4 +15,7 @@
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# org.gradle.parallel=true
versionName=0.1.7
versionCode=8

1
server/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

28
server/build.gradle Normal file
View file

@ -0,0 +1,28 @@
apply plugin: 'scala'
apply plugin: 'application'
dependencies {
compile 'org.scala-lang:scala-library:2.11.7'
compile project(path: ':core')
compile 'com.github.scopt:scopt_2.10:3.3.0'
}
mainClassName = 'com.nutomic.ensichat.server.Main'
version = properties.versionName
applicationName = 'ensichat'
run {
// Use this to pass command line arguments via `gradle run`.
//
// Uses comma instead of space for command seperation for simpler parsing.
//
// Examples:
// ```
// ./gradlew server:run -Pargs="--help"
// ./gradlew server:run -Pargs="--name,MyName"
// ./gradlew server:run -Pargs="--name,MyName,--status,My Status"
// ```
if (project.hasProperty('args')) {
args project.args.split(',')
}
}

View file

@ -0,0 +1,3 @@
package com.nutomic.ensichat.server
case class Config(name: Option[String] = None, status: Option[String] = None)

View file

@ -0,0 +1,23 @@
package com.nutomic.ensichat.server
import com.nutomic.ensichat.core.interfaces.DatabaseInterface
import com.nutomic.ensichat.core.{Address, Message, User}
class Database extends DatabaseInterface {
private var contacts = Set[User]()
def onMessageReceived(msg: Message): Unit = {}
def getContacts: Set[User] = contacts
def getContact(address: Address): Option[User] = contacts.find(_.address == address)
def addContact(contact: User): Unit = contacts += contact
def updateContact(contact: User): Unit = {
contacts = contacts.filterNot(_.address == contact.address)
contacts += contact
}
}

View file

@ -0,0 +1,42 @@
package com.nutomic.ensichat.server
import java.io.{PrintWriter, StringWriter}
import java.text.DateFormat
import java.util.{Date, Locale}
import com.nutomic.ensichat.core.interfaces.Log
import scala.collection.mutable
class Logging extends Log {
private val logs = new mutable.Queue[String]()
def dequeue(): Seq[String] = logs.dequeueAll((String) => true)
private def enqueue(tag: String, message: String, tr: Option[Throwable]): Unit = {
val df = DateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.UK)
val throwableString = tr.map { tr =>
val sw = new StringWriter()
tr.printStackTrace(new PrintWriter(sw))
"\n" + sw.toString
}
logs.enqueue(df.format(new Date()) + " " + tag + ": " + message + throwableString.getOrElse(""))
}
def v(tag: String, message: String, tr: Throwable = null): Unit =
enqueue("V/" + tag, message, Option(tr))
def d(tag: String, message: String, tr: Throwable = null): Unit =
enqueue("D/" + tag, message, Option(tr))
def i(tag: String, message: String, tr: Throwable = null): Unit =
enqueue("I/" + tag, message, Option(tr))
def w(tag: String, message: String, tr: Throwable = null): Unit =
enqueue("W/" + tag, message, Option(tr))
def e(tag: String, message: String, tr: Throwable = null): Unit =
enqueue("E/" + tag, message, Option(tr))
}

View file

@ -0,0 +1,86 @@
package com.nutomic.ensichat.server
import java.io.File
import java.util.concurrent.TimeUnit
import com.nutomic.ensichat.core.body.Text
import com.nutomic.ensichat.core.interfaces.SettingsInterface._
import com.nutomic.ensichat.core.interfaces.{CallbackInterface, Log, SettingsInterface}
import com.nutomic.ensichat.core.{Message, ConnectionHandler, Crypto}
import scopt.OptionParser
object Main extends App with CallbackInterface {
private val Tag = "Main"
private val ConfigFolder = new File(System.getProperty("user.home"), ".config/ensichat")
private val ConfigFile = new File(ConfigFolder, "config.properties")
private val KeyFolder = new File(ConfigFolder, "keys")
private val LogInterval = TimeUnit.SECONDS.toMillis(1)
private lazy val logInstance = new Logging()
private lazy val settings = new Settings(ConfigFile)
private lazy val crypto = new Crypto(settings, KeyFolder)
private lazy val connectionHandler = new ConnectionHandler(settings, new Database(), this, crypto)
init()
/**
* Initializes the app, parses command line parameters.
*
* See build.gradle for information about passing command line parameters from gradle.
*/
private def init(): Unit = {
ConfigFolder.mkdirs()
KeyFolder.mkdirs()
Log.setLogInstance(logInstance)
sys.addShutdownHook(connectionHandler.stop())
settings.put(KeyServers, DefaultServers.mkString(", "))
val parser = new OptionParser[Config]("ensichat") {
head("ensichat")
opt[String]('n', "name") action { (x, c) =>
c.copy(name = Option(x))
} text "the username for this node (optional)"
opt[String]('s', "status") action { (x, c) =>
c.copy(status = Option(x))
} text "the status line (optional)"
help("help") text "prints this usage text"
}
parser.parse(args, Config()).foreach { config =>
config.name.foreach(settings.put(SettingsInterface.KeyUserName, _))
config.status.foreach(settings.put(SettingsInterface.KeyUserStatus, _))
run()
}
}
private def run(): Unit = {
connectionHandler.start()
// Keep alive and print logs
while (true) {
Thread.sleep(LogInterval)
logInstance.dequeue().foreach(System.out.println)
}
}
def onMessageReceived(msg: Message): Unit = {
if (msg.header.target != crypto.localAddress)
return
msg.body match {
case text: Text =>
val address = msg.header.origin
val name = connectionHandler.getUser(address).name
connectionHandler.sendTo(address, new Text("Hello " + name))
Log.i(Tag, "Received text: " + text.text)
case _ =>
Log.i(Tag, "Received msg: " + msg.body)
}
}
def onConnectionsChanged(): Unit = {}
}

View file

@ -0,0 +1,53 @@
package com.nutomic.ensichat.server
import java.io._
import java.util.Properties
import com.nutomic.ensichat.core.interfaces.{Log, SettingsInterface}
import scala.collection.JavaConverters._
class Settings(file: File) extends SettingsInterface {
private val Tag = "Settings"
if (!file.exists()) {
file.createNewFile()
put(SettingsInterface.KeyUserName, "unknown user")
}
private lazy val props: Properties = {
val p = new Properties()
try {
val fis = new InputStreamReader(new FileInputStream(file), "UTF-8")
p.load(fis)
fis.close()
} catch {
case e: IOException => Log.w(Tag, "Failed to load settings from " + file, e)
}
p
}
def put[T](key: String, value: T): Unit = {
props.asScala.put(key, value.toString)
try {
val fos = new OutputStreamWriter(new FileOutputStream(file), "UTF-8")
props.store(fos, "")
fos.close()
} catch {
case e: IOException => Log.w(Tag, "Failed to write preference for key " + key, e)
}
}
def get[T](key: String, default: T): T = {
val value = props.asScala.getOrElse[String](key, default.toString)
val cast = default match {
case _: Int => value.toInt
case _: Long => value.toLong
case _: String => value
}
// This has no effect due to type erasure, but is needed to avoid compiler error.
cast.asInstanceOf[T]
}
}

View file

@ -1 +1 @@
include ':android', ':core'
include ':android', ':core', ':server'