Commit 1e7cab48 authored by Anon's avatar Anon Committed by Andreas Schildbach
Browse files

Implement file transfer.

parent acf018b1
......@@ -25,5 +25,8 @@
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
</manifest>
/*
* Copyright by the original author or authors.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package de.ccc.events.badge.card10.common
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import de.ccc.events.badge.card10.filetransfer.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 val fileServiceUuid = UUID.fromString("42230100-2342-2342-2342-234223422342")
val deviceName: String?
get() = device?.name
val deviceAddress: String?
get() = device?.address
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)
}
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 == fileServiceUuid) {
leService = LowEffortService(service)
}
}
if (leService == null) {
Log.e(TAG, "Could not find file transfer service")
return
}
gatt.requestMtu(mtu)
}
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?, newMtu: Int, status: Int) {
Log.d(TAG, "MTU changed to: $newMtu")
if (gatt == null) {
throw IllegalStateException()
}
mtu = newMtu - 3 // Very precise science
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
/*
* Copyright by the original author or authors.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package de.ccc.events.badge.card10.common
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
/*
* Copyright by the original author or authors.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package de.ccc.events.badge.card10.filetransfer
import android.content.Context
import android.net.Uri
import android.util.Log
import de.ccc.events.badge.card10.filetransfer.protocol.Packet
import de.ccc.events.badge.card10.filetransfer.protocol.PacketType
import java.lang.IllegalStateException
import java.nio.ByteBuffer
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.contentResolver.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 {
Log.d(TAG, "lastCrc: $lastCrc, crc: $crc")
Log.d(TAG, "lastCrc: ${java.lang.Long.toHexString(lastCrc)}, crc: ${java.lang.Long.toHexString(crc)}")
return (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
val buffer = ByteBuffer.allocate(mtu - 1)
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++
}
val byteArray = buffer.array().sliceArray(0 until n + 4)
offset += n
Log.d(TAG, "n: $n offset: $offset")
val packet = Packet(
PacketType.CHUNK,
byteArray
)
lastCrc = packet.getCrc()
lastPacket = packet
return packet
}
}
\ No newline at end of file
/*
* Copyright by the original author or authors.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package de.ccc.events.badge.card10.filetransfer
import android.util.Log
import de.ccc.events.badge.card10.filetransfer.protocol.Packet
import de.ccc.events.badge.card10.filetransfer.protocol.PacketType
import de.ccc.events.badge.card10.filetransfer.protocol.TransferState
import java.nio.ByteBuffer
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) {
when (packet.type) {
PacketType.START_ACK -> {
if (currentState != TransferState.START_SENT) {
abort()
} else {
currentState = TransferState.READY_TO_SEND
sendNext()
}
}
PacketType.CHUNK_ACK -> {
if (currentState == TransferState.READY_TO_SEND || currentState == TransferState.CHUNK_SENT) {
if (verifyCrc(packet)) {
sendNext()
} else {
// TODO: Retry
abort()
}
} else {
abort()
}
}
PacketType.FINISH_ACK -> {
if (currentState != TransferState.FINISH_SENT) {
abort()
} else {
currentState = TransferState.IDLE
listener.onFinish()
}
}
PacketType.ERROR -> {
// Abort transfer
peripheralAbort()
listener.onError()
}
PacketType.ERROR_ACK -> {
// TODO
}
else -> {
abort()
}
}
}
fun start() {
if (currentState != TransferState.IDLE) {
throw IllegalStateException()
}
service.sendPacket(
Packet(
PacketType.START,
"/foo.py".toByteArray(Charset.forName("ASCII"))
)
)
currentState = TransferState.START_SENT
}
private fun sendNext() {
if (!reader.isDone()) {
var status = service.sendPacket(reader.getNext())
var retryCount = 1
while (!status && retryCount < 3) {
status = service.sendPacket(reader.getLast())
retryCount++
}
} else {
service.sendPacket(
Packet(
PacketType.FINISH,
ByteArray(0)
)
)
currentState = TransferState.FINISH_SENT
}
}
private fun verifyCrc(packet: Packet): Boolean {
val crcBuf = ByteBuffer.wrap(packet.payload)
val buf = ByteBuffer.allocate(8)
buf.putInt(0)
buf.put(crcBuf)
val long = buf.getLong(0)
return reader.verifyCrc(long)
}
private fun peripheralAbort() {
// Peripheral sent error. Only clean up
}
fun abort() {
// We want to abort. Clean up and send ERROR
service.sendPacket(
Packet(
PacketType.ERROR,
ByteArray(0)
)
)
listener.onError()
}
}
\ No newline at end of file
/*
* Copyright by the original author or authors.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package de.ccc.events.badge.card10.filetransfer
import android.bluetooth.BluetoothAdapter
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.UiThread
import androidx.fragment.app.Fragment
import de.ccc.events.badge.card10.R
import de.ccc.events.badge.card10.common.ConnectionService
import de.ccc.events.badge.card10.common.GattListener
import java.lang.Exception
import java.lang.IllegalStateException
private const val TAG = "FileTransferFragment"
private const val INTENT_RESULT_CODE_FILE = 1
class FileTransferFragment : Fragment(), GattListener, FileTransferListener{
private var isSending = false
private var transfer: FileTransfer? = null
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 onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.file_transfer_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
tvSelected = view.findViewById(R.id.label_selected)
tvStatus = view.findViewById(R.id.label_status)
progressBar = view.findViewById(R.id.progress)
buttonPickFile = view.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_FILE)
}
buttonStartStop = view.findViewById(R.id.button_start_stop_transfer)
connect()
toggleControls()
}
private fun connect() {
// Test function that connects to specific card10
val remoteDevice = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("00:05:8B:44:42:42")
ConnectionService.device = remoteDevice
val ctx = context ?: throw IllegalStateException()
ConnectionService.connect(ctx)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != INTENT_RESULT_CODE_FILE) {
return
}
val ctx = context ?: throw IllegalStateException()
val uri = data?.data ?: return
try {
val reader = ChunkedReader(ctx, uri, ConnectionService.mtu)
val service = ConnectionService.leService ?: throw IllegalStateException()
transfer = FileTransfer(service, reader, this)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize transfer")
return
}
buttonStartStop.isEnabled = true
tvSelected.text = uri.path
}
private fun toggleControls() {
if (isSending) {
activity?.runOnUiThread {
progressBar.visibility = View.VISIBLE
buttonPickFile.isEnabled = false
buttonStartStop.text = getString(R.string.file_transfer_button_stop_transfer)
}
buttonStartStop.setOnClickListener {
transfer?.abort()
transfer = null
isSending = false