Added text chat functionality.
- ChatFragment with layout, Fragment handling in MainActivity - [MessagePack](http://msgpack.org/) serialization in TextMessage - MessageStore to hold old messages - various small code enhancements Way too many changes for one commit, but it's too late to change.
This commit is contained in:
parent
308d92185e
commit
3bd4feacbd
21 changed files with 572 additions and 162 deletions
|
@ -12,6 +12,7 @@ buildscript {
|
|||
|
||||
dependencies {
|
||||
compile "org.scala-lang:scala-library:2.11.2"
|
||||
compile "org.msgpack:msgpack-scala_2.11:0.6.11"
|
||||
}
|
||||
|
||||
android {
|
||||
|
@ -42,14 +43,13 @@ android {
|
|||
|
||||
buildTypes {
|
||||
debug {
|
||||
runProguard true
|
||||
proguardFile file("proguard-rules.pro")
|
||||
applicationIdSuffix ".debug"
|
||||
}
|
||||
|
||||
release {
|
||||
runProguard true
|
||||
proguardFile file("proguard-rules.pro")
|
||||
}
|
||||
|
||||
// Avoid duplicate file errors during packaging.
|
||||
packagingOptions {
|
||||
exclude 'decoder.properties'
|
||||
exclude 'rootdoc.txt'
|
||||
}
|
||||
}
|
||||
|
|
24
app/proguard-rules.pro
vendored
24
app/proguard-rules.pro
vendored
|
@ -1,24 +0,0 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /home/felix/software/android-studio/sdk/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
-dontoptimize
|
||||
-dontobfuscate
|
||||
-dontpreverify
|
||||
-dontwarn scala.**
|
||||
-keep class !scala*.** { *; }
|
||||
-ignorewarnings
|
BIN
app/src/main/res/drawable-hdpi/ic_action_send_now.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_action_send_now.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 465 B |
BIN
app/src/main/res/drawable-mdpi/ic_action_send_now.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_action_send_now.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 332 B |
BIN
app/src/main/res/drawable-xhdpi/ic_action_send_now.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_action_send_now.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 578 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_action_send_now.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_action_send_now.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 759 B |
59
app/src/main/res/layout/fragment_chat.xml
Normal file
59
app/src/main/res/layout/fragment_chat.xml
Normal file
|
@ -0,0 +1,59 @@
|
|||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="10">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_height="0dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_weight="11"
|
||||
android:background="@android:color/background_light">
|
||||
|
||||
<ListView
|
||||
android:id="@android:id/list"
|
||||
android:drawSelectorOnTop="false"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_width="match_parent"
|
||||
android:footerDividersEnabled="true" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
|
||||
<FrameLayout
|
||||
android:layout_height="0dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_height="match_parent"
|
||||
android:layout_width="match_parent"
|
||||
android:background="@android:color/darker_gray"
|
||||
android:id="@+id/linearLayout">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/message"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:imeOptions="actionDone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/send"
|
||||
android:layout_width="45dp"
|
||||
android:layout_height="45dp"
|
||||
android:background="@drawable/ic_action_send_now"
|
||||
android:onClick="sendMessage"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
|
@ -7,15 +7,24 @@ import android.os.Bundle
|
|||
import android.view.{Menu, MenuItem}
|
||||
import android.widget.Toast
|
||||
import com.nutomic.ensichat.R
|
||||
import com.nutomic.ensichat.bluetooth.ChatService
|
||||
import com.nutomic.ensichat.bluetooth.{ChatService, Device}
|
||||
import com.nutomic.ensichat.fragments.{ChatFragment, ContactsFragment}
|
||||
|
||||
import scala.collection.mutable.HashMap
|
||||
|
||||
/**
|
||||
* Main activity, holds fragments and requests Bluetooth to be discoverable.
|
||||
* Main activity, entry point for app start.
|
||||
*/
|
||||
class MainActivity extends Activity {
|
||||
|
||||
private val RequestSetDiscoverable = 1
|
||||
|
||||
private var ContactsFragment: ContactsFragment = _
|
||||
|
||||
private val ChatFragments = new HashMap[Device.ID, ChatFragment]()
|
||||
|
||||
private var currentChat: Option[Device.ID] = None
|
||||
|
||||
/**
|
||||
* Initializes layout, starts service and requests Bluetooth to be discoverable.
|
||||
*/
|
||||
|
@ -28,14 +37,43 @@ class MainActivity extends Activity {
|
|||
Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
|
||||
intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 0)
|
||||
startActivityForResult(intent, RequestSetDiscoverable)
|
||||
val fm = getFragmentManager
|
||||
if (savedInstanceState != null) {
|
||||
ContactsFragment = fm.getFragment(savedInstanceState, classOf[ContactsFragment].getName)
|
||||
.asInstanceOf[ContactsFragment]
|
||||
for (i <- 0 until savedInstanceState.getInt("chat_fragments_count", 0)) {
|
||||
val key = classOf[ChatFragment].getName + i
|
||||
val cf = fm.getFragment(savedInstanceState, key).asInstanceOf[ChatFragment]
|
||||
ChatFragments += (cf.getDevice -> cf)
|
||||
}
|
||||
currentChat = Some(new Device.ID(savedInstanceState.getString("current_chat")))
|
||||
currentChat.collect{case c => openChat(c) }
|
||||
} else {
|
||||
ContactsFragment = new ContactsFragment()
|
||||
}
|
||||
fm.beginTransaction()
|
||||
.add(android.R.id.content, ContactsFragment)
|
||||
.commit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes menu.
|
||||
* Saves all fragment state.
|
||||
*/
|
||||
override def onSaveInstanceState(outState: Bundle): Unit = {
|
||||
super.onSaveInstanceState(outState)
|
||||
getFragmentManager.putFragment(outState, classOf[ContactsFragment].getName, ContactsFragment)
|
||||
outState.putInt("chat_fragments_count", ChatFragments.size)
|
||||
var i: Int = 0
|
||||
ChatFragments.foreach(cf => {
|
||||
getFragmentManager.putFragment(outState, classOf[ChatFragment].getName + i, cf._2)
|
||||
i += 1
|
||||
})
|
||||
outState.putString("current_chat", currentChat.toString)
|
||||
}
|
||||
|
||||
override def onCreateOptionsMenu(menu: Menu): Boolean = {
|
||||
getMenuInflater().inflate(R.menu.main, menu)
|
||||
return true
|
||||
getMenuInflater.inflate(R.menu.main, menu)
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -51,18 +89,45 @@ class MainActivity extends Activity {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu click handler.
|
||||
*/
|
||||
override def onOptionsItemSelected(item: MenuItem): Boolean = {
|
||||
item.getItemId match {
|
||||
case R.id.exit =>
|
||||
stopService(new Intent(this, classOf[ChatService]))
|
||||
finish()
|
||||
return true
|
||||
true
|
||||
case _ =>
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a chat fragment for the given device, creating the fragment if needed.
|
||||
*/
|
||||
def openChat(device: Device.ID): Unit = {
|
||||
currentChat = Some(device)
|
||||
val ft = getFragmentManager.beginTransaction()
|
||||
if (!ChatFragments.keySet.contains(device)) {
|
||||
ChatFragments += (device -> new ChatFragment(device))
|
||||
ft.add(android.R.id.content, ChatFragments.apply(device))
|
||||
}
|
||||
ft.detach(ContactsFragment)
|
||||
.attach(ChatFragments.apply(device))
|
||||
.commit()
|
||||
}
|
||||
|
||||
/**
|
||||
* If in a ChatFragment, goes back up to ContactsFragment.
|
||||
*/
|
||||
override def onBackPressed(): Unit = {
|
||||
if (currentChat != None) {
|
||||
getFragmentManager
|
||||
.beginTransaction()
|
||||
.detach(ChatFragments.apply(currentChat.get))
|
||||
.attach(ContactsFragment)
|
||||
.commit()
|
||||
currentChat = None
|
||||
} else
|
||||
super.onBackPressed()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -5,13 +5,14 @@ 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, IBinder}
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.nutomic.ensichat.bluetooth.Device.ID
|
||||
import com.nutomic.ensichat.{Message, R}
|
||||
import com.nutomic.ensichat.R
|
||||
import com.nutomic.ensichat.bluetooth.ChatService.{OnDeviceConnectedListener, OnMessageReceivedListener}
|
||||
import com.nutomic.ensichat.messages.{MessageStore, TextMessage}
|
||||
|
||||
import scala.collection.immutable.{HashMap, Set}
|
||||
import scala.collection.immutable.{HashMap, HashSet, TreeSet}
|
||||
import scala.collection.{SortedSet, mutable}
|
||||
import scala.ref.WeakReference
|
||||
|
||||
object ChatService {
|
||||
|
@ -21,31 +22,51 @@ object ChatService {
|
|||
*/
|
||||
val appUuid: UUID = UUID.fromString("8ed52b7a-4501-5348-b054-3d94d004656e")
|
||||
|
||||
trait OnDeviceConnectedListener {
|
||||
def onDeviceConnected(devices: Map[Device.ID, Device]): Unit
|
||||
}
|
||||
|
||||
trait OnMessageReceivedListener {
|
||||
def onMessageReceived(messages: SortedSet[TextMessage]): Unit
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles all Bluetooth connectivity.
|
||||
*/
|
||||
class ChatService extends Service {
|
||||
|
||||
private val Tag = "ChatService"
|
||||
|
||||
private val SCAN_INTERVAL: Int = 5000
|
||||
private val ScanInterval = 5000
|
||||
|
||||
private final val Binder = new ChatServiceBinder(this)
|
||||
private val Binder = new ChatServiceBinder(this)
|
||||
|
||||
private var bluetoothAdapter: BluetoothAdapter = _
|
||||
|
||||
private var deviceListener: Set[WeakReference[Map[Device.ID, Device] => Unit]] =
|
||||
Set[WeakReference[Map[Device.ID, Device] => Unit]]()
|
||||
/**
|
||||
* 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 deviceListeners = new HashSet[WeakReference[OnDeviceConnectedListener]]()
|
||||
|
||||
private var devices: HashMap[Device.ID, Device] = new HashMap[Device.ID, Device]()
|
||||
private val messageListeners =
|
||||
mutable.HashMap[Device.ID, mutable.Set[WeakReference[OnMessageReceivedListener]]]()
|
||||
.withDefaultValue(mutable.Set[WeakReference[OnMessageReceivedListener]]())
|
||||
|
||||
private var connections: HashMap[Device.ID, TransferThread] =
|
||||
new HashMap[Device.ID, TransferThread]()
|
||||
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: Handler = new Handler()
|
||||
private val MainHandler = new Handler()
|
||||
|
||||
private var MessageStore: MessageStore = _
|
||||
|
||||
/**
|
||||
* Initializes BroadcastReceiver for discovery, starts discovery and listens for connections.
|
||||
|
@ -53,7 +74,9 @@ class ChatService extends Service {
|
|||
override def onCreate(): Unit = {
|
||||
super.onCreate()
|
||||
|
||||
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
|
||||
MessageStore = new MessageStore(this)
|
||||
|
||||
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter
|
||||
|
||||
registerReceiver(DeviceDiscoveredReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND))
|
||||
registerReceiver(BluetoothStateReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
|
||||
|
@ -62,14 +85,13 @@ class ChatService extends Service {
|
|||
}
|
||||
}
|
||||
|
||||
override def onStartCommand(intent: Intent, flags: Int, startId: Int): Int = {
|
||||
return Service.START_STICKY
|
||||
}
|
||||
override def onStartCommand(intent: Intent, flags: Int, startId: Int) = Service.START_STICKY
|
||||
|
||||
override def onBind(intent: Intent): IBinder = {
|
||||
return Binder
|
||||
}
|
||||
override def onBind(intent: Intent) = Binder
|
||||
|
||||
/**
|
||||
* Stops discovery, listening and unregisters receivers.
|
||||
*/
|
||||
override def onDestroy(): Unit = {
|
||||
super.onDestroy()
|
||||
ListenThread.cancel()
|
||||
|
@ -85,14 +107,14 @@ class ChatService extends Service {
|
|||
if (cancelDiscovery)
|
||||
return
|
||||
|
||||
if (!bluetoothAdapter.isDiscovering()) {
|
||||
if (!bluetoothAdapter.isDiscovering) {
|
||||
Log.v(Tag, "Running discovery")
|
||||
bluetoothAdapter.startDiscovery()
|
||||
}
|
||||
|
||||
MainHandler.postDelayed(new Runnable {
|
||||
override def run(): Unit = discover()
|
||||
}, SCAN_INTERVAL)
|
||||
}, ScanInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -102,7 +124,7 @@ class ChatService extends Service {
|
|||
override def onReceive(context: Context, intent: Intent) {
|
||||
val device: Device =
|
||||
new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false)
|
||||
devices = devices + (device.id -> device)
|
||||
devices += (device.id -> device)
|
||||
new ConnectThread(device, onConnected).start()
|
||||
}
|
||||
}
|
||||
|
@ -118,7 +140,7 @@ class ChatService extends Service {
|
|||
case BluetoothAdapter.STATE_TURNING_OFF =>
|
||||
connections.foreach(d => d._2.close())
|
||||
case BluetoothAdapter.STATE_OFF =>
|
||||
Log.d(Tag, "Bluetooth disabled, stopping listening and discovery")
|
||||
Log.i(Tag, "Bluetooth disabled, stopping listening and discovery")
|
||||
if (ListenThread != null) {
|
||||
ListenThread.cancel()
|
||||
}
|
||||
|
@ -132,7 +154,6 @@ class ChatService extends Service {
|
|||
* Starts to listen for incoming connections, and starts regular active discovery.
|
||||
*/
|
||||
private def startBluetoothConnections(): Unit = {
|
||||
Log.i(Tag, "Listening and discovery started")
|
||||
cancelDiscovery = false
|
||||
discover()
|
||||
ListenThread = new ListenThread(getString(R.string.app_name), bluetoothAdapter, onConnected)
|
||||
|
@ -142,42 +163,64 @@ class ChatService extends Service {
|
|||
/**
|
||||
* Registers a listener that is called whenever a new device is connected.
|
||||
*/
|
||||
def registerDeviceListener(listener: Map[Device.ID, Device] => Unit): Unit = {
|
||||
deviceListener = deviceListener + new WeakReference[(Map[ID, Device]) => Unit](listener)
|
||||
listener(devices)
|
||||
def registerDeviceListener(listener: OnDeviceConnectedListener): Unit = {
|
||||
deviceListeners += new WeakReference[OnDeviceConnectedListener](listener)
|
||||
listener.onDeviceConnected(devices)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a device listener.
|
||||
* Called when a Bluetooth device is connected.
|
||||
*
|
||||
* Adds the device to [[connections]], notifies all [[deviceListeners]].
|
||||
*/
|
||||
def unregisterDeviceListener(listener: Map[Device.ID, Device] => Unit): Unit = {
|
||||
deviceListener.foreach(l =>
|
||||
if (l == listener)
|
||||
deviceListener = deviceListener - l)
|
||||
}
|
||||
|
||||
def onConnected(device: Device, socket: BluetoothSocket): Unit = {
|
||||
val updatedDevice: Device = new Device(device.bluetoothDevice, true)
|
||||
devices = devices + (device.id -> updatedDevice)
|
||||
connections = connections + (device.id -> new TransferThread(updatedDevice, socket, onReceive))
|
||||
devices += (device.id -> updatedDevice)
|
||||
connections += (device.id -> new TransferThread(updatedDevice, socket, handleNewMessage))
|
||||
connections(device.id).start()
|
||||
deviceListener.foreach(d =>
|
||||
if (d != null)
|
||||
d.apply()(devices)
|
||||
else
|
||||
deviceListener = deviceListener - d)
|
||||
}
|
||||
|
||||
def send(device: Device.ID, message: Message): Unit = {
|
||||
connections.apply(device).send(message)
|
||||
}
|
||||
|
||||
def onReceive(device: Device.ID, message: Message): Unit = {
|
||||
MainHandler.post(new Runnable {
|
||||
override def run(): Unit =
|
||||
Toast.makeText(ChatService.this, devices(device).name + " sent: " + message, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
deviceListeners.foreach(l => l.get match {
|
||||
case Some(_) => l.apply().onDeviceConnected(devices)
|
||||
case None => deviceListeners -= l
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends message to the device specified as receiver,
|
||||
*/
|
||||
def send(message: TextMessage): Unit = {
|
||||
connections.apply(message.receiver).send(message)
|
||||
handleNewMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the message to database and sends it to registered listeners.
|
||||
*
|
||||
* If you want to send a new message, use [[send]].
|
||||
*/
|
||||
def handleNewMessage(message: TextMessage): Unit = {
|
||||
MessageStore.addMessage(message)
|
||||
MainHandler.post(new Runnable {
|
||||
override def run(): Unit = {
|
||||
messageListeners(message.sender).foreach(l =>
|
||||
if (l.get != null)
|
||||
l.apply().onMessageReceived(new TreeSet[TextMessage]()(TextMessage.Ordering) + message)
|
||||
else
|
||||
messageListeners(message.sender) -= l)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a listener that is called whenever a new message is sent or received.
|
||||
*/
|
||||
def registerMessageListener(device: Device.ID, listener: OnMessageReceivedListener): Unit = {
|
||||
messageListeners(device) += new WeakReference[OnMessageReceivedListener](listener)
|
||||
listener.onMessageReceived(MessageStore.getMessages(device, 10))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unique bluetooth address of the local device.
|
||||
*/
|
||||
def localDeviceId = new Device.ID(bluetoothAdapter.getAddress)
|
||||
|
||||
}
|
|
@ -2,10 +2,8 @@ package com.nutomic.ensichat.bluetooth
|
|||
|
||||
import android.os.Binder
|
||||
|
||||
class ChatServiceBinder (mService: ChatService) extends Binder {
|
||||
class ChatServiceBinder (service: ChatService) extends Binder {
|
||||
|
||||
def getService(): ChatService = {
|
||||
return mService
|
||||
}
|
||||
def getService = service
|
||||
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Un
|
|||
device.bluetoothDevice.createInsecureRfcommSocketToServiceRecord(ChatService.appUuid)
|
||||
|
||||
override def run(): Unit = {
|
||||
Log.i(Tag, "Connecting to " + device.toString)
|
||||
try {
|
||||
socket.connect()
|
||||
} catch {
|
||||
|
@ -25,7 +26,7 @@ class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Un
|
|||
socket.close()
|
||||
} catch {
|
||||
case e2: IOException =>
|
||||
Log.e(Tag, "Failed to close socket", e2);
|
||||
Log.e(Tag, "Failed to close socket", e2)
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.nutomic.ensichat.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import org.msgpack.annotation.Message
|
||||
|
||||
object Device {
|
||||
|
||||
|
@ -9,12 +10,14 @@ object Device {
|
|||
*
|
||||
* @param Id A bluetooth device address.
|
||||
*/
|
||||
@Message
|
||||
class ID(private val Id: String) {
|
||||
override def hashCode = Id.hashCode
|
||||
override def equals(a: Any) = a match {
|
||||
case other: Device.ID => Id == other.Id
|
||||
case _ => false
|
||||
}
|
||||
override def toString = Id
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -32,4 +35,6 @@ class Device(BluetoothDevice: BluetoothDevice, Connected: Boolean) {
|
|||
|
||||
def bluetoothDevice = BluetoothDevice
|
||||
|
||||
override def toString = "Device(" + name + ", " + bluetoothDevice.getAddress + ")"
|
||||
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ class ListenThread(name: String, adapter: BluetoothAdapter,
|
|||
}
|
||||
|
||||
override def run(): Unit = {
|
||||
Log.i(Tag, "Listening for connections")
|
||||
var socket: BluetoothSocket = null
|
||||
|
||||
while (true) {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
package com.nutomic.ensichat.bluetooth
|
||||
|
||||
import java.io.{OutputStream, InputStream}
|
||||
import java.io.{IOException, InputStream, OutputStream}
|
||||
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.util.Log
|
||||
import com.nutomic.ensichat.Message
|
||||
import java.io.IOException
|
||||
import com.nutomic.ensichat.messages.TextMessage
|
||||
|
||||
/**
|
||||
* Transfers data between connnected devices.
|
||||
*
|
||||
|
@ -14,13 +14,13 @@ import java.io.IOException
|
|||
* @param onReceive Called when a message was received from the other device.
|
||||
*/
|
||||
class TransferThread(device: Device, socket: BluetoothSocket,
|
||||
onReceive: (Device.ID, Message) => Unit) extends Thread {
|
||||
onReceive: (TextMessage) => Unit) extends Thread {
|
||||
|
||||
val Tag: String = "TransferThread"
|
||||
|
||||
val InStream: InputStream =
|
||||
try {
|
||||
socket.getInputStream()
|
||||
socket.getInputStream
|
||||
} catch {
|
||||
case e: IOException =>
|
||||
Log.e(Tag, "Failed to open stream", e)
|
||||
|
@ -29,7 +29,7 @@ class TransferThread(device: Device, socket: BluetoothSocket,
|
|||
|
||||
val OutStream: OutputStream =
|
||||
try {
|
||||
socket.getOutputStream()
|
||||
socket.getOutputStream
|
||||
} catch {
|
||||
case e: IOException =>
|
||||
Log.e(Tag, "Failed to open stream", e)
|
||||
|
@ -37,28 +37,26 @@ class TransferThread(device: Device, socket: BluetoothSocket,
|
|||
}
|
||||
|
||||
override def run(): Unit = {
|
||||
var buffer: Array[Byte] = new Array(1024)
|
||||
|
||||
Log.i(Tag, "Starting data transfer with " + device.toString)
|
||||
// Keep listening to the InputStream while connected
|
||||
while (true) {
|
||||
try {
|
||||
InStream.read(buffer)
|
||||
val msg: Message = Message.fromByteArray(buffer)
|
||||
onReceive(device.id, msg)
|
||||
val msg = TextMessage.fromStream(InStream)
|
||||
onReceive(msg)
|
||||
} catch {
|
||||
case e: IOException =>
|
||||
Log.e(Tag, "Disconnected from device", e);
|
||||
Log.e(Tag, "Disconnected from device", e)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def send(message: Message): Unit = {
|
||||
def send(message: TextMessage): Unit = {
|
||||
try {
|
||||
OutStream.write(message.toByteArray())
|
||||
message.write(OutStream)
|
||||
} catch {
|
||||
case e: IOException =>
|
||||
Log.e(Tag, "Failed to write message", e);
|
||||
Log.e(Tag, "Failed to write message", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
package com.nutomic.ensichat.fragments
|
||||
|
||||
import java.util.Date
|
||||
|
||||
import android.app.ListFragment
|
||||
import android.content.{ComponentName, Context, Intent, ServiceConnection}
|
||||
import android.os.{Bundle, IBinder}
|
||||
import android.view.View.OnClickListener
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.{KeyEvent, LayoutInflater, View, ViewGroup}
|
||||
import android.widget.TextView.OnEditorActionListener
|
||||
import android.widget._
|
||||
import com.nutomic.ensichat.R
|
||||
import com.nutomic.ensichat.bluetooth.ChatService.OnMessageReceivedListener
|
||||
import com.nutomic.ensichat.bluetooth.{ChatService, ChatServiceBinder, Device}
|
||||
import com.nutomic.ensichat.messages.TextMessage
|
||||
import com.nutomic.ensichat.util.MessagesAdapter
|
||||
|
||||
import scala.collection.SortedSet
|
||||
|
||||
/**
|
||||
* Represents a single chat with another specific device.
|
||||
*/
|
||||
class ChatFragment extends ListFragment with OnClickListener
|
||||
with OnMessageReceivedListener {
|
||||
|
||||
def this(device: Device.ID) {
|
||||
this
|
||||
this.device = device
|
||||
}
|
||||
|
||||
private var device: Device.ID = _
|
||||
|
||||
private var chatService: ChatService = _
|
||||
|
||||
private var sendButton: Button = _
|
||||
|
||||
private var messageText: EditText = _
|
||||
|
||||
private var listView: ListView = _
|
||||
|
||||
private var adapter: ArrayAdapter[TextMessage] = _
|
||||
|
||||
private final val mChatServiceConnection: ServiceConnection = new ServiceConnection {
|
||||
override def onServiceConnected(componentName: ComponentName, iBinder: IBinder): Unit = {
|
||||
val binder: ChatServiceBinder = iBinder.asInstanceOf[ChatServiceBinder]
|
||||
chatService = binder.getService
|
||||
// Read local device ID from service,
|
||||
adapter = new MessagesAdapter(getActivity, chatService.localDeviceId)
|
||||
chatService.registerMessageListener(device, ChatFragment.this)
|
||||
if (listView != null) {
|
||||
listView.setAdapter(adapter)
|
||||
}
|
||||
}
|
||||
|
||||
override def onServiceDisconnected(componentName: ComponentName): Unit = {
|
||||
chatService = null
|
||||
}
|
||||
}
|
||||
|
||||
override def onCreateView(inflater: LayoutInflater, container: ViewGroup,
|
||||
savedInstanceState: Bundle): View = {
|
||||
val view: View = inflater.inflate(R.layout.fragment_chat, container, false)
|
||||
sendButton = view.findViewById(R.id.send).asInstanceOf[Button]
|
||||
sendButton.setOnClickListener(this)
|
||||
messageText = view.findViewById(R.id.message).asInstanceOf[EditText]
|
||||
messageText.setOnEditorActionListener(new OnEditorActionListener {
|
||||
override def onEditorAction(view: TextView, actionId: Int, event: KeyEvent): Boolean = {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
onClick(sendButton)
|
||||
true
|
||||
} else
|
||||
false
|
||||
}
|
||||
})
|
||||
listView = view.findViewById(android.R.id.list).asInstanceOf[ListView]
|
||||
listView.setAdapter(adapter)
|
||||
view
|
||||
}
|
||||
|
||||
override def onCreate(savedInstanceState: Bundle): Unit = {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
getActivity.bindService(new Intent(getActivity, classOf[ChatService]),
|
||||
mChatServiceConnection, Context.BIND_AUTO_CREATE)
|
||||
|
||||
if (savedInstanceState != null) {
|
||||
device = new Device.ID(savedInstanceState.getString("device"))
|
||||
}
|
||||
}
|
||||
|
||||
override def onSaveInstanceState(outState: Bundle): Unit = {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putString("device", device.toString)
|
||||
}
|
||||
|
||||
override def onDestroy(): Unit = {
|
||||
super.onDestroy()
|
||||
getActivity.unbindService(mChatServiceConnection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message if send button was clicked.
|
||||
*/
|
||||
override def onClick(view: View): Unit = {
|
||||
view.getId match {
|
||||
case R.id.send =>
|
||||
val text: String = messageText.getText.toString
|
||||
if (!text.isEmpty) {
|
||||
chatService.send(
|
||||
new TextMessage(chatService.localDeviceId, device, text.toString, new Date()))
|
||||
messageText.getText.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays new messages in UI.
|
||||
*/
|
||||
override def onMessageReceived(messages: SortedSet[TextMessage]): Unit = {
|
||||
messages.foreach(m => adapter.add(m))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the device that this fragment shows chats for.
|
||||
*/
|
||||
def getDevice = this.device
|
||||
|
||||
}
|
|
@ -3,22 +3,25 @@ package com.nutomic.ensichat.fragments
|
|||
import android.app.ListFragment
|
||||
import android.content.{ComponentName, Context, Intent, ServiceConnection}
|
||||
import android.os.{Bundle, IBinder}
|
||||
import android.util.Log
|
||||
import android.view.{LayoutInflater, View, ViewGroup}
|
||||
import android.widget.{ArrayAdapter, ListView}
|
||||
import com.nutomic.ensichat.R
|
||||
import com.nutomic.ensichat.activities.MainActivity
|
||||
import com.nutomic.ensichat.bluetooth.{ChatService, ChatServiceBinder, Device}
|
||||
import com.nutomic.ensichat.util.DevicesAdapter
|
||||
import com.nutomic.ensichat.{Message, R}
|
||||
|
||||
class ContactsFragment extends ListFragment {
|
||||
/**
|
||||
* Lists all nearby, connected devices.
|
||||
*/
|
||||
class ContactsFragment extends ListFragment with ChatService.OnDeviceConnectedListener {
|
||||
|
||||
private var chatService: ChatService = _
|
||||
|
||||
private final val mChatServiceConnection: ServiceConnection = new ServiceConnection {
|
||||
private final val ChatServiceConnection: ServiceConnection = new ServiceConnection {
|
||||
override def onServiceConnected(componentName: ComponentName, iBinder: IBinder): Unit = {
|
||||
val binder: ChatServiceBinder = iBinder.asInstanceOf[ChatServiceBinder]
|
||||
chatService = binder.getService()
|
||||
chatService.registerDeviceListener(onDeviceConnected)
|
||||
chatService = binder.getService
|
||||
chatService.registerDeviceListener(ContactsFragment.this)
|
||||
}
|
||||
|
||||
override def onServiceDisconnected(componentName: ComponentName): Unit = {
|
||||
|
@ -31,7 +34,7 @@ class ContactsFragment extends ListFragment {
|
|||
override def onCreateView(inflater: LayoutInflater, container: ViewGroup,
|
||||
savedInstanceState: Bundle): View = {
|
||||
val view: View = inflater.inflate(R.layout.fragment_contacts, container, false)
|
||||
return view
|
||||
view
|
||||
}
|
||||
|
||||
override def onCreate(savedInstanceState: Bundle): Unit = {
|
||||
|
@ -40,19 +43,21 @@ class ContactsFragment extends ListFragment {
|
|||
adapter = new DevicesAdapter(getActivity)
|
||||
setListAdapter(adapter)
|
||||
getActivity.bindService(new Intent(getActivity, classOf[ChatService]),
|
||||
mChatServiceConnection, Context.BIND_AUTO_CREATE)
|
||||
ChatServiceConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
override def onDestroy(): Unit = {
|
||||
super.onDestroy()
|
||||
getActivity.unbindService(mChatServiceConnection)
|
||||
chatService.unregisterDeviceListener(onDeviceConnected)
|
||||
getActivity.unbindService(ChatServiceConnection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays all connected devices in the listview.
|
||||
* Displays newly connected devices in the list.
|
||||
*/
|
||||
def onDeviceConnected(devices: Map[Device.ID, Device]): Unit = {
|
||||
override def onDeviceConnected(devices: Map[Device.ID, Device]): Unit = {
|
||||
if (getActivity == null)
|
||||
return
|
||||
|
||||
val filtered = devices.filter{ case (_, d) => d.connected }
|
||||
getActivity.runOnUiThread(new Runnable {
|
||||
override def run(): Unit = {
|
||||
|
@ -63,10 +68,10 @@ class ContactsFragment extends ListFragment {
|
|||
}
|
||||
|
||||
/**
|
||||
* Sends a ping message to the clicked device.
|
||||
* Opens a chat with the clicked device.
|
||||
*/
|
||||
override def onListItemClick(l: ListView, v: View, position: Int, id: Long): Unit = {
|
||||
chatService.send(adapter.getItem(position).id, new Message("Ping"))
|
||||
getActivity.asInstanceOf[MainActivity].openChat(adapter.getItem(position).id)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
package com.nutomic.ensichat
|
||||
|
||||
object Message {
|
||||
|
||||
/**
|
||||
* Constructs a new message from transferred bytes.
|
||||
*/
|
||||
def fromByteArray(data: Array[Byte]): Message = {
|
||||
return new Message(new String(data))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for all messages that can be passed between bluetooth devices.
|
||||
*
|
||||
* Provides methods for (de-)serialization.
|
||||
*/
|
||||
class Message(text: String) {
|
||||
|
||||
/**
|
||||
* Converts message to bytes for transfer.
|
||||
* @return
|
||||
*/
|
||||
def toByteArray(): Array[Byte] = {
|
||||
return text.getBytes()
|
||||
}
|
||||
|
||||
override def toString = text
|
||||
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
package com.nutomic.ensichat.messages
|
||||
|
||||
import java.util.Date
|
||||
|
||||
import android.content.{ContentValues, Context}
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.{SQLiteDatabase, SQLiteOpenHelper}
|
||||
import com.nutomic.ensichat.bluetooth.Device
|
||||
|
||||
import scala.collection.SortedSet
|
||||
import scala.collection.immutable.TreeSet
|
||||
|
||||
object MessageStore {
|
||||
|
||||
private val DatabaseName = "message_store.db"
|
||||
|
||||
private val DatabaseVersion = 1
|
||||
|
||||
private val DatabaseCreate = "CREATE TABLE messages(" +
|
||||
"_id integer primary key autoincrement," +
|
||||
"sender string not null," +
|
||||
"receiver string not null," +
|
||||
"text blob not null," +
|
||||
"date integer not null);" // Unix timestamp of message.
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores all messages in SQL database.
|
||||
*/
|
||||
class MessageStore(context: Context) extends SQLiteOpenHelper(context, MessageStore.DatabaseName,
|
||||
null, MessageStore.DatabaseVersion) {
|
||||
|
||||
private val Tag = "MessageStore"
|
||||
|
||||
override def onCreate(db: SQLiteDatabase): Unit = {
|
||||
db.execSQL(MessageStore.DatabaseCreate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count last messages for device.
|
||||
*/
|
||||
def getMessages(device: Device.ID, count: Int): SortedSet[TextMessage] = {
|
||||
val c: Cursor = getReadableDatabase.query(true,
|
||||
"messages", Array("sender", "receiver", "text", "date"),
|
||||
"sender = ? OR receiver = ?", Array(device.toString, device.toString),
|
||||
null, null, "date DESC", count.toString)
|
||||
var messages: SortedSet[TextMessage] = new TreeSet[TextMessage]()(TextMessage.Ordering)
|
||||
while (c.moveToNext()) {
|
||||
val m: TextMessage = new TextMessage(
|
||||
new Device.ID(c.getString(c.getColumnIndex("sender"))),
|
||||
new Device.ID(c.getString(c.getColumnIndex("receiver"))),
|
||||
new String(c.getBlob(c.getColumnIndex ("text"))),
|
||||
new Date(c.getLong(c.getColumnIndex("date"))))
|
||||
messages += m
|
||||
}
|
||||
c.close()
|
||||
messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the given new message into the database.
|
||||
*/
|
||||
def addMessage(message: TextMessage): Unit = {
|
||||
val cv: ContentValues = new ContentValues()
|
||||
cv.put("sender", message.sender.toString)
|
||||
cv.put("receiver", message.receiver.toString)
|
||||
cv.put("text", message.text)
|
||||
cv.put("date", message.date.getTime.toString) // toString used as workaround for compile error
|
||||
getWritableDatabase.insert("messages", null, cv)
|
||||
}
|
||||
|
||||
override def onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int): Unit = {
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package com.nutomic.ensichat.messages
|
||||
|
||||
import java.io.{InputStream, OutputStream}
|
||||
import java.util.Date
|
||||
|
||||
import com.nutomic.ensichat.bluetooth.Device
|
||||
import org.msgpack.ScalaMessagePack
|
||||
|
||||
object TextMessage {
|
||||
|
||||
val Ordering = new Ordering[TextMessage] {
|
||||
override def compare(m1: TextMessage, m2: TextMessage) = m1.date.compareTo(m2.date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new message from stream.
|
||||
*/
|
||||
def fromStream(in: InputStream): TextMessage = {
|
||||
val up = new ScalaMessagePack().createUnpacker(in)
|
||||
new TextMessage(
|
||||
new Device.ID(up.read(classOf[String])),
|
||||
new Device.ID(up.read(classOf[String])),
|
||||
up.read(classOf[String]),
|
||||
new Date(up.read(classOf[Long])))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents content and metadata that can be transferred between devices.
|
||||
*/
|
||||
class TextMessage(val sender: Device.ID, val receiver: Device.ID,
|
||||
val text: String, val date: Date) {
|
||||
|
||||
/**
|
||||
* Writes this object into stream.
|
||||
*/
|
||||
def write(os: OutputStream): Unit = {
|
||||
new ScalaMessagePack().createPacker(os)
|
||||
.write(sender.toString)
|
||||
.write(receiver.toString)
|
||||
.write(text)
|
||||
.write(date.getTime)
|
||||
}
|
||||
|
||||
override def equals(a: Any) = a match {
|
||||
case o: TextMessage =>
|
||||
sender == o.sender && receiver == o.receiver && text == o.text && date == o.date
|
||||
case _ => false
|
||||
}
|
||||
|
||||
override def hashCode() = sender.hashCode + receiver.hashCode + text.hashCode + date.hashCode()
|
||||
|
||||
override def toString = "TextMessage(" + sender.toString + ", " + receiver.toString +
|
||||
", " + text + ", " + date.toString + ")"
|
||||
}
|
|
@ -1,17 +1,21 @@
|
|||
package com.nutomic.ensichat.util
|
||||
|
||||
import android.content.Context
|
||||
import android.view.{ViewGroup, View}
|
||||
import android.widget.{TextView, ArrayAdapter}
|
||||
import android.view.{View, ViewGroup}
|
||||
import android.widget.{ArrayAdapter, TextView}
|
||||
import com.nutomic.ensichat.bluetooth.Device
|
||||
|
||||
class DevicesAdapter(context: Context) extends ArrayAdapter[Device](context, android.R.layout.simple_list_item_1) {
|
||||
/**
|
||||
* Displays [[Device]]s in ListView.
|
||||
*/
|
||||
class DevicesAdapter(context: Context) extends
|
||||
ArrayAdapter[Device](context, android.R.layout.simple_list_item_1) {
|
||||
|
||||
override def getView(position: Int, convertView: View, parent: ViewGroup): View = {
|
||||
val view = super.getView(position, convertView, parent)
|
||||
val title: TextView = view.findViewById(android.R.id.text1).asInstanceOf[TextView]
|
||||
title.setText(getItem(position).name)
|
||||
return view
|
||||
view
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package com.nutomic.ensichat.util
|
||||
|
||||
import android.content.Context
|
||||
import android.view.{View, ViewGroup}
|
||||
import android.widget.{ArrayAdapter, TextView}
|
||||
import com.nutomic.ensichat.bluetooth.Device
|
||||
import com.nutomic.ensichat.messages.TextMessage
|
||||
|
||||
/**
|
||||
* Displays [[TextMessage]]s in ListView.
|
||||
*/
|
||||
class MessagesAdapter(context: Context, localDevice: Device.ID) extends
|
||||
ArrayAdapter[TextMessage](context, android.R.layout.simple_list_item_1) {
|
||||
|
||||
override def getView(position: Int, convertView: View, parent: ViewGroup): View = {
|
||||
val view: View = super.getView(position, convertView, parent)
|
||||
val tv: TextView = view.findViewById(android.R.id.text1).asInstanceOf[TextView]
|
||||
view.setBackgroundColor(context.getResources.getColor(
|
||||
if (getItem(position).sender == localDevice) android.R.color.holo_blue_light
|
||||
else android.R.color.holo_green_light))
|
||||
tv.setText(getItem(position).text)
|
||||
view
|
||||
}
|
||||
|
||||
}
|
Reference in a new issue