Android蓝牙OBEX FTP服务端实现

最近有一个需求,要求在Android APP中,通过蓝牙FTP协议实现文件接收功能。为此,找了很多资料,发现比较简单的实现方案是采用Bluecove库通过OBEX协议来实现。

Bluecove库中已经实现了OBEX协议的解析,同时会调用Android系统的 BluetoothServerSocketBluetoothSocket 进行蓝牙通信监听与数据通信。所以利用该库,直接调用相关接口即可,使用比较简单。

Bluecove库下载

  1. 使用implementation

    在Android Studio中可使用 implementation 的方式自动下载bluecove.jar

    在build.gradle中加入(当前最新版本是2.1.0)

    implementation 'net.sf.bluecove:bluecove:2.1.0'
    
  2. 网上下载jar包

    https://mvnrepository.com/artifact/net.sf.bluecove/bluecove/2.1.0可下载最新的jar包,点击下图红圈标记处直接下载,这一方式其实与方式1是同一源

image-20211229104509004.png
  1. 直接到github上下载源码

    https://github.com/fallowu/bluecove

只需要两个模块:bluecovebluecove-android2

我们发现源码比jar包多了很多模块,尤其是bluecove-android2,说明增加了对Android的支持,而jar包中其实是不支持Android系统的,在后来的运行中也印证了这一点,使用jar包运行时,基本上通不过,会报各种错误,比如缺少.so等,所以最终是采用直接拷贝bluecove源码到工程中来实现的。

https://github.com/fallowu/bluecove/tree/master/bluecove/src/main/java
https://github.com/fallowu/bluecove/tree/master/bluecove-android2/src/main/java
中的package与java代码拷贝到工程中即可。

image-20211229140414844.png

如何使用Bluecove库

我也是参考了网上的资料,比如:

https://oomake.com/question/2117043

https://stackoverflow.com/questions/8063178/bluetooth-obex-ftp-server-on-android-2-x

等等,众多的解决方案都提到了OBEXServer 这个类,然后我到bluecove源码中找了一下,发现其实OBEXServer是bluecove源码中写的一个使用示例,见:

https://github.com/fallowu/bluecove/blob/master/bluecove-examples/obex-server/src/main/java/net/sf/bluecove/obex/server/OBEXServer.java

如果在Android中直接使用OBEXServer.java会出现很多错误, 所以还需要改造OBEXServer才能实现FTP服务。

另外还可以参考bluecove源码中对android支持的说明,见bluecove/bluecove-android2/src/site/apt/index.apt

截取几段说明:

......

BlueCove-Android2 is additional module for BlueCove to partially support JSR-82 on Android using Android 2.x bluetooth APIs.

This module doesn't need any use of Android NDK or any native libraries. Just include its jar in classpath and it should work.
......

Before calling any JSR-82 API, be sure that you called this passing a context object (typically, the activity from which you are using BlueCove).
---
BlueCoveImpl.setConfigObject(BlueCoveConfigProperties.PROPERTY_ANDROID_CONTEXT, context);
---

......

OBEXServer.java改造

