Merge branch 'internet-transport'
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
BIN
android/src/main/res/drawable-hdpi/ic_person_add_white_24dp.png
Normal file
After Width: | Height: | Size: 289 B |
BIN
android/src/main/res/drawable-hdpi/ic_qrcode_white_24dp.png
Normal file
After Width: | Height: | Size: 391 B |
BIN
android/src/main/res/drawable-mdpi/ic_person_add_white_24dp.png
Normal file
After Width: | Height: | Size: 204 B |
BIN
android/src/main/res/drawable-mdpi/ic_qrcode_white_24dp.png
Normal file
After Width: | Height: | Size: 233 B |
BIN
android/src/main/res/drawable-xhdpi/ic_person_add_white_24dp.png
Normal file
After Width: | Height: | Size: 329 B |
BIN
android/src/main/res/drawable-xhdpi/ic_qrcode_white_24dp.png
Normal file
After Width: | Height: | Size: 271 B |
After Width: | Height: | Size: 464 B |
BIN
android/src/main/res/drawable-xxhdpi/ic_qrcode_white_24dp.png
Normal file
After Width: | Height: | Size: 329 B |
After Width: | Height: | Size: 610 B |
BIN
android/src/main/res/drawable-xxxhdpi/ic_qrcode_white_24dp.png
Normal file
After Width: | Height: | Size: 380 B |
|
@ -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>
|
||||
|
|
17
android/src/main/res/menu/connections.xml
Normal 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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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" >
|
||||
|
|
15
android/src/main/scala/com/nutomic/ensichat/App.scala
Normal 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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
@ -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"
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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 _ =>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 + ")")
|
||||
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)]()
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) &&
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -7,4 +7,5 @@ trait CallbackInterface {
|
|||
def onMessageReceived(msg: Message): Unit
|
||||
|
||||
def onConnectionsChanged(): Unit
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
@ -0,0 +1 @@
|
|||
/build
|
28
server/build.gradle
Normal 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(',')
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package com.nutomic.ensichat.server
|
||||
|
||||
case class Config(name: Option[String] = None, status: Option[String] = None)
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
|
||||
}
|
86
server/src/main/scala/com/nutomic/ensichat/server/Main.scala
Normal 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 = {}
|
||||
|
||||
}
|
|
@ -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]
|
||||
}
|
||||
|
||||
}
|
|
@ -1 +1 @@
|
|||
include ':android', ':core'
|
||||
include ':android', ':core', ':server'
|
||||
|
|