Split ChatService class into BluetoothInterface and new ChatService.
This commit is contained in:
parent
591d47ffc3
commit
647f946586
12 changed files with 414 additions and 361 deletions
|
@ -42,7 +42,7 @@
|
||||||
android:value=".activities.MainActivity" />
|
android:value=".activities.MainActivity" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service android:name=".bluetooth.ChatService" />
|
<service android:name=".protocol.ChatService" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|
|
@ -10,10 +10,8 @@ import android.view._
|
||||||
import android.widget.AdapterView.OnItemClickListener
|
import android.widget.AdapterView.OnItemClickListener
|
||||||
import android.widget._
|
import android.widget._
|
||||||
import com.nutomic.ensichat.R
|
import com.nutomic.ensichat.R
|
||||||
import com.nutomic.ensichat.bluetooth.ChatService
|
|
||||||
import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener
|
|
||||||
import com.nutomic.ensichat.protocol.messages.{Message, RequestAddContact, ResultAddContact}
|
import com.nutomic.ensichat.protocol.messages.{Message, RequestAddContact, ResultAddContact}
|
||||||
import com.nutomic.ensichat.protocol.{Address, Crypto}
|
import com.nutomic.ensichat.protocol.{Address, ChatService, Crypto}
|
||||||
import com.nutomic.ensichat.util.{DevicesAdapter, IdenticonGenerator}
|
import com.nutomic.ensichat.util.{DevicesAdapter, IdenticonGenerator}
|
||||||
|
|
||||||
import scala.collection.SortedSet
|
import scala.collection.SortedSet
|
||||||
|
@ -23,14 +21,14 @@ import scala.collection.SortedSet
|
||||||
*
|
*
|
||||||
* Adding a contact requires confirmation on both sides.
|
* Adding a contact requires confirmation on both sides.
|
||||||
*/
|
*/
|
||||||
class AddContactsActivity extends EnsiChatActivity with ChatService.OnNearbyContactsChangedListener
|
class AddContactsActivity extends EnsiChatActivity with ChatService.OnConnectionsChangedListener
|
||||||
with OnItemClickListener with OnMessageReceivedListener {
|
with OnItemClickListener with ChatService.OnMessageReceivedListener {
|
||||||
|
|
||||||
private val Tag = "AddContactsActivity"
|
private val Tag = "AddContactsActivity"
|
||||||
|
|
||||||
private lazy val Adapter = new DevicesAdapter(this)
|
private lazy val Adapter = new DevicesAdapter(this)
|
||||||
|
|
||||||
private lazy val Database = service.database
|
private lazy val Database = service.Database
|
||||||
|
|
||||||
private lazy val Crypto = new Crypto(this)
|
private lazy val Crypto = new Crypto(this)
|
||||||
|
|
||||||
|
@ -46,8 +44,7 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnNearbyCont
|
||||||
* @param localConfirmed If true, the local user has accepted adding the contact.
|
* @param localConfirmed If true, the local user has accepted adding the contact.
|
||||||
* @param remoteConfirmed If true, the remote contact has accepted adding this device as contact.
|
* @param remoteConfirmed If true, the remote contact has accepted adding this device as contact.
|
||||||
*/
|
*/
|
||||||
private class AddContactInfo(val localConfirmed: Boolean, val remoteConfirmed: Boolean) {
|
private class AddContactInfo(val localConfirmed: Boolean, val remoteConfirmed: Boolean)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes layout, registers connection and message listeners.
|
* Initializes layout, registers connection and message listeners.
|
||||||
|
@ -71,7 +68,7 @@ class AddContactsActivity extends EnsiChatActivity with ChatService.OnNearbyCont
|
||||||
/**
|
/**
|
||||||
* Displays newly connected devices in the list.
|
* Displays newly connected devices in the list.
|
||||||
*/
|
*/
|
||||||
override def onNearbyContactsChanged(devices: Set[Address]): Unit = {
|
override def onConnectionsChanged(devices: Set[Address]): Unit = {
|
||||||
runOnUiThread(new Runnable {
|
runOnUiThread(new Runnable {
|
||||||
override def run(): Unit = {
|
override def run(): Unit = {
|
||||||
Adapter.clear()
|
Adapter.clear()
|
||||||
|
|
|
@ -3,7 +3,8 @@ package com.nutomic.ensichat.activities
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.{ComponentName, Context, Intent, ServiceConnection}
|
import android.content.{ComponentName, Context, Intent, ServiceConnection}
|
||||||
import android.os.{Bundle, IBinder}
|
import android.os.{Bundle, IBinder}
|
||||||
import com.nutomic.ensichat.bluetooth.{ChatService, ChatServiceBinder}
|
import com.nutomic.ensichat.protocol.ChatService
|
||||||
|
import com.nutomic.ensichat.protocol.ChatServiceBinder
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects to [[ChatService]] and provides access to it.
|
* Connects to [[ChatService]] and provides access to it.
|
||||||
|
|
|
@ -0,0 +1,198 @@
|
||||||
|
package com.nutomic.ensichat.bluetooth
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
import android.bluetooth.{BluetoothAdapter, BluetoothDevice, BluetoothSocket}
|
||||||
|
import android.content.{BroadcastReceiver, Context, Intent, IntentFilter}
|
||||||
|
import android.preference.PreferenceManager
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.common.collect.HashBiMap
|
||||||
|
import com.nutomic.ensichat.R
|
||||||
|
import com.nutomic.ensichat.protocol.ChatService.InterfaceHandler
|
||||||
|
import com.nutomic.ensichat.protocol._
|
||||||
|
import com.nutomic.ensichat.protocol.messages.{ConnectionInfo, Message}
|
||||||
|
|
||||||
|
import scala.collection.immutable.HashMap
|
||||||
|
|
||||||
|
object BluetoothInterface {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bluetooth service UUID version 5, created with namespace URL and "ensichat.nutomic.com".
|
||||||
|
*/
|
||||||
|
val AppUuid: UUID = UUID.fromString("8ed52b7a-4501-5348-b054-3d94d004656e")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles all Bluetooth connectivity.
|
||||||
|
*/
|
||||||
|
class BluetoothInterface(Service: ChatService, Crypto: Crypto) extends InterfaceHandler {
|
||||||
|
|
||||||
|
private val Tag = "BluetoothInterface"
|
||||||
|
|
||||||
|
private lazy val BtAdapter = BluetoothAdapter.getDefaultAdapter
|
||||||
|
|
||||||
|
private var devices = new HashMap[Device.ID, Device]()
|
||||||
|
|
||||||
|
private var connections = new HashMap[Device.ID, TransferThread]()
|
||||||
|
|
||||||
|
private lazy val ListenThread =
|
||||||
|
new ListenThread(Service.getString(R.string.app_name), BtAdapter, onConnectionOpened)
|
||||||
|
|
||||||
|
private var cancelDiscovery = false
|
||||||
|
|
||||||
|
private var discovered = Set[Device]()
|
||||||
|
|
||||||
|
private val AddressDeviceMap = HashBiMap.create[Address, Device.ID]()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes and starts discovery and listening.
|
||||||
|
*/
|
||||||
|
override def create(): Unit = {
|
||||||
|
Service.registerReceiver(DeviceDiscoveredReceiver,
|
||||||
|
new IntentFilter(BluetoothDevice.ACTION_FOUND))
|
||||||
|
Service.registerReceiver(BluetoothStateReceiver,
|
||||||
|
new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
|
||||||
|
Service.registerReceiver(DiscoveryFinishedReceiver,
|
||||||
|
new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED))
|
||||||
|
startBluetoothConnections()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops discovery and listening.
|
||||||
|
*/
|
||||||
|
override def destroy(): Unit = {
|
||||||
|
ListenThread.cancel()
|
||||||
|
cancelDiscovery = true
|
||||||
|
Service.unregisterReceiver(DeviceDiscoveredReceiver)
|
||||||
|
Service.unregisterReceiver(BluetoothStateReceiver)
|
||||||
|
Service.unregisterReceiver(DiscoveryFinishedReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts discovery and listening.
|
||||||
|
*/
|
||||||
|
private def startBluetoothConnections(): Unit = {
|
||||||
|
ListenThread.start()
|
||||||
|
cancelDiscovery = false
|
||||||
|
discover()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs discovery as long as [[cancelDiscovery]] is false.
|
||||||
|
*/
|
||||||
|
def discover(): Unit = {
|
||||||
|
if (cancelDiscovery)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!BtAdapter.isDiscovering) {
|
||||||
|
Log.v(Tag, "Starting discovery")
|
||||||
|
BtAdapter.startDiscovery()
|
||||||
|
}
|
||||||
|
|
||||||
|
val scanInterval = PreferenceManager.getDefaultSharedPreferences(Service)
|
||||||
|
.getString("scan_interval_seconds", "15").toInt * 1000
|
||||||
|
Service.MainHandler.postDelayed(new Runnable {
|
||||||
|
override def run(): Unit = discover()
|
||||||
|
}, scanInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores newly discovered devices.
|
||||||
|
*/
|
||||||
|
private val DeviceDiscoveredReceiver = new BroadcastReceiver() {
|
||||||
|
override def onReceive(context: Context, intent: Intent) {
|
||||||
|
discovered += new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates connection to discovered devices.
|
||||||
|
*/
|
||||||
|
private val DiscoveryFinishedReceiver = new BroadcastReceiver() {
|
||||||
|
override def onReceive(context: Context, intent: Intent): Unit = {
|
||||||
|
discovered.filterNot(d => connections.keySet.contains(d.Id))
|
||||||
|
.foreach { d =>
|
||||||
|
new ConnectThread(d, onConnectionOpened).start()
|
||||||
|
devices += (d.Id -> d)
|
||||||
|
}
|
||||||
|
discovered = Set[Device]()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts or stops listening and discovery based on bluetooth state.
|
||||||
|
*/
|
||||||
|
private val BluetoothStateReceiver = new BroadcastReceiver {
|
||||||
|
override def onReceive(context: Context, intent: Intent): Unit = {
|
||||||
|
intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) match {
|
||||||
|
case BluetoothAdapter.STATE_ON =>
|
||||||
|
if (Crypto.localKeysExist)
|
||||||
|
startBluetoothConnections()
|
||||||
|
case BluetoothAdapter.STATE_TURNING_OFF =>
|
||||||
|
Log.i(Tag, "Bluetooth disabled, stopping connectivity")
|
||||||
|
ListenThread.cancel()
|
||||||
|
cancelDiscovery = true
|
||||||
|
connections.foreach(_._2.close())
|
||||||
|
case _ =>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates data transfer with device.
|
||||||
|
*/
|
||||||
|
def onConnectionOpened(device: Device, socket: BluetoothSocket): Unit = {
|
||||||
|
devices += (device.Id -> device)
|
||||||
|
connections += (device.Id ->
|
||||||
|
new TransferThread(device, socket, this, Crypto, onReceiveMessage))
|
||||||
|
connections(device.Id).start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes device from active connections.
|
||||||
|
*/
|
||||||
|
def onConnectionClosed(device: Device, socket: BluetoothSocket): Unit = {
|
||||||
|
devices -= device.Id
|
||||||
|
connections -= device.Id
|
||||||
|
val inv = AddressDeviceMap.inverse()
|
||||||
|
Service.callConnectionListeners()
|
||||||
|
inv.remove(device.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passes incoming messages to [[ChatService]].
|
||||||
|
*
|
||||||
|
* Also uses [[ConnectionInfo]] message to determine mapping from [[Device.ID]] to [[Address]].
|
||||||
|
*
|
||||||
|
* @param msg The message that was received.
|
||||||
|
* @param device Device that sent the message.
|
||||||
|
*/
|
||||||
|
private def onReceiveMessage(msg: Message, device: Device.ID): Unit = msg.Body match {
|
||||||
|
case info: ConnectionInfo =>
|
||||||
|
val sender = Service.onConnectionOpened(msg)
|
||||||
|
sender match {
|
||||||
|
case Some(s) =>
|
||||||
|
AddressDeviceMap.put(Crypto.calculateAddress(info.key), device)
|
||||||
|
Service.callConnectionListeners()
|
||||||
|
case None =>
|
||||||
|
}
|
||||||
|
case _ =>
|
||||||
|
Service.onMessageReceived(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the message to the target address specified in the message header.
|
||||||
|
*/
|
||||||
|
override def send(msg: Message): Unit = {
|
||||||
|
connections.apply(AddressDeviceMap.get(msg.Header.Target)).send(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all active Bluetooth connections.
|
||||||
|
*/
|
||||||
|
def getConnections: Set[Address] =
|
||||||
|
connections.map(x => AddressDeviceMap.inverse().get(x._1)).toSet
|
||||||
|
|
||||||
|
}
|
|
@ -1,333 +0,0 @@
|
||||||
package com.nutomic.ensichat.bluetooth
|
|
||||||
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
import android.app.Service
|
|
||||||
import android.bluetooth.{BluetoothAdapter, BluetoothDevice, BluetoothSocket}
|
|
||||||
import android.content.{BroadcastReceiver, Context, Intent, IntentFilter}
|
|
||||||
import android.os.Handler
|
|
||||||
import android.preference.PreferenceManager
|
|
||||||
import android.util.Log
|
|
||||||
import com.google.common.collect.HashBiMap
|
|
||||||
import com.nutomic.ensichat.R
|
|
||||||
import com.nutomic.ensichat.bluetooth.ChatService.{OnMessageReceivedListener, OnNearbyContactsChangedListener}
|
|
||||||
import com.nutomic.ensichat.protocol._
|
|
||||||
import com.nutomic.ensichat.protocol.messages.{ConnectionInfo, Message, MessageBody, MessageHeader}
|
|
||||||
import com.nutomic.ensichat.util.Database
|
|
||||||
|
|
||||||
import scala.collection.SortedSet
|
|
||||||
import scala.collection.immutable.{HashMap, HashSet, TreeSet}
|
|
||||||
import scala.concurrent.Future
|
|
||||||
import scala.concurrent.ExecutionContext.Implicits.global
|
|
||||||
import scala.ref.WeakReference
|
|
||||||
|
|
||||||
object ChatService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bluetooth service UUID version 5, created with namespace URL and "ensichat.nutomic.com".
|
|
||||||
*/
|
|
||||||
val appUuid: UUID = UUID.fromString("8ed52b7a-4501-5348-b054-3d94d004656e")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used with [[ChatService.registerConnectionListener]], called when a bluetooth device
|
|
||||||
* connects or disconnects
|
|
||||||
*/
|
|
||||||
trait OnNearbyContactsChangedListener {
|
|
||||||
def onNearbyContactsChanged(devices: Set[Address]): Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
trait OnMessageReceivedListener {
|
|
||||||
def onMessageReceived(messages: SortedSet[Message]): Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles all Bluetooth connectivity.
|
|
||||||
*/
|
|
||||||
class ChatService extends Service {
|
|
||||||
|
|
||||||
private val Tag = "ChatService"
|
|
||||||
|
|
||||||
private val Binder = new ChatServiceBinder(this)
|
|
||||||
|
|
||||||
private var bluetoothAdapter: BluetoothAdapter = _
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For this (and [[messageListeners]], functions would be useful instead of instances,
|
|
||||||
* but on a Nexus S (Android 4.1.2), these functions are garbage collected even when
|
|
||||||
* referenced.
|
|
||||||
*/
|
|
||||||
private var connectionListeners = new HashSet[WeakReference[OnNearbyContactsChangedListener]]()
|
|
||||||
|
|
||||||
private var messageListeners = Set[WeakReference[OnMessageReceivedListener]]()
|
|
||||||
|
|
||||||
private var devices = new HashMap[Device.ID, Device]()
|
|
||||||
|
|
||||||
private var connections = new HashMap[Device.ID, TransferThread]()
|
|
||||||
|
|
||||||
private var ListenThread: ListenThread = _
|
|
||||||
|
|
||||||
private var cancelDiscovery = false
|
|
||||||
|
|
||||||
private val MainHandler = new Handler()
|
|
||||||
|
|
||||||
private lazy val Database = new Database(this)
|
|
||||||
|
|
||||||
private lazy val Crypto = new Crypto(this)
|
|
||||||
|
|
||||||
private var discovered = Set[Device]()
|
|
||||||
|
|
||||||
private val AddressDeviceMap = HashBiMap.create[Address, Device.ID]()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up bluetooth connectivity.
|
|
||||||
*/
|
|
||||||
override def onCreate(): Unit = {
|
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter
|
|
||||||
|
|
||||||
registerReceiver(DeviceDiscoveredReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND))
|
|
||||||
registerReceiver(BluetoothStateReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
|
|
||||||
registerReceiver(DiscoveryFinishedReceiver,
|
|
||||||
new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED))
|
|
||||||
|
|
||||||
Future {
|
|
||||||
Crypto.generateLocalKeys()
|
|
||||||
startBluetoothConnections()
|
|
||||||
Log.i(Tag, "Service started, address is " + Crypto.getLocalAddress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override def onStartCommand(intent: Intent, flags: Int, startId: Int) = Service.START_STICKY
|
|
||||||
|
|
||||||
override def onBind(intent: Intent) = Binder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops discovery, listening and unregisters receivers.
|
|
||||||
*/
|
|
||||||
override def onDestroy(): Unit = {
|
|
||||||
super.onDestroy()
|
|
||||||
ListenThread.cancel()
|
|
||||||
cancelDiscovery = true
|
|
||||||
unregisterReceiver(DeviceDiscoveredReceiver)
|
|
||||||
unregisterReceiver(BluetoothStateReceiver)
|
|
||||||
unregisterReceiver(DiscoveryFinishedReceiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops any current discovery, then starts a new one, recursively until service is stopped.
|
|
||||||
*/
|
|
||||||
def discover(): Unit = {
|
|
||||||
if (cancelDiscovery)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (!bluetoothAdapter.isDiscovering) {
|
|
||||||
Log.v(Tag, "Starting discovery")
|
|
||||||
bluetoothAdapter.startDiscovery()
|
|
||||||
}
|
|
||||||
|
|
||||||
val scanInterval = PreferenceManager.getDefaultSharedPreferences(this)
|
|
||||||
.getString("scan_interval_seconds", "15").toInt * 1000
|
|
||||||
MainHandler.postDelayed(new Runnable {
|
|
||||||
override def run(): Unit = discover()
|
|
||||||
}, scanInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Receives newly discovered devices and connects to them.
|
|
||||||
*/
|
|
||||||
private val DeviceDiscoveredReceiver = new BroadcastReceiver() {
|
|
||||||
override def onReceive(context: Context, intent: Intent) {
|
|
||||||
discovered += new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Iniates the actual connection to discovered devices.
|
|
||||||
*/
|
|
||||||
private val DiscoveryFinishedReceiver = new BroadcastReceiver() {
|
|
||||||
override def onReceive(context: Context, intent: Intent): Unit = {
|
|
||||||
discovered.filterNot(d => connections.keySet.contains(d.Id))
|
|
||||||
.foreach { d =>
|
|
||||||
new ConnectThread(d, onConnectionChanged).start()
|
|
||||||
devices += (d.Id -> d)
|
|
||||||
}
|
|
||||||
discovered = Set[Device]()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts or stops listening and discovery based on bluetooth state.
|
|
||||||
*/
|
|
||||||
private val BluetoothStateReceiver = new BroadcastReceiver {
|
|
||||||
override def onReceive(context: Context, intent: Intent): Unit = {
|
|
||||||
intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) match {
|
|
||||||
case BluetoothAdapter.STATE_ON =>
|
|
||||||
if (Crypto.localKeysExist)
|
|
||||||
startBluetoothConnections()
|
|
||||||
case BluetoothAdapter.STATE_TURNING_OFF =>
|
|
||||||
connections.foreach(d => d._2.close())
|
|
||||||
case BluetoothAdapter.STATE_OFF =>
|
|
||||||
Log.i(Tag, "Bluetooth disabled, stopping listening and discovery")
|
|
||||||
if (ListenThread != null) {
|
|
||||||
ListenThread.cancel()
|
|
||||||
}
|
|
||||||
cancelDiscovery = true
|
|
||||||
case _ =>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts to listen for incoming connections, and starts regular active discovery.
|
|
||||||
*/
|
|
||||||
private def startBluetoothConnections(): Unit = {
|
|
||||||
cancelDiscovery = false
|
|
||||||
discover()
|
|
||||||
ListenThread =
|
|
||||||
new ListenThread(getString(R.string.app_name), bluetoothAdapter, onConnectionChanged)
|
|
||||||
ListenThread.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a listener that is called whenever a new device is connected.
|
|
||||||
*/
|
|
||||||
def registerConnectionListener(listener: OnNearbyContactsChangedListener): Unit = {
|
|
||||||
connectionListeners += new WeakReference[OnNearbyContactsChangedListener](listener)
|
|
||||||
nearbyContactsChanged(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a Bluetooth device is connected.
|
|
||||||
*
|
|
||||||
* Adds the device to [[connections]], notifies all [[connectionListeners]], sends DeviceInfoMessage.
|
|
||||||
*
|
|
||||||
* @param device The updated device info for the remote device.
|
|
||||||
* @param socket A socket for data transfer if device.connected is true, otherwise null.
|
|
||||||
*/
|
|
||||||
def onConnectionChanged(device: Device, socket: BluetoothSocket): Unit = {
|
|
||||||
devices += (device.Id -> device)
|
|
||||||
|
|
||||||
if (device.Connected && !connections.keySet.contains(device.Id)) {
|
|
||||||
connections += (device.Id ->
|
|
||||||
new TransferThread(device, socket, this, Crypto, onReceiveMessage))
|
|
||||||
connections(device.Id).start()
|
|
||||||
} else {
|
|
||||||
Log.i(Tag, device + " has disconnected")
|
|
||||||
AddressDeviceMap.inverse().remove(device.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls listener with [[devices]] (converting [[Device.ID]]s to [[Address]]es.
|
|
||||||
*/
|
|
||||||
def nearbyContactsChanged(listener: OnNearbyContactsChangedListener) = {
|
|
||||||
listener.onNearbyContactsChanged(
|
|
||||||
devices.keySet.map(d => AddressDeviceMap.inverse().get(d)).filter(_ != null))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends a new message to the given target address.
|
|
||||||
*/
|
|
||||||
def sendTo(target: Address, body: MessageBody): Unit = {
|
|
||||||
if (!AddressDeviceMap.containsKey(target)) {
|
|
||||||
Log.w(Tag, "Receiver " + target + " is not connected, ignoring message")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val header = new MessageHeader(body.Type, MessageHeader.DefaultHopLimit,
|
|
||||||
Crypto.getLocalAddress, target, 0, 0)
|
|
||||||
|
|
||||||
val msg = new Message(header, body)
|
|
||||||
val encrypted = Crypto.encrypt(Crypto.sign(msg))
|
|
||||||
connections.apply(AddressDeviceMap.get(target)).send(encrypted)
|
|
||||||
Database.addMessage(msg)
|
|
||||||
callMessageReceivedListeners(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the message to database and sends it to registered listeners.
|
|
||||||
*
|
|
||||||
* If you want to send a new message, use [[sendTo]].
|
|
||||||
*
|
|
||||||
* Messages must always be sent between local device and a contact.
|
|
||||||
*
|
|
||||||
* NOTE: Messages sent from the local node using [[sendTo]] are also passed through this method.
|
|
||||||
*/
|
|
||||||
private def onReceiveMessage(message: Message, device: Device.ID): Unit = {
|
|
||||||
assert(message.Header.Origin != Crypto.getLocalAddress)
|
|
||||||
|
|
||||||
message.Body match {
|
|
||||||
case info: ConnectionInfo =>
|
|
||||||
if (message.Header.Origin == Crypto.getLocalAddress)
|
|
||||||
return
|
|
||||||
onNeighborConnected(info, device)
|
|
||||||
case _ =>
|
|
||||||
val decrypted = Crypto.decrypt(message)
|
|
||||||
if (!Crypto.verify(decrypted)) {
|
|
||||||
Log.i(Tag, "Dropping message with invalid signature from " + message.Header.Origin)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
callMessageReceivedListeners(decrypted)
|
|
||||||
Database.addMessage(decrypted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls all [[OnMessageReceivedListener]]s with the new message.
|
|
||||||
*/
|
|
||||||
private def callMessageReceivedListeners(message: Message): Unit = {
|
|
||||||
MainHandler.post(new Runnable {
|
|
||||||
override def run(): Unit = {
|
|
||||||
messageListeners.foreach(l =>
|
|
||||||
if (l.get != null)
|
|
||||||
l.apply().onMessageReceived(new TreeSet[Message]()(Message.Ordering) + message)
|
|
||||||
else
|
|
||||||
messageListeners -= l)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a [[ConnectionInfo]] message from a new neighbor is received.
|
|
||||||
*/
|
|
||||||
private def onNeighborConnected(info: ConnectionInfo, device: Device.ID): Unit = {
|
|
||||||
val sender = Crypto.calculateAddress(info.key)
|
|
||||||
if (sender == Address.Broadcast || sender == Address.Null) {
|
|
||||||
Log.i(Tag, "Received ConnectionInfo message with invalid sender " + sender + ", ignoring")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Crypto.havePublicKey(sender)) {
|
|
||||||
Crypto.addPublicKey(sender, info.key)
|
|
||||||
Log.i(Tag, "Added public key for new device " + sender.toString)
|
|
||||||
}
|
|
||||||
|
|
||||||
AddressDeviceMap.put(sender, device)
|
|
||||||
Log.i(Tag, "Node " + sender + " connected as " + device)
|
|
||||||
|
|
||||||
connectionListeners.foreach(l => l.get match {
|
|
||||||
case Some(c) => nearbyContactsChanged(c)
|
|
||||||
case None => connectionListeners -= l
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a listener that is called whenever a new message is sent or received.
|
|
||||||
*/
|
|
||||||
def registerMessageListener(listener: OnMessageReceivedListener): Unit = {
|
|
||||||
messageListeners += new WeakReference[OnMessageReceivedListener](listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the unique bluetooth address of the local device.
|
|
||||||
*/
|
|
||||||
private def localDeviceId = new Device.ID(bluetoothAdapter.getAddress)
|
|
||||||
|
|
||||||
def database = Database
|
|
||||||
|
|
||||||
}
|
|
|
@ -14,7 +14,7 @@ class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Un
|
||||||
private val Tag = "ConnectThread"
|
private val Tag = "ConnectThread"
|
||||||
|
|
||||||
private val Socket: BluetoothSocket =
|
private val Socket: BluetoothSocket =
|
||||||
device.bluetoothDevice.createInsecureRfcommSocketToServiceRecord(ChatService.appUuid)
|
device.bluetoothDevice.createInsecureRfcommSocketToServiceRecord(BluetoothInterface.AppUuid)
|
||||||
|
|
||||||
override def run(): Unit = {
|
override def run(): Unit = {
|
||||||
Log.i(Tag, "Connecting to " + device.toString)
|
Log.i(Tag, "Connecting to " + device.toString)
|
||||||
|
|
|
@ -17,7 +17,7 @@ class ListenThread(name: String, adapter: BluetoothAdapter,
|
||||||
|
|
||||||
private val ServerSocket: BluetoothServerSocket =
|
private val ServerSocket: BluetoothServerSocket =
|
||||||
try {
|
try {
|
||||||
adapter.listenUsingInsecureRfcommWithServiceRecord(name, ChatService.appUuid)
|
adapter.listenUsingInsecureRfcommWithServiceRecord(name, BluetoothInterface.AppUuid)
|
||||||
} catch {
|
} catch {
|
||||||
case e: IOException =>
|
case e: IOException =>
|
||||||
Log.e(Tag, "Failed to create listener", e)
|
Log.e(Tag, "Failed to create listener", e)
|
||||||
|
|
|
@ -16,8 +16,8 @@ import com.nutomic.ensichat.protocol.messages.{ConnectionInfo, Message, MessageH
|
||||||
* @param socket An open socket to the given device.
|
* @param socket An open socket to the given device.
|
||||||
* @param onReceive Called when a message was received from the other device.
|
* @param onReceive Called when a message was received from the other device.
|
||||||
*/
|
*/
|
||||||
class TransferThread(device: Device, socket: BluetoothSocket, service: ChatService,
|
class TransferThread(device: Device, socket: BluetoothSocket, Handler: BluetoothInterface,
|
||||||
crypto: Crypto, onReceive: (Message, Device.ID) => Unit)
|
Crypto: Crypto, onReceive: (Message, Device.ID) => Unit)
|
||||||
extends Thread {
|
extends Thread {
|
||||||
|
|
||||||
private val Tag: String = "TransferThread"
|
private val Tag: String = "TransferThread"
|
||||||
|
@ -43,8 +43,8 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi
|
||||||
override def run(): Unit = {
|
override def run(): Unit = {
|
||||||
Log.i(Tag, "Starting data transfer with " + device.toString)
|
Log.i(Tag, "Starting data transfer with " + device.toString)
|
||||||
|
|
||||||
send(crypto.sign(new Message(new MessageHeader(ConnectionInfo.Type, ConnectionInfo.HopLimit,
|
send(Crypto.sign(new Message(new MessageHeader(ConnectionInfo.Type, ConnectionInfo.HopLimit,
|
||||||
Address.Null, Address.Null, 0, 0), new ConnectionInfo(crypto.getLocalPublicKey))))
|
Address.Null, Address.Null, 0, 0), new ConnectionInfo(Crypto.getLocalPublicKey))))
|
||||||
|
|
||||||
while (socket.isConnected) {
|
while (socket.isConnected) {
|
||||||
try {
|
try {
|
||||||
|
@ -62,8 +62,7 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
service.onConnectionChanged(new Device(device.bluetoothDevice, false), null)
|
close()
|
||||||
Log.i(Tag, "Neighbor " + device + " has disconnected")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def send(msg: Message): Unit = {
|
def send(msg: Message): Unit = {
|
||||||
|
@ -81,7 +80,7 @@ class TransferThread(device: Device, socket: BluetoothSocket, service: ChatServi
|
||||||
} catch {
|
} catch {
|
||||||
case e: IOException => Log.e(Tag, "Failed to close socket", e);
|
case e: IOException => Log.e(Tag, "Failed to close socket", e);
|
||||||
} finally {
|
} finally {
|
||||||
service.onConnectionChanged(new Device(device.bluetoothDevice, false), null)
|
Handler.onConnectionClosed(new Device(device.bluetoothDevice, false), null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,11 +9,10 @@ import android.widget.TextView.OnEditorActionListener
|
||||||
import android.widget._
|
import android.widget._
|
||||||
import com.nutomic.ensichat.R
|
import com.nutomic.ensichat.R
|
||||||
import com.nutomic.ensichat.activities.EnsiChatActivity
|
import com.nutomic.ensichat.activities.EnsiChatActivity
|
||||||
import com.nutomic.ensichat.bluetooth.ChatService
|
import com.nutomic.ensichat.protocol.{ChatService, Address}
|
||||||
import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener
|
import com.nutomic.ensichat.protocol.ChatService.OnMessageReceivedListener
|
||||||
import com.nutomic.ensichat.protocol.Address
|
|
||||||
import com.nutomic.ensichat.protocol.messages.{Message, Text}
|
import com.nutomic.ensichat.protocol.messages.{Message, Text}
|
||||||
import com.nutomic.ensichat.util.MessagesAdapter
|
import com.nutomic.ensichat.util.{Database, MessagesAdapter}
|
||||||
|
|
||||||
import scala.collection.SortedSet
|
import scala.collection.SortedSet
|
||||||
|
|
||||||
|
@ -53,7 +52,7 @@ class ChatFragment extends ListFragment with OnClickListener
|
||||||
// Read local device ID from service,
|
// Read local device ID from service,
|
||||||
adapter = new MessagesAdapter(getActivity, address)
|
adapter = new MessagesAdapter(getActivity, address)
|
||||||
chatService.registerMessageListener(ChatFragment.this)
|
chatService.registerMessageListener(ChatFragment.this)
|
||||||
onMessageReceived(chatService.database.getMessages(address, 15))
|
onMessageReceived(chatService.Database.getMessages(address, 15))
|
||||||
|
|
||||||
if (listView != null) {
|
if (listView != null) {
|
||||||
listView.setAdapter(adapter)
|
listView.setAdapter(adapter)
|
||||||
|
|
|
@ -7,7 +7,7 @@ import android.view._
|
||||||
import android.widget.ListView
|
import android.widget.ListView
|
||||||
import com.nutomic.ensichat.R
|
import com.nutomic.ensichat.R
|
||||||
import com.nutomic.ensichat.activities.{AddContactsActivity, EnsiChatActivity, MainActivity, SettingsActivity}
|
import com.nutomic.ensichat.activities.{AddContactsActivity, EnsiChatActivity, MainActivity, SettingsActivity}
|
||||||
import com.nutomic.ensichat.bluetooth.ChatService
|
import com.nutomic.ensichat.protocol.ChatService
|
||||||
import com.nutomic.ensichat.util.DevicesAdapter
|
import com.nutomic.ensichat.util.DevicesAdapter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -17,7 +17,7 @@ class ContactsFragment extends ListFragment {
|
||||||
|
|
||||||
private lazy val Adapter = new DevicesAdapter(getActivity)
|
private lazy val Adapter = new DevicesAdapter(getActivity)
|
||||||
|
|
||||||
private lazy val Database = getActivity.asInstanceOf[EnsiChatActivity].service.database
|
private lazy val Database = getActivity.asInstanceOf[EnsiChatActivity].service.Database
|
||||||
|
|
||||||
override def onCreate(savedInstanceState: Bundle): Unit = {
|
override def onCreate(savedInstanceState: Bundle): Unit = {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
|
@ -0,0 +1,192 @@
|
||||||
|
package com.nutomic.ensichat.protocol
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Handler
|
||||||
|
import android.util.Log
|
||||||
|
import com.nutomic.ensichat.bluetooth.BluetoothInterface
|
||||||
|
import com.nutomic.ensichat.protocol.ChatService.{OnMessageReceivedListener, OnConnectionsChangedListener}
|
||||||
|
import com.nutomic.ensichat.protocol.messages.{ConnectionInfo, Message, MessageBody, MessageHeader}
|
||||||
|
import com.nutomic.ensichat.util.Database
|
||||||
|
|
||||||
|
import scala.collection.SortedSet
|
||||||
|
import scala.concurrent.ExecutionContext.Implicits.global
|
||||||
|
import scala.concurrent.Future
|
||||||
|
import scala.ref.WeakReference
|
||||||
|
|
||||||
|
object ChatService {
|
||||||
|
|
||||||
|
abstract class InterfaceHandler {
|
||||||
|
|
||||||
|
def create(): Unit
|
||||||
|
|
||||||
|
def destroy(): Unit
|
||||||
|
|
||||||
|
def send(msg: Message): Unit
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
trait OnMessageReceivedListener {
|
||||||
|
def onMessageReceived(messages: SortedSet[Message]): Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used with [[ChatService.registerConnectionListener]], called when a Bluetooth device
|
||||||
|
* connects or disconnects
|
||||||
|
*/
|
||||||
|
trait OnConnectionsChangedListener {
|
||||||
|
def onConnectionsChanged(devices: Set[Address]): Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level handling of all message transfers and callbacks.
|
||||||
|
*/
|
||||||
|
class ChatService extends Service {
|
||||||
|
|
||||||
|
private val Tag = "ChatService"
|
||||||
|
|
||||||
|
lazy val Database = new Database(this)
|
||||||
|
|
||||||
|
val MainHandler = new Handler()
|
||||||
|
|
||||||
|
private lazy val Binder = new ChatServiceBinder(this)
|
||||||
|
|
||||||
|
private lazy val Crypto = new Crypto(this)
|
||||||
|
|
||||||
|
private lazy val BluetoothInterface = new BluetoothInterface(this, Crypto)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For this (and [[messageListeners]], functions would be useful instead of instances,
|
||||||
|
* but on a Nexus S (Android 4.1.2), these functions are garbage collected even when
|
||||||
|
* referenced.
|
||||||
|
*/
|
||||||
|
private var connectionListeners = Set[WeakReference[OnConnectionsChangedListener]]()
|
||||||
|
|
||||||
|
private var messageListeners = Set[WeakReference[OnMessageReceivedListener]]()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates keys and starts Bluetooth interface.
|
||||||
|
*/
|
||||||
|
override def onCreate(): Unit = {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
Future {
|
||||||
|
Crypto.generateLocalKeys()
|
||||||
|
Log.i(Tag, "Service started, address is " + Crypto.getLocalAddress)
|
||||||
|
|
||||||
|
BluetoothInterface.create()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override def onDestroy(): Unit = {
|
||||||
|
BluetoothInterface.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override def onStartCommand(intent: Intent, flags: Int, startId: Int) = Service.START_STICKY
|
||||||
|
|
||||||
|
override def onBind(intent: Intent) = Binder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a listener that is called whenever a new message is sent or received.
|
||||||
|
*/
|
||||||
|
def registerMessageListener(listener: OnMessageReceivedListener): Unit = {
|
||||||
|
messageListeners += new WeakReference[OnMessageReceivedListener](listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a listener that is called whenever a new device is connected.
|
||||||
|
*/
|
||||||
|
def registerConnectionListener(listener: OnConnectionsChangedListener): Unit = {
|
||||||
|
connectionListeners += new WeakReference[OnConnectionsChangedListener](listener)
|
||||||
|
listener.onConnectionsChanged(BluetoothInterface.getConnections)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a new message to the given target address.
|
||||||
|
*/
|
||||||
|
def sendTo(target: Address, body: MessageBody): Unit = {
|
||||||
|
if (!BluetoothInterface.getConnections.contains(target))
|
||||||
|
return
|
||||||
|
|
||||||
|
val header = new MessageHeader(body.Type, MessageHeader.DefaultHopLimit,
|
||||||
|
Crypto.getLocalAddress, target, 0, 0)
|
||||||
|
|
||||||
|
val msg = new Message(header, body)
|
||||||
|
val encrypted = Crypto.encrypt(Crypto.sign(msg))
|
||||||
|
BluetoothInterface.send(encrypted)
|
||||||
|
onNewMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts and verifies incoming messages, forwards valid ones to [[onNewMessage()]].
|
||||||
|
*/
|
||||||
|
def onMessageReceived(msg: Message): Unit = {
|
||||||
|
val decrypted = Crypto.decrypt(msg)
|
||||||
|
if (!Crypto.verify(decrypted)) {
|
||||||
|
Log.i(Tag, "Ignoring message with invalid signature from " + msg.Header.Origin)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onNewMessage(decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls all [[OnMessageReceivedListener]]s with the new message.
|
||||||
|
*
|
||||||
|
* This function is called both for locally and remotely sent messages.
|
||||||
|
*/
|
||||||
|
private def onNewMessage(msg: Message): Unit = {
|
||||||
|
Database.addMessage(msg)
|
||||||
|
MainHandler.post(new Runnable {
|
||||||
|
override def run(): Unit =
|
||||||
|
messageListeners
|
||||||
|
.filter(_.get.nonEmpty)
|
||||||
|
.foreach(_.apply().onMessageReceived(SortedSet(msg)(Message.Ordering)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens connection to a direct neighbor.
|
||||||
|
*
|
||||||
|
* This adds the other node's public key if we don't have it. If we do, it validates the signature
|
||||||
|
* with the stored key.
|
||||||
|
*
|
||||||
|
* The caller must invoke [[callConnectionListeners()]]
|
||||||
|
*
|
||||||
|
* @param infoMsg The message containing [[ConnectionInfo]] to open the connection.
|
||||||
|
* @return True if the connection is valid
|
||||||
|
*/
|
||||||
|
def onConnectionOpened(infoMsg: Message): Option[Address] = {
|
||||||
|
val info = infoMsg.Body.asInstanceOf[ConnectionInfo]
|
||||||
|
val sender = Crypto.calculateAddress(info.key)
|
||||||
|
if (sender == Address.Broadcast || sender == Address.Null) {
|
||||||
|
Log.i(Tag, "Ignoring ConnectionInfo message with invalid sender " + sender)
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Crypto.havePublicKey(sender) && !Crypto.verify(infoMsg, Crypto.getPublicKey(sender))) {
|
||||||
|
Log.i(Tag, "Ignoring ConnectionInfo message with invalid signature")
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Crypto.havePublicKey(sender)) {
|
||||||
|
Crypto.addPublicKey(sender, info.key)
|
||||||
|
Log.i(Tag, "Added public key for new device " + sender.toString)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(Tag, "Node " + sender + " connected")
|
||||||
|
Some(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls all [[connectionListeners]] with the currently active connections.
|
||||||
|
*
|
||||||
|
* Should be called whenever a neighbor connects or disconnects.
|
||||||
|
*/
|
||||||
|
def callConnectionListeners(): Unit =
|
||||||
|
connectionListeners
|
||||||
|
.filter(_ != None)
|
||||||
|
.foreach(_.apply().onConnectionsChanged(BluetoothInterface.getConnections))
|
||||||
|
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package com.nutomic.ensichat.bluetooth
|
package com.nutomic.ensichat.protocol
|
||||||
|
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
|
|
Reference in a new issue