Commit 0e74fac5 authored by Anon's avatar Anon

Add basic file transfer implementation

parent 5c94d18f
package com.github.antweb.donkey
import android.bluetooth.BluetoothDevice
import android.bluetooth.*
import android.content.Context
import android.util.Log
import com.github.antweb.donkey.file.LowEffortService
import java.lang.IllegalStateException
import java.lang.NullPointerException
import java.util.*
private const val TAG = "ConnectionService"
object ConnectionService {
var device: BluetoothDevice? = null
var leService: LowEffortService? = null
var mtu = 100
private var connection: BluetoothGatt? = null
private var connectionState = BluetoothGatt.STATE_DISCONNECTED
private var gattListeners = mutableListOf<GattListener>()
private const val serviceUuid = "00422342-2342-2342-2342-234223422342"
val deviceName: String?
get() = device?.name
......@@ -14,4 +31,101 @@ object ConnectionService {
fun hasDevice(): Boolean {
return device != null
}
fun isConnected() = connectionState == BluetoothGatt.STATE_CONNECTED
fun addGattListener(listener: GattListener) {
gattListeners.add(listener)
}
fun connect(context: Context) {
if (device == null) {
throw IllegalStateException()
}
// 1. Connect
// 2. Discover services
// 3. Change MTU
// 4. ???
// 5. Profit
connection = device?.connectGatt(context, true, gattCallback, BluetoothDevice.TRANSPORT_LE)
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
if (gatt == null) {
throw NullPointerException()
}
connection = gatt
for (service in gatt.services) {
Log.d(TAG, "Found service: ${service.uuid}")
if (service.uuid.toString() == serviceUuid) {
leService = LowEffortService(service)
}
}
if (leService == null) {
Log.e(TAG, "Could not find file transfer service")
return
}
gatt.requestMtu(mtu)
// leService?.enableNotify(gatt)
}
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
connectionState = newState
connection = gatt
gattListeners.map { it.onConnectionStateChange(newState) }
when (newState) {
BluetoothGatt.STATE_CONNECTED -> {
gatt?.discoverServices()
}
}
}
override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) {
Log.d(TAG, "MTU changed to: $mtu")
if (gatt == null) {
throw IllegalStateException()
}
leService?.enableNotify(gatt)
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt?,
characteristic: BluetoothGattCharacteristic?,
status: Int
) {
if (gatt == null || characteristic == null) {
throw IllegalStateException()
}
connection = gatt
gattListeners.map { it.onCharacteristicWrite(characteristic, status) }
}
override fun onCharacteristicChanged(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) {
connection = gatt
if (gatt == null || characteristic == null) {
throw IllegalStateException()
}
gattListeners.map { it.onCharacteristicChanged(characteristic) }
}
}
fun writeCharacteristic(characteristic: BluetoothGattCharacteristic): Boolean {
return connection?.writeCharacteristic(characteristic) ?: false
}
}
\ No newline at end of file
package com.github.antweb.donkey
fun toHex(bytes: ByteArray): String {
val sb = StringBuilder()
for (b in bytes) {
sb.append(String.format("%02X ", b))
}
return sb.toString()
}
\ No newline at end of file
......@@ -7,7 +7,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.TextView
import kotlinx.android.synthetic.main.activity_main.view.*
class DeviceListAdapter(context: Context, private val list: ArrayList<BluetoothDevice> = ArrayList()) :
ArrayAdapter<BluetoothDevice>(context, android.R.layout.simple_list_item_1, list) {
......
package com.github.antweb.donkey
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.os.Environment
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
class FileTransfer(
private val gatt: BluetoothGatt,
private val dataCharacteristic: BluetoothGattCharacteristic,
private val mtu: Int
) {
private val filePath = "/Download/SEND.txt"
private val iterator = openFile().iterator()
private var offset: Int = 0
private fun openFile(): ByteArray {
// TODO: Don't listen to Android Docs, use stuff that is not deprecated
val file = File(
Environment.getExternalStorageDirectory()
.absolutePath, filePath
)
val size = file.length()
val bytes = ByteArray(size.toInt())
val buf = BufferedInputStream(FileInputStream(file))
buf.read(bytes, 0, bytes.size)
buf.close()
return bytes
}
fun sendFile() {
// Send first chunk
sendNext()
}
fun sendNext() {
if (!iterator.hasNext()) {
return
}
gatt?.beginReliableWrite()
val chunk = mutableListOf<Byte>()
for (i in 0 until (mtu - 5)) {
chunk.add(iterator.next())
}
// Our packet:
// 4 byte header + chunk
val header = offset
offset += chunk.size
val sendBuffer = ByteBuffer.allocate(chunk.size + 4)
sendBuffer.putInt(header)
sendBuffer.put(chunk.toByteArray())
dataCharacteristic?.value = sendBuffer.array()
gatt?.writeCharacteristic(dataCharacteristic)
// This will trigger the onCharacteristicWrite callback once the other side ACKs
// In there, we can call the actual gatt?.executeReliableWrite()
}
}
\ No newline at end of file
package com.github.antweb.donkey
import android.bluetooth.BluetoothGattCharacteristic
interface GattListener {
fun onCharacteristicWrite(characteristic: BluetoothGattCharacteristic, status: Int) {}
fun onCharacteristicChanged(characteristic: BluetoothGattCharacteristic) {}
fun onConnectionStateChange(state: Int) {}
}
\ No newline at end of file
......@@ -14,11 +14,6 @@ private const val TAG = "ScanActivity"
class ScanActivity : AppCompatActivity() {
// TODO: Figure out how to transfer this later
companion object {
var selectedDevice: BluetoothDevice? = null
}
private lateinit var listView: ListView
private lateinit var listAdapter: DeviceListAdapter
......@@ -42,6 +37,7 @@ class ScanActivity : AppCompatActivity() {
if (item != null) {
ConnectionService.device = item
ConnectionService.connect(this)
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
}
......
package com.github.antweb.donkey
import android.bluetooth.*
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.github.antweb.donkey.file.ChunkedReader
import com.github.antweb.donkey.file.FileTransfer
import java.lang.NullPointerException
import java.util.*
import com.github.antweb.donkey.file.FileTransferListener
import java.lang.Exception
import java.lang.IllegalStateException
private const val TAG = "SendActivity"
private const val INTENT_RESULT_CODE = 1
class SendActivity : AppCompatActivity() {
private val mtu = 128
private val serviceUuid = "00422342-2342-2342-2342-234223422342"
class SendActivity : AppCompatActivity(), GattListener, FileTransferListener {
private val centralTxCharacteristicUuid = UUID.fromString("01422342-2342-2342-2342-234223422342")
private val centralRxCharacteristicUuid = UUID.fromString("02422342-2342-2342-2342-234223422342")
private var isSending = false
private var transfer: FileTransfer? = null
private var bluetoothGatt: BluetoothGatt? = null
private var fileService: BluetoothGattService? = null
private var mGatt: BluetoothGatt? = null
private var fileTransferService: FileTransfer? = null
private lateinit var tvConnection: TextView
private lateinit var tvValue: TextView
private lateinit var buttonPickFile: Button
private lateinit var buttonStartStop: Button
private lateinit var tvSelected: TextView
private lateinit var tvStatus: TextView
private lateinit var progressBar: ProgressBar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_send)
tvValue = findViewById(R.id.text_value)
tvConnection = findViewById(R.id.text_connection_status)
tvConnection.text = "STATE_DISCONNECTED"
tvSelected = findViewById(R.id.label_selected)
tvStatus = findViewById(R.id.label_status)
progressBar = findViewById(R.id.progress)
val device = ScanActivity.selectedDevice
if (device != null) {
connect(device)
} else {
Log.e(TAG, "Device is NULL!")
buttonPickFile = findViewById(R.id.button_pick_file)
buttonPickFile.setOnClickListener {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*"
startActivityForResult(intent, INTENT_RESULT_CODE)
}
}
fun connect(device: BluetoothDevice) {
val gattCallback = object : BluetoothGattCallback() {
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
if (gatt == null) {
throw NullPointerException()
}
buttonStartStop = findViewById(R.id.button_start_stop_transfer)
for (service in gatt.services) {
Log.d(TAG, "Found service: ${service.uuid}")
toggleControls()
}
if (service.uuid.toString() == serviceUuid) {
fileService = service
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != INTENT_RESULT_CODE) {
return
}
if (fileService == null) {
Log.e(TAG, "Could not find file transfer service")
return
}
val uri = data?.data ?: return
gatt.requestMtu(mtu)
}
try {
val reader = ChunkedReader(this, uri, ConnectionService.mtu)
val service = ConnectionService.leService ?: throw IllegalStateException()
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
when (newState) {
BluetoothGatt.STATE_CONNECTED -> {
runOnUiThread {
tvConnection.text = "STATE_CONNECTED"
}
mGatt = gatt
gatt?.discoverServices()
}
BluetoothGatt.STATE_DISCONNECTED -> tvConnection.text = "STATE_DISCONNECTED"
BluetoothGatt.STATE_CONNECTING -> tvConnection.text = "STATE_CONNECTING"
BluetoothGatt.STATE_DISCONNECTING -> tvConnection.text = "STATE_DISCONNECTING"
}
}
transfer = FileTransfer(service, reader, this)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize transfer")
return
}
override fun onCharacteristicChanged(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) {
super.onCharacteristicChanged(gatt, characteristic)
}
buttonStartStop.isEnabled = true
tvSelected.text = uri.path
}
override fun onCharacteristicRead(
gatt: BluetoothGatt?,
characteristic: BluetoothGattCharacteristic?,
status: Int
) {
super.onCharacteristicRead(gatt, characteristic, status)
}
private fun toggleControls() {
if (isSending) {
progressBar.visibility = View.VISIBLE
buttonPickFile.isEnabled = false
buttonStartStop.text = getString(R.string.send_button_stop_transfer)
override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) {
Log.d(TAG, "MTU changed to: $mtu")
buttonStartStop.setOnClickListener {
transfer?.abort()
transfer = null
isSending = false
runOnUiThread {
tvValue.text = "MTU: $mtu"
toggleControls()
}
}
} else {
progressBar.visibility = View.INVISIBLE
buttonPickFile.isEnabled = true
buttonStartStop.text = getString(R.string.send_button_start_transfer)
val tx = fileService?.getCharacteristic(centralTxCharacteristicUuid)
val rx = fileService?.getCharacteristic(centralRxCharacteristicUuid)
if (gatt != null && tx != null && rx != null) {
val notifySuccess = gatt.setCharacteristicNotification(rx, true)
if (!notifySuccess) {
Log.e(TAG, "Notify enable failed")
}
buttonStartStop.setOnClickListener {
transfer?.start()
isSending = true
// fileTransferService = FileTransfer("/Download/SEND.txt", mtu)
// fileTransferService?.sendFile()
runOnUiThread {
tvStatus.text = "STARTED"
toggleControls()
}
}
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt?,
characteristic: BluetoothGattCharacteristic?,
status: Int
) {
// Thread.sleep(3000)
// fileTransferService?.sendNext()
// return
}
buttonStartStop.isEnabled = transfer != null
}
override fun onError() {
runOnUiThread {
tvStatus.text = "ERROR"
}
}
override fun onFinish() {
isSending = false
transfer = null
bluetoothGatt = device.connectGatt(this, true, gattCallback, BluetoothDevice.TRANSPORT_LE)
runOnUiThread {
tvStatus.text = "FINISHED"
toggleControls()
}
}
}
\ No newline at end of file
package com.github.antweb.donkey.file
import android.content.Context
import android.net.Uri
import android.util.Log
import com.github.antweb.donkey.file.protocol.Packet
import com.github.antweb.donkey.file.protocol.PacketType
import java.lang.IllegalStateException
import java.nio.ByteBuffer
import java.nio.charset.Charset
private const val TAG = "ChunkedReader"
class ChunkedReader(
context: Context,
uri: Uri,
private val mtu: Int
) {
private val iterator: Iterator<Byte>
private var lastPacket: Packet? = null
init {
val inputStream = context.getContentResolver().openInputStream(uri) ?: throw IllegalStateException()
val bytes = inputStream.readBytes()
iterator = bytes.iterator()
}
private var offset: Int = 0
private var lastCrc: Long = 0
fun verifyCrc(crc: Long): Boolean = (lastCrc == crc)
fun isDone() = !iterator.hasNext()
fun getLast(): Packet {
return lastPacket ?: throw IllegalStateException()
}
fun getNext(): Packet {
Log.d(TAG, "Next chunk: $offset")
if (!iterator.hasNext()) {
throw IndexOutOfBoundsException()
}
// Our packet:
// 1 byte header + 4 byte offset + chunk
val maxChunkSize = mtu - 5 - 10
val buffer = ByteBuffer.allocate(mtu - 11)
buffer.putInt(offset)
// TODO: Make less hacky
var n = 0
for (i in 0 until maxChunkSize) {
if (!iterator.hasNext()) {
break
}
buffer.put(iterator.next())
n++
}
var byteArray = buffer.array().sliceArray(0..n)
offset += n
val packet = Packet(
PacketType.CHUNK,
byteArray
)
lastCrc = packet.getCrc()
lastPacket = packet
return packet
}
}
\ No newline at end of file
package com.github.antweb.donkey.file
import android.util.Log
import com.github.antweb.donkey.file.protocol.Packet
import com.github.antweb.donkey.file.protocol.PacketType
import com.github.antweb.donkey.file.protocol.TransferState
import com.github.antweb.donkey.toHex
import java.nio.Buffer
import java.nio.charset.Charset
private const val TAG = "FileTransfer"
class FileTransfer(
private val service: LowEffortService,
private val reader: ChunkedReader,
private var listener: FileTransferListener
) : OnPacketReceivedListener {
private var currentState = TransferState.IDLE
init {
service.addOnPacketReceivedListener(this)
}
override fun onPacketReceived(packet: Packet) {
Log.d(TAG, "Received packet ${packet.type.prefix} ${toHex(packet.payload)}")
Log.d(TAG, "Received packet ${packet.type.prefix} ${String(packet.payload, Charset.defaultCharset())}")
when (packet.type) {
PacketType.START_ACK -> {
if (currentState != TransferState.START_SENT) {
abort()
} else {
currentState = TransferState.READY_TO_SEND