除了在项目中引入 Bluecove库 ,我们还需要将 OBEXServer.java 引入,但是直接引入使用时有问题,还需要进行小小的改造:

  1. public final UUID OBEX_OBJECT_PUSH = new UUID(0x1105);
    

    改为

    public final UUID OBEX_OBJECT_PUSH = new UUID(0x1106);
    

    为什么这么改,在bluecove源码中 BluetoothStackAndroid.java 已经给了说明:

    ......
    
    private static final UUID UUID_OBEX = new UUID(0x0008);
    private static final UUID UUID_OBEX_OBJECT_PUSH = new UUID(0x1105);
    private static final UUID UUID_OBEX_FILE_TRANSFER = new UUID(0x1106);
    
    ......
    

    UUID(0x1106)才是专门传输文件的

  2. run()函数中设置context object

    public void run() {
            //add start
            BlueCoveImpl.setConfigObject(BlueCoveConfigProperties.PROPERTY_ANDROID_CONTEXT,context);
            //add end
            
         isStoped = false;
         LocalDevice localDevice;
         try {
             localDevice = LocalDevice.getLocalDevice();
             if (!localDevice.setDiscoverable(DiscoveryAgent.GIAC)) {
                 Logger.error("Fail to set LocalDevice Discoverable");
             }
             serverConnection = (SessionNotifier) Connector.open("btgoep://localhost:" + OBEX_OBJECT_PUSH + ";name="
                     + SERVER_NAME);
         } catch (Throwable e) {
             Logger.error("OBEX Server start error", e);
             isStoped = true;
             return;
         }
         
         ......
     }
    

    我们加了一行

    BlueCoveImpl.setConfigObject(BlueCoveConfigProperties.PROPERTY_ANDROID_CONTEXT,context )
    

    其中变量context为Context类型,需要启动服务时将Activity作为参数传进来。

  3. 去掉不需要的代码

    还是在run()函数里

    public void run() {
            //add start
            BlueCoveImpl.setConfigObject(BlueCoveConfigProperties.PROPERTY_ANDROID_CONTEXT,context);
            //add end
            
         isStoped = false;
         //LocalDevice localDevice; --del
         try {
            /*
             localDevice = LocalDevice.getLocalDevice();
             if (!localDevice.setDiscoverable(DiscoveryAgent.GIAC)) {
                 Logger.error("Fail to set LocalDevice Discoverable");
             }
           */ --del
             serverConnection = (SessionNotifier) Connector.open("btgoep://localhost:" + OBEX_OBJECT_PUSH + ";name="
                     + SERVER_NAME);
         } catch (Throwable e) {
             Logger.error("OBEX Server start error", e);
             isStoped = true;
             return;
         }
    
            //下面的try catch 全部去掉
            /*
         try {
             ServiceRecord record = localDevice.getRecord(serverConnection);
             String url = record.getConnectionURL(ServiceRecord.NOAUTHENTICATE_NOENCRYPT, false);
             Logger.debug("BT server url: " + url);
                
             final int OBJECT_TRANSFER_SERVICE = 0x100000;
    
             try {
                 record.setDeviceServiceClasses(OBJECT_TRANSFER_SERVICE);
             } catch (Throwable e) {
                 Logger.debug("setDeviceServiceClasses", e);
             }
    
             DataElement bluetoothProfileDescriptorList = new DataElement(DataElement.DATSEQ);
             DataElement obbexPushProfileDescriptor = new DataElement(DataElement.DATSEQ);
             obbexPushProfileDescriptor.addElement(new DataElement(DataElement.UUID, OBEX_OBJECT_PUSH));
             obbexPushProfileDescriptor.addElement(new DataElement(DataElement.U_INT_2, 0x100));
             bluetoothProfileDescriptorList.addElement(obbexPushProfileDescriptor);
             record.setAttributeValue(0x0009, bluetoothProfileDescriptorList);
    
             final short ATTR_SUPPORTED_FORMAT_LIST_LIST = 0x0303;
             DataElement supportedFormatList = new DataElement(DataElement.DATSEQ);
             // any type of object.
             supportedFormatList.addElement(new DataElement(DataElement.U_INT_1, 0xFF));
             record.setAttributeValue(ATTR_SUPPORTED_FORMAT_LIST_LIST, supportedFormatList);
    
             final short UUID_PUBLICBROWSE_GROUP = 0x1002;
             final short ATTR_BROWSE_GRP_LIST = 0x0005;
             DataElement browseClassIDList = new DataElement(DataElement.DATSEQ);
             UUID browseClassUUID = new UUID(UUID_PUBLICBROWSE_GROUP);
             browseClassIDList.addElement(new DataElement(DataElement.UUID, browseClassUUID));
             record.setAttributeValue(ATTR_BROWSE_GRP_LIST, browseClassIDList);
    
             localDevice.updateRecord(record);
         } catch (Throwable e) {
             Logger.error("Updating SDP", e);
         }
         */
    
            ......
      }
    

主要去掉了两块:localDevice.setDiscoverablelocalDevice.updateRecord 这两个函数的调用

去掉:localDevice.setDiscoverable(DiscoveryAgent.GIAC),可防止开启服务时,手机弹出对话框提示
去掉:localDevice.updateRecord(record); 这段代码的作用,原因可以查看 BluetoothStackAndroid.java 源码

public void rfServerUpdateServiceRecord(long handle, ServiceRecordImpl serviceRecord, boolean acceptAndOpen) throws ServiceRegistrationException {
     throw new UnsupportedOperationException("Not supported yet.");
 }

因为 localDevice.updateRecord(record) 最终会调用 BluetoothStackAndroid 类的 rfServerUpdateServiceRecord 函数,此函数会抛出异常,告知不支持该操作。

4.将每个Client连接变量放入集合里,方便退出时关闭
目的是在APP退出时,调用close函数,能关掉socket连接

public class OBEXServer implements Runnable {

    private SessionNotifier serverConnection;

    private boolean isStoped = false;

    private boolean isRunning = false;

    public final UUID OBEX_OBJECT_PUSH = new UUID(0x1106);

