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:
Felix Ableitner 2014-10-30 00:23:01 +02:00
parent 308d92185e
commit 3bd4feacbd
21 changed files with 572 additions and 162 deletions

View File

@ -12,6 +12,7 @@ buildscript {
dependencies { dependencies {
compile "org.scala-lang:scala-library:2.11.2" compile "org.scala-lang:scala-library:2.11.2"
compile "org.msgpack:msgpack-scala_2.11:0.6.11"
} }
android { android {
@ -42,14 +43,13 @@ android {
buildTypes { buildTypes {
debug { debug {
runProguard true
proguardFile file("proguard-rules.pro")
applicationIdSuffix ".debug" applicationIdSuffix ".debug"
} }
}
release { // Avoid duplicate file errors during packaging.
runProguard true packagingOptions {
proguardFile file("proguard-rules.pro") exclude 'decoder.properties'
} exclude 'rootdoc.txt'
} }
} }

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

View 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>

View File

@ -7,15 +7,24 @@ import android.os.Bundle
import android.view.{Menu, MenuItem} import android.view.{Menu, MenuItem}
import android.widget.Toast import android.widget.Toast
import com.nutomic.ensichat.R 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 { class MainActivity extends Activity {
private val RequestSetDiscoverable = 1 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. * Initializes layout, starts service and requests Bluetooth to be discoverable.
*/ */
@ -28,14 +37,43 @@ class MainActivity extends Activity {
Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE) Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 0) intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 0)
startActivityForResult(intent, RequestSetDiscoverable) 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 = { override def onCreateOptionsMenu(menu: Menu): Boolean = {
getMenuInflater().inflate(R.menu.main, menu) getMenuInflater.inflate(R.menu.main, menu)
return true true
} }
/** /**
@ -51,18 +89,45 @@ class MainActivity extends Activity {
} }
} }
/**
* Menu click handler.
*/
override def onOptionsItemSelected(item: MenuItem): Boolean = { override def onOptionsItemSelected(item: MenuItem): Boolean = {
item.getItemId match { item.getItemId match {
case R.id.exit => case R.id.exit =>
stopService(new Intent(this, classOf[ChatService])) stopService(new Intent(this, classOf[ChatService]))
finish() finish()
return true true
case _ => 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()
}
} }

View File

@ -5,13 +5,14 @@ import java.util.UUID
import android.app.Service import android.app.Service
import android.bluetooth.{BluetoothAdapter, BluetoothDevice, BluetoothSocket} import android.bluetooth.{BluetoothAdapter, BluetoothDevice, BluetoothSocket}
import android.content.{BroadcastReceiver, Context, Intent, IntentFilter} import android.content.{BroadcastReceiver, Context, Intent, IntentFilter}
import android.os.{Handler, IBinder} import android.os.Handler
import android.util.Log import android.util.Log
import android.widget.Toast import com.nutomic.ensichat.R
import com.nutomic.ensichat.bluetooth.Device.ID import com.nutomic.ensichat.bluetooth.ChatService.{OnDeviceConnectedListener, OnMessageReceivedListener}
import com.nutomic.ensichat.{Message, R} 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 import scala.ref.WeakReference
object ChatService { object ChatService {
@ -21,31 +22,51 @@ object ChatService {
*/ */
val appUuid: UUID = UUID.fromString("8ed52b7a-4501-5348-b054-3d94d004656e") 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 { class ChatService extends Service {
private val Tag = "ChatService" 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 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] = private var devices = new HashMap[Device.ID, Device]()
new HashMap[Device.ID, TransferThread]()
private var connections = new HashMap[Device.ID, TransferThread]()
private var ListenThread: ListenThread = _ private var ListenThread: ListenThread = _
private var cancelDiscovery = false 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. * Initializes BroadcastReceiver for discovery, starts discovery and listens for connections.
@ -53,7 +74,9 @@ class ChatService extends Service {
override def onCreate(): Unit = { override def onCreate(): Unit = {
super.onCreate() super.onCreate()
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() MessageStore = new MessageStore(this)
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter
registerReceiver(DeviceDiscoveredReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND)) registerReceiver(DeviceDiscoveredReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND))
registerReceiver(BluetoothStateReceiver, new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) 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 = { override def onStartCommand(intent: Intent, flags: Int, startId: Int) = Service.START_STICKY
return Service.START_STICKY
}
override def onBind(intent: Intent): IBinder = { override def onBind(intent: Intent) = Binder
return Binder
}
/**
* Stops discovery, listening and unregisters receivers.
*/
override def onDestroy(): Unit = { override def onDestroy(): Unit = {
super.onDestroy() super.onDestroy()
ListenThread.cancel() ListenThread.cancel()
@ -85,14 +107,14 @@ class ChatService extends Service {
if (cancelDiscovery) if (cancelDiscovery)
return return
if (!bluetoothAdapter.isDiscovering()) { if (!bluetoothAdapter.isDiscovering) {
Log.v(Tag, "Running discovery") Log.v(Tag, "Running discovery")
bluetoothAdapter.startDiscovery() bluetoothAdapter.startDiscovery()
} }
MainHandler.postDelayed(new Runnable { MainHandler.postDelayed(new Runnable {
override def run(): Unit = discover() override def run(): Unit = discover()
}, SCAN_INTERVAL) }, ScanInterval)
} }
/** /**
@ -102,7 +124,7 @@ class ChatService extends Service {
override def onReceive(context: Context, intent: Intent) { override def onReceive(context: Context, intent: Intent) {
val device: Device = val device: Device =
new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false) new Device(intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE), false)
devices = devices + (device.id -> device) devices += (device.id -> device)
new ConnectThread(device, onConnected).start() new ConnectThread(device, onConnected).start()
} }
} }
@ -118,7 +140,7 @@ class ChatService extends Service {
case BluetoothAdapter.STATE_TURNING_OFF => case BluetoothAdapter.STATE_TURNING_OFF =>
connections.foreach(d => d._2.close()) connections.foreach(d => d._2.close())
case BluetoothAdapter.STATE_OFF => 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) { if (ListenThread != null) {
ListenThread.cancel() ListenThread.cancel()
} }
@ -132,7 +154,6 @@ class ChatService extends Service {
* Starts to listen for incoming connections, and starts regular active discovery. * Starts to listen for incoming connections, and starts regular active discovery.
*/ */
private def startBluetoothConnections(): Unit = { private def startBluetoothConnections(): Unit = {
Log.i(Tag, "Listening and discovery started")
cancelDiscovery = false cancelDiscovery = false
discover() discover()
ListenThread = new ListenThread(getString(R.string.app_name), bluetoothAdapter, onConnected) 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. * Registers a listener that is called whenever a new device is connected.
*/ */
def registerDeviceListener(listener: Map[Device.ID, Device] => Unit): Unit = { def registerDeviceListener(listener: OnDeviceConnectedListener): Unit = {
deviceListener = deviceListener + new WeakReference[(Map[ID, Device]) => Unit](listener) deviceListeners += new WeakReference[OnDeviceConnectedListener](listener)
listener(devices) 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 = { def onConnected(device: Device, socket: BluetoothSocket): Unit = {
val updatedDevice: Device = new Device(device.bluetoothDevice, true) val updatedDevice: Device = new Device(device.bluetoothDevice, true)
devices = devices + (device.id -> updatedDevice) devices += (device.id -> updatedDevice)
connections = connections + (device.id -> new TransferThread(updatedDevice, socket, onReceive)) connections += (device.id -> new TransferThread(updatedDevice, socket, handleNewMessage))
connections(device.id).start() connections(device.id).start()
deviceListener.foreach(d => deviceListeners.foreach(l => l.get match {
if (d != null) case Some(_) => l.apply().onDeviceConnected(devices)
d.apply()(devices) case None => deviceListeners -= l
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()
}) })
} }
/**
* 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)
} }

View File

@ -2,10 +2,8 @@ package com.nutomic.ensichat.bluetooth
import android.os.Binder import android.os.Binder
class ChatServiceBinder (mService: ChatService) extends Binder { class ChatServiceBinder (service: ChatService) extends Binder {
def getService(): ChatService = { def getService = service
return mService
}
} }

View File

@ -17,6 +17,7 @@ class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Un
device.bluetoothDevice.createInsecureRfcommSocketToServiceRecord(ChatService.appUuid) device.bluetoothDevice.createInsecureRfcommSocketToServiceRecord(ChatService.appUuid)
override def run(): Unit = { override def run(): Unit = {
Log.i(Tag, "Connecting to " + device.toString)
try { try {
socket.connect() socket.connect()
} catch { } catch {
@ -25,7 +26,7 @@ class ConnectThread(device: Device, onConnected: (Device, BluetoothSocket) => Un
socket.close() socket.close()
} catch { } catch {
case e2: IOException => case e2: IOException =>
Log.e(Tag, "Failed to close socket", e2); Log.e(Tag, "Failed to close socket", e2)
} }
return; return;
} }

View File

@ -1,6 +1,7 @@
package com.nutomic.ensichat.bluetooth package com.nutomic.ensichat.bluetooth
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import org.msgpack.annotation.Message
object Device { object Device {
@ -9,12 +10,14 @@ object Device {
* *
* @param Id A bluetooth device address. * @param Id A bluetooth device address.
*/ */
@Message
class ID(private val Id: String) { class ID(private val Id: String) {
override def hashCode = Id.hashCode override def hashCode = Id.hashCode
override def equals(a: Any) = a match { override def equals(a: Any) = a match {
case other: Device.ID => Id == other.Id case other: Device.ID => Id == other.Id
case _ => false case _ => false
} }
override def toString = Id
} }
} }
@ -32,4 +35,6 @@ class Device(BluetoothDevice: BluetoothDevice, Connected: Boolean) {
def bluetoothDevice = BluetoothDevice def bluetoothDevice = BluetoothDevice
override def toString = "Device(" + name + ", " + bluetoothDevice.getAddress + ")"
} }

View File

@ -23,6 +23,7 @@ class ListenThread(name: String, adapter: BluetoothAdapter,
} }
override def run(): Unit = { override def run(): Unit = {
Log.i(Tag, "Listening for connections")
var socket: BluetoothSocket = null var socket: BluetoothSocket = null
while (true) { while (true) {

View File

@ -1,11 +1,11 @@
package com.nutomic.ensichat.bluetooth package com.nutomic.ensichat.bluetooth
import java.io.{OutputStream, InputStream} import java.io.{IOException, InputStream, OutputStream}
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.util.Log import android.util.Log
import com.nutomic.ensichat.Message import com.nutomic.ensichat.messages.TextMessage
import java.io.IOException
/** /**
* Transfers data between connnected devices. * 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. * @param onReceive Called when a message was received from the other device.
*/ */
class TransferThread(device: Device, socket: BluetoothSocket, class TransferThread(device: Device, socket: BluetoothSocket,
onReceive: (Device.ID, Message) => Unit) extends Thread { onReceive: (TextMessage) => Unit) extends Thread {
val Tag: String = "TransferThread" val Tag: String = "TransferThread"
val InStream: InputStream = val InStream: InputStream =
try { try {
socket.getInputStream() socket.getInputStream
} catch { } catch {
case e: IOException => case e: IOException =>
Log.e(Tag, "Failed to open stream", e) Log.e(Tag, "Failed to open stream", e)
@ -29,7 +29,7 @@ class TransferThread(device: Device, socket: BluetoothSocket,
val OutStream: OutputStream = val OutStream: OutputStream =
try { try {
socket.getOutputStream() socket.getOutputStream
} catch { } catch {
case e: IOException => case e: IOException =>
Log.e(Tag, "Failed to open stream", e) Log.e(Tag, "Failed to open stream", e)
@ -37,28 +37,26 @@ class TransferThread(device: Device, socket: BluetoothSocket,
} }
override def run(): Unit = { 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 // Keep listening to the InputStream while connected
while (true) { while (true) {
try { try {
InStream.read(buffer) val msg = TextMessage.fromStream(InStream)
val msg: Message = Message.fromByteArray(buffer) onReceive(msg)
onReceive(device.id, msg)
} catch { } catch {
case e: IOException => case e: IOException =>
Log.e(Tag, "Disconnected from device", e); Log.e(Tag, "Disconnected from device", e)
return return
} }
} }
} }
def send(message: Message): Unit = { def send(message: TextMessage): Unit = {
try { try {
OutStream.write(message.toByteArray()) message.write(OutStream)
} catch { } catch {
case e: IOException => case e: IOException =>
Log.e(Tag, "Failed to write message", e); Log.e(Tag, "Failed to write message", e)
} }
} }

View File

@ -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
}

View File

@ -3,22 +3,25 @@ package com.nutomic.ensichat.fragments
import android.app.ListFragment import android.app.ListFragment
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 android.util.Log
import android.view.{LayoutInflater, View, ViewGroup} import android.view.{LayoutInflater, View, ViewGroup}
import android.widget.{ArrayAdapter, ListView} 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.bluetooth.{ChatService, ChatServiceBinder, Device}
import com.nutomic.ensichat.util.DevicesAdapter 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 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 = { override def onServiceConnected(componentName: ComponentName, iBinder: IBinder): Unit = {
val binder: ChatServiceBinder = iBinder.asInstanceOf[ChatServiceBinder] val binder: ChatServiceBinder = iBinder.asInstanceOf[ChatServiceBinder]
chatService = binder.getService() chatService = binder.getService
chatService.registerDeviceListener(onDeviceConnected) chatService.registerDeviceListener(ContactsFragment.this)
} }
override def onServiceDisconnected(componentName: ComponentName): Unit = { override def onServiceDisconnected(componentName: ComponentName): Unit = {
@ -31,7 +34,7 @@ class ContactsFragment extends ListFragment {
override def onCreateView(inflater: LayoutInflater, container: ViewGroup, override def onCreateView(inflater: LayoutInflater, container: ViewGroup,
savedInstanceState: Bundle): View = { savedInstanceState: Bundle): View = {
val view: View = inflater.inflate(R.layout.fragment_contacts, container, false) val view: View = inflater.inflate(R.layout.fragment_contacts, container, false)
return view view
} }
override def onCreate(savedInstanceState: Bundle): Unit = { override def onCreate(savedInstanceState: Bundle): Unit = {
@ -40,19 +43,21 @@ class ContactsFragment extends ListFragment {
adapter = new DevicesAdapter(getActivity) adapter = new DevicesAdapter(getActivity)
setListAdapter(adapter) setListAdapter(adapter)
getActivity.bindService(new Intent(getActivity, classOf[ChatService]), getActivity.bindService(new Intent(getActivity, classOf[ChatService]),
mChatServiceConnection, Context.BIND_AUTO_CREATE) ChatServiceConnection, Context.BIND_AUTO_CREATE)
} }
override def onDestroy(): Unit = { override def onDestroy(): Unit = {
super.onDestroy() super.onDestroy()
getActivity.unbindService(mChatServiceConnection) getActivity.unbindService(ChatServiceConnection)
chatService.unregisterDeviceListener(onDeviceConnected)
} }
/** /**
* 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 } val filtered = devices.filter{ case (_, d) => d.connected }
getActivity.runOnUiThread(new Runnable { getActivity.runOnUiThread(new Runnable {
override def run(): Unit = { 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 = { 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)
} }
} }

View File

@ -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
}

View File

@ -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 = {
}
}

View File

@ -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 + ")"
}

View File

@ -1,17 +1,21 @@
package com.nutomic.ensichat.util package com.nutomic.ensichat.util
import android.content.Context import android.content.Context
import android.view.{ViewGroup, View} import android.view.{View, ViewGroup}
import android.widget.{TextView, ArrayAdapter} import android.widget.{ArrayAdapter, TextView}
import com.nutomic.ensichat.bluetooth.Device 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 = { override def getView(position: Int, convertView: View, parent: ViewGroup): View = {
val view = super.getView(position, convertView, parent) val view = super.getView(position, convertView, parent)
val title: TextView = view.findViewById(android.R.id.text1).asInstanceOf[TextView] val title: TextView = view.findViewById(android.R.id.text1).asInstanceOf[TextView]
title.setText(getItem(position).name) title.setText(getItem(position).name)
return view view
} }
} }

View File

@ -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
}
}