    public static final String SERVER_NAME = "OBEX Object Push";

    private UserInteraction interaction;

    //add
    private HashSet<RequestHandler> requestHandlerSet = new HashSet<RequestHandler>();

    ......

}

RequestHandlerconnectionAccepted函数中增加一条语句:

void connectionAccepted(Connection cconn) {
            Logger.debug("Received OBEX connection");
            showStatus("Client connected");
            this.cconn = cconn;

            //add
            requestHandlerSet.add(this);

            if (!isConnected) {
                notConnectedTimer.schedule(new TimerTask() {
                    public void run() {
                        notConnectedClose();
                    }
                }, 1000 * 30);
            }
        }

RequestHandler中增加一个函数close:

void close() {
    try {
         if (cconn != null)  {
               cconn.close();
         }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

OBEXServerclose函数增加:

public void close() {
        isStoped = true;
  
        //add
        for (RequestHandler handler : requestHandlerSet) {
            handler.close();
        }
        requestHandlerSet.clear();
        //add end
                
        try {
            if (serverConnection != null) {
                serverConnection.close();
            }
            Logger.debug("OBEX ServerConnection closed");
        } catch (Throwable e) {
            Logger.error("OBEX Server stop error", e);
        }
    }

总结:基本上改了这四处后,OBEXServer已经支持Android作为蓝牙FTP服务端开启正常运行,其它一些细节,根据需要进行更改与优化即可,比如文件存储的目录,需要根据Android系统进行更改,要更改homePath函数。

bluecove库源码改造

如果不改造bluecove库,APP可以收到Client端的连接请求,并回复连接成功消息,但是就没有后续了,Socket会被Client端断开,所以就需要进行一些改动。

  1. OBEXSessionBase.java

    ......
    
    protected static int id = 1; //add
    public OBEXSessionBase(StreamConnection conn, OBEXConnectionParams obexConnectionParams) throws IOException {
        if (obexConnectionParams == null) {
            throw new NullPointerException("obexConnectionParams is null");
        }
        this.isConnected = false;
        this.conn = conn;
        this.obexConnectionParams = obexConnectionParams;
        this.mtu = obexConnectionParams.mtu;
        this.connectionID = id++; //modify
        this.packetsCountWrite = 0;
        this.packetsCountRead = 0;
        boolean initOK = false;
        try {
            this.os = conn.openOutputStream();
            this.is = conn.openInputStream();
            initOK = true;
        } finally {
            if (!initOK) {
                try {
                    this.close();
                } catch (IOException e) {
                    DebugLog.error("close error", e);
                }
            }
        }
    }
    
    ......
    

    增加了一个变量

    protected static int id = 1;
    

    同时将

    this.connectionID = 0;
    

    改为

    this.connectionID = id++; 
    

    源码里connectionID是一直为0,明显不正常,所以每次新建对象时connectionID自增1

另外有一处bug需要更改:

函数handleAuthenticationResponse

if ((authChallengesSent == null) && (authChallengesSent.size() == 0)) {
     throw new IOException("Authentication challenges had not been sent");
}

改为

if ((authChallengesSent == null) || (authChallengesSent.size() == 0)) {
     throw new IOException("Authentication challenges had not been sent");
}

这个bug非常明显,如果用&&符,前面为null,还要去执行size,会引起空指针异常。

  1. OBEXServerSessionImpl.java

    增加一个函数:

    private void connectHeaderTargetCopy(OBEXHeaderSetImpl paramOBEXHeaderSetImpl1, OBEXHeaderSetImpl paramOBEXHeaderSetImpl2) {
         if (paramOBEXHeaderSetImpl1 != null && paramOBEXHeaderSetImpl2 != null && paramOBEXHeaderSetImpl1.headerValues != null && paramOBEXHeaderSetImpl2.headerValues != null)
             for (Object entry : paramOBEXHeaderSetImpl1.headerValues.entrySet()) {
                 if (((Map.Entry)entry).getKey() instanceof Integer && ((Map.Entry)entry).getValue() instanceof byte[] && ((Integer)((Map.Entry)entry).getKey()).intValue() == 70 && !paramOBEXHeaderSetImpl2.headerValues.containsKey(Integer.valueOf(74))) {
                     paramOBEXHeaderSetImpl2.headerValues.put(Integer.valueOf(74), ((Map.Entry)entry).getValue());
                     break;
                 }
         }
    }
    

    然后在函数processConnect 中调用

    private void processConnect(byte[] b) throws IOException {
        
        ......
            
         byte[] connectResponse = new byte[4];
         connectResponse[0] = OBEXOperationCodes.OBEX_VERSION;
         connectResponse[1] = 0; /* Flags */
         connectResponse[2] = OBEXUtils.hiByte(obexConnectionParams.mtu);
         connectResponse[3] = OBEXUtils.loByte(obexConnectionParams.mtu);  
    
         connectHeaderTargetCopy(requestHeaders,replyHeaders); //add
        
         writePacketWithFlags(rc, connectResponse, replyHeaders);
         if (rc == ResponseCodes.OBEX_HTTP_OK) {
            this.isConnected = true;
         }
    }
    

    目的就是在回复Client端的连接请求时,将Client端连接请求Headers信息中的Target数据拷贝到Server端Response消息中,如果不拷贝,Client会将Socket连接断开。
    注:
    70:0x46 Target,操作的目的服务名
    74:0x4A Who,OBEX Application标识,用于表明是否是同一个应用

Headers涉及到了OBEX协议,具体可以参考https://blog.csdn.net/feelinghappy/article/details/107967796

image-20211229155336206.png
  1. OBEXHeaderSetImpl.java

    修改hasIncommingData函数

    boolean hasIncommingData() {
     return headerValues.contains(new Integer(OBEX_HDR_BODY))
             || headerValues.contains(new Integer(OBEX_HDR_BODY_END));
    }
    

    改为

    boolean hasIncommingData() {
     return headerValues.containsKey(new Integer(OBEX_HDR_BODY))
         || headerValues.containsKey(new Integer(OBEX_HDR_BODY_END));
    }
    

    此处估计是一个bug,应该判断的是headerValues的key是否包含那两个值

  2. BluetoothStackAndroid.java

    将函数rfServerAcceptAndOpenRfServerConnection 中的一行serverSocket.close();去掉

    修改后的函数如下:

    public long rfServerAcceptAndOpenRfServerConnection(long handle) throws IOException {
         AndroidBluetoothConnection bluetoothConnection =      AndroidBluetoothConnection.getBluetoothConnection(handle);
         BluetoothServerSocket serverSocket = bluetoothConnection.getServerSocket();
         BluetoothSocket socket = serverSocket.accept();
    //       serverSocket.close();  --del
         AndroidBluetoothConnection connection = AndroidBluetoothConnection.createConnection(socket, true);
         return connection.getHandle();
     }
    

    rfServerAcceptAndOpenRfServerConnection函数最开始是由OBEXServer的循环语句内

    handler.connectionAccepted(serverConnection.acceptAndOpen(handler));
    

    调用到的,如果在rfServerAcceptAndOpenRfServerConnection函数中关闭了serverSocket,将导致第一次成功acceptAndOpen后的后续acceptAndOpen调用全部产生异常

     java.io.IOException: bt socket is not in listen state
            at android.bluetooth.BluetoothSocket.accept(BluetoothSocket.java:493)
            at android.bluetooth.BluetoothServerSocket.accept(BluetoothServerSocket.java:171)
            at android.bluetooth.BluetoothServerSocket.accept(BluetoothServerSocket.java:157)
            at com.intel.bluetooth.BluetoothStackAndroid.rfServerAcceptAndOpenRfServerConnection(BluetoothStackAndroid.java:461)
            at com.intel.bluetooth.BluetoothRFCommConnectionNotifier.acceptAndOpen(BluetoothRFCommConnectionNotifier.java:74)
            at com.intel.bluetooth.obex.OBEXSessionNotifierImpl.acceptAndOpen(OBEXSessionNotifierImpl.java:89)
            at com.intel.bluetooth.obex.OBEXSessionNotifierImpl.acceptAndOpen(OBEXSessionNotifierImpl.java:79)
            .......
    

5.OBEXServerOperationPut.java
构造函数OBEXServerOperationPut,最后增加一句:

protected OBEXServerOperationPut(OBEXServerSessionImpl session, OBEXHeaderSetImpl receivedHeaders,
           boolean finalPacket) throws IOException {
       super(session, receivedHeaders);
       this.inputStream = new OBEXOperationInputStream(this);
       processIncommingData(receivedHeaders, finalPacket);
       //下面是增加的代码,主要是解决put操作时,如果接收到最后一条数据
       //程序没有及时设置成最后一条,导致仍然在put操作中,没有退出,
       //后续上传新的文件时,会当成上一个文件的后续,上传会失败
       finalPacketReceived = finalPacket; 
   }

总结

应用内还需要增加权限的支持、蓝牙配对等功能,OBEXServer也可以优化,但是经过上述修改后,APP已经具备通过蓝牙FTP接收文件并保存到手机的功能。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容