学校寒假有个程序设计比赛,我也一直想要去写一个安卓模拟的蓝牙键盘,这样无论到哪里,比如班班通和没有键盘的电脑设备,有手机就可以操作它,也比USB方便一些。忙活了一个寒假,也走了不少歪路,终于整成了,下面分享一些经验。
代码思路
①第一步是蓝牙HID的初始化
在安卓API28后开放了BluetoothHidDevice类,主要就是用它来完成。首先是注册HID服务:
mBtAdapter.getProfileProxy(context, new BluetoothProfile.ServiceListener() {
@Override
public void onServiceConnected(int profile, BluetoothProfile proxy) {
Log.d(TAG, "onServiceConnected: " + profile);
Toast.makeText(context, "Okk_connected_service", Toast.LENGTH_SHORT).show();
if (profile == BluetoothProfile.HID_DEVICE) {
Log.d(TAG, "Proxy received but it isn't hid_OUT");
if (!(proxy instanceof BluetoothHidDevice)) {
Log.e(TAG, "Proxy received but it isn't hid");
return;
}
Log.d(TAG,"Connecting HID…");
mHidDevice = (BluetoothHidDevice) proxy;
Log.d(TAG, "proxyOK");
BluetoothHidDeviceAppSdpSettings Sdpsettings = new BluetoothHidDeviceAppSdpSettings(
HidConfig.KEYBOARD_NAME,
HidConfig.DESCRIPTION,
HidConfig.PROVIDER,
BluetoothHidDevice.SUBCLASS1_KEYBOARD,
HidConfig.KEYBOARD_COMBO
);
if (mHidDevice != null) {
Toast.makeText(context, "OK for HID profile", Toast.LENGTH_SHORT).show();
Log.d(TAG, "HID_OK");
Log.d(TAG, "Get in register");
//getPermission();
// 创建一个BluetoothHidDeviceAppSdpSettings对象
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
Log.d(TAG,"return before register");
String[] list = new String[] {
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
};
requestPermissions(activity,list,1);
return;
}
BluetoothHidDeviceAppQosSettings inQos = new BluetoothHidDeviceAppQosSettings(
BluetoothHidDeviceAppQosSettings.SERVICE_GUARANTEED, 200, 2, 200,
10000 /* 10 ms */, 10000 /* 10 ms */);
BluetoothHidDeviceAppQosSettings outQos = new BluetoothHidDeviceAppQosSettings(
BluetoothHidDeviceAppQosSettings.SERVICE_GUARANTEED, 900, 9, 900,
10000 /* 10 ms */, 10000 /* 10 ms */);
mHidDevice.registerApp(Sdpsettings, null, null, Executors.newCachedThreadPool(), mCallback);
// registerApp();// 注册
} else {
Toast.makeText(context, "Disable for HID profile", Toast.LENGTH_SHORT).show();
}
// 启用设备发现
// requestLauncher.launch(new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE));
Log.d(TAG, "Discover");
}
}
@SuppressLint("MissingPermission")
@Override
public void onServiceDisconnected(int profile) {// 断开连接
if (profile == BluetoothProfile.HID_DEVICE) {
Log.d(TAG, "Unexpected Disconnected: " + profile);
mHidDevice = null;
mHidDevice.unregisterApp();
}
}
}, BluetoothProfile.HID_DEVICE);
}
public final BluetoothHidDevice.Callback mCallback = new BluetoothHidDevice.Callback() {
private final int[] mMatchingStates = new int[]{
BluetoothProfile.STATE_DISCONNECTED,
BluetoothProfile.STATE_CONNECTING,
BluetoothProfile.STATE_CONNECTED
};
@Override
public void onAppStatusChanged(BluetoothDevice pluggedDevice, boolean registered) {
Log.d(TAG, "ccccc_str");
if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
return;
}
Log.d(TAG, "onAppStatusChanged: " + (pluggedDevice != null ? pluggedDevice.getName() : "null") + "registered:" + registered);
// Toast.makeText(context, "onAppStatusChanged", Toast.LENGTH_SHORT).show();
IsRegisted = registered;
if (registered) {
// 应用已注册
Log.d(TAG, "register OK!.......");
// List<BluetoothDevice> matchingDevices = mHidDevice.getDevicesMatchingConnectionStates(mMatchingStates);
// Log.d(TAG, "paired devices: " + matchingDevices + " " + mHidDevice.getConnectionState(pluggedDevice));
// Toast.makeText(context, "paired devices: " + matchingDevices + " " + mHidDevice.getConnectionState(pluggedDevice), Toast.LENGTH_SHORT).show();
// if (pluggedDevice != null && mHidDevice.getConnectionState(pluggedDevice) != BluetoothProfile.STATE_CONNECTED) {
// boolean result = mHidDevice.connect(pluggedDevice);// pluggedDevice即为连接到模拟HID的设备
// Log.d(TAG, "hidDevice connect:" + result);
// Toast.makeText(context, "hidDevice connect:" + result, Toast.LENGTH_SHORT).show();
// } else if (matchingDevices != null && matchingDevices.size() > 0) {
// // 选择连接的设备
// mHostDevice = matchingDevices.get(0);// 获得第一个已经配对过的设备
// Toast.makeText(context, "device_is_ok: " + mHostDevice.getName() + mHostDevice.getAddress(), Toast.LENGTH_SHORT).show();
// } else {
// // 注册成功未配对
// }
}
// } else {
// // 应用未注册
// }
}
@Override
public void onConnectionStateChanged(BluetoothDevice device, int state) {
Log.d(TAG, "onConnectStateChanged:" + device + " state:" + state);
// Toast.makeText(context, state, Toast.LENGTH_SHORT).show();
if (state == BluetoothProfile.STATE_CONNECTED) {// 已经连接了
connected = true;
mHostDevice = device;
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
// TODO: Consider calling
return;
}
Log.d(TAG,"hid state is connected");
Log.d(TAG,"-----------------------------------connected HID");
Log.d(TAG,device.getName().toString());
// Toast.makeText(context, "device_is_ok: " + mHostDevice.getName() + mHostDevice.getAddress(), Toast.LENGTH_SHORT).show();
} else if (state == BluetoothProfile.STATE_DISCONNECTED) {
connected = false;
Log.d(TAG,"hid state is disconnected");
// mHostDevice = null;
// Toast.makeText(context, "device_is_null", Toast.LENGTH_SHORT).show();
} else if (state == BluetoothProfile.STATE_CONNECTING) {
Log.d(TAG,"hid state is connecting");
}
}
};
在mBtAdapter.getProfileProxy()
中注册,其中onServiceConnected()
会在开始注册时调用,其中的mHidDevice.registerApp()
就是注册采用的方法,提供的SdpSettings
是最主要的的HID描述,其中定义一系列常量用于描述模拟的HID设备。
进行注册时会有一个回调mCallback
,onAppStatusChanged()
调用在注册成功,onConnectStateChanged()
则是在蓝牙连接状态改变时调用,如连接上、断开、正在连接,其中的日志可以反应蓝牙连接的状态。
注意注册HID时,蓝牙必须处于打开状态。打开蓝牙的代码我暂未编写。
②发起蓝牙连接
在发起连接上,我试了从电脑端发起连接、从手机端发起连接,而且使用的都是点进系统蓝牙列表的方式,均无法建立稳定连接。
后来看到大佬文章,解决了这个问题,即使用代理连接:
@SuppressLint("MissingPermission")
public void ConnectotherBluetooth() {
mHostDevice = mBtAdapter.getRemoteDevice("B4:8C:9D:AD:9B:9A");
if (mHostDevice!=null) {
Log.d(TAG,"Connected is OK");
Log.d(TAG,mHostDevice.getName());
}
mHidDevice.connect(mHostDevice);// 代理连接
}
只要把mac地址改成所想要连接的蓝牙设备的mac即可。电脑可以采用cmd指令ipconfig /all
,拉到最底即可;手机使用adb连接后,输入指令adb shell settings get secure bluetooth_address
即可。当然也可以直接扫描,但我目前还未完成相关代码。
③发送报告
@JavascriptInterface
@SuppressLint("MissingPermission")
public void sendKey(String key) {
byte b1 = 0;
if (key.length()<=1) {
char keychar = key.charAt(0);
if ((keychar>=65)&&(keychar<=90)){
b1 = 2;
}
}
if (keyMap.SHITBYTE.containsKey(key)) {
b1 = 2;
}
Log.d(TAG,"pre_send: "+key);
mHidDevice.sendReport(mHostDevice,8,new byte[]{
b1,0,keyMap.KEY2BYTE.get(key.toUpperCase()),0,0,0,0,0
});
mHidDevice.sendReport(mHostDevice,8,new byte[]{
0,0,0,0,0,0,0,0
});// 这是松开按键的报告
Log.d(TAG,"after_send: "+key);
}
发送报告使用sendReport()
,发送对应ID和字节的报告即可。
整体写完其实代码量并不多,但是前期对API的研究还是挺费时间的。
完成代码后,耗时间的还有一些配置:
①HidConfig.java
——HID配置文件
这玩意在安卓上适配都挺好,但Windows上会有一些问题。我自己找了一版描述符,目前是正常的(也是在GitHub上找的):
public class HidConfig {
public final static String KEYBOARD_NAME = "My Keyboard";
public final static String DESCRIPTION = "KKKey";
public final static String PROVIDER = "Alphabet";
public final static byte ID_KEYBOARD = 1;
// HID码表【不知道干啥的】
public static final byte[] KEYBOARD_COMBO =
{
(byte) 0x05, (byte) 0x01, // Usage Page (Generic Desktop)
(byte) 0x09, (byte) 0x06, // Usage (Keyboard)
(byte) 0xA1, (byte) 0x01, // Collection (Application)
(byte) 0x85, (byte) 0x08, // REPORT_ID (Keyboard)
(byte) 0x05, (byte) 0x07, // Usage Page (Key Codes)
(byte) 0x19, (byte) 0xE0, // Usage Minimum (224)
(byte) 0x29, (byte) 0xE7, // Usage Maximum (231)
(byte) 0x15, (byte) 0x00, // Logical Minimum (0)
(byte) 0x25, (byte) 0x01, // Logical Maximum (1)
(byte) 0x75, (byte) 0x01, // Report Size (1)
(byte) 0x95, (byte) 0x08, // Report Count (8)
(byte) 0x81, (byte) 0x02, // Input (Data, Variable, Absolute)
(byte) 0x95, (byte) 0x01, // Report Count (1)
(byte) 0x75, (byte) 0x08, // Report Size (8)
(byte) 0x81, (byte) 0x01, // Input (Constant) reserved byte(1)
(byte) 0x95, (byte) 0x05, // Report Count (5)
(byte) 0x75, (byte) 0x01, // Report Size (1)
(byte) 0x05, (byte) 0x08, // Usage Page (Page# for LEDs)
(byte) 0x19, (byte) 0x01, // Usage Minimum (1)
(byte) 0x29, (byte) 0x05, // Usage Maximum (5)
(byte) 0x91, (byte) 0x02, // Output (Data, Variable, Absolute), Led report
(byte) 0x95, (byte) 0x01, // Report Count (1)
(byte) 0x75, (byte) 0x03, // Report Size (3)
(byte) 0x91, (byte) 0x01, // Output (Data, Variable, Absolute), Led report padding
(byte) 0x95, (byte) 0x06, // Report Count (6)
(byte) 0x75, (byte) 0x08, // Report Size (8)
(byte) 0x15, (byte) 0x00, // Logical Minimum (0)
(byte) 0x25, (byte) 0x65, // Logical Maximum (101)
(byte) 0x05, (byte) 0x07, // Usage Page (Key codes)
(byte) 0x19, (byte) 0x00, // Usage Minimum (0)
(byte) 0x29, (byte) 0x65, // Usage Maximum (101)
(byte) 0x81, (byte) 0x00, // Input (Data, Array) Key array(6 bytes)
(byte) 0xC0 // End Collection (Application)
};
}
也确实尝试了很多版,这版可以。但需要注意其中的(byte) 0x85, (byte) 0x08, // REPORT_ID (Keyboard)
反映了报告的ID = 8,需要和report中的相对应,如:
mHidDevice.sendReport(mHostDevice,/*ID = 8*/8,new byte[]{
0,0,0,0,0,0,0,0
});// 这是松开按键的报告
这里需要注意的一点是发送报告每一位的对应意义。如:
mHidDevice.sendReport(mHostDevice, 8, new byte[]{
b1, 0, keyByte, 0, 0, 0, 0, 0// b1是对应修饰键的字节,包括shift/ctrl/alt等等;keyByte对应发送的字母或数字的字节,即不是修饰键
});
mHidDevice.sendReport(mHostDevice, 8, new byte[]{
0, 0, 0, 0, 0, 0, 0, 0// 松开按键
});
b1是修饰键的描述。需要注意的是如果只发送一个修饰键如shift
来切换输入法中英文输入,只用在修饰键位添加字节即可,此时字母位置为0。
一个合理的根据输入的修饰键调整b1的值的代码如下。不在需要单独为修饰键创建映射集合:
// 发送信息
@JavascriptInterface
@SuppressLint("MissingPermission")
public void sendKey(String key) {
byte b1 = 0;
byte keyByte = 0;
Log.d(TAG,key);
// 修饰键处理
if (key.contains("+")||xiushi.contains(key)) {
String[] keys = key.split("\\+");
for (String k : keys) {
if (k.equalsIgnoreCase("LEFT_SHIFT")||k.equalsIgnoreCase("RIGHT_SHIFT")){
b1 |= 2;
} else if (k.equalsIgnoreCase("LEFT_CTRL")||k.equalsIgnoreCase("RIGHT_CTRL")) {
b1 |= 1;
} else if (k.equalsIgnoreCase("LEFT_ALT")||k.equalsIgnoreCase("RIGHT_ALT")) {
b1 |= 4;
} else if (k.equalsIgnoreCase("HOME")) {
b1 |= 8;
} else {
keyByte = keyMap.KEY2BYTE.get(k.toUpperCase());
}
}
} else {// 其实貌似没啥用
if (key.length() <= 1) {
char keychar = key.charAt(0);
if ((keychar >= 65) && (keychar <= 90)) {
b1 = 2;
}
}
if (keyMap.SHITBYTE.containsKey(key)) {
b1 = 2;
}
keyByte = keyMap.KEY2BYTE.get(key.toUpperCase());
}
//Log.d(TAG, "pre_send: " + key);
Log.d(TAG,"b1="+b1);
Log.d(TAG,"keybyte="+keyByte);
mHidDevice.sendReport(mHostDevice, 8, new byte[]{
b1, 0, keyByte, 0, 0, 0, 0, 0
});
mHidDevice.sendReport(mHostDevice, 8, new byte[]{
0, 0, 0, 0, 0, 0, 0, 0
});
Log.d(TAG, "after_send: " + key);
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
webView.loadUrl("javascript:Showinformation('你在本机发送了:" + key + "')");
}
});
}
在输入端也做了一些多键位输入、组合键识别的处理(使用的是JavaScript构建前端),大家可以参考参考,也可以去看我的仓库:
function setButton() {
let Image = document.querySelector("img.Img");// img
let Map = document.getElementById("image-map");// map
var AreaAll = Map.getElementsByTagName('area');
var ImageRect = Image.getBoundingClientRect();
var BTN_SET = document.getElementById('BTN_SET');
// 首先清除按钮
BTN_SET.innerHTML = '';
// 依次添加按钮
for (var i = 0; i < AreaAll.length; i++) {
var btn = document.createElement('button');
btn.style.position = 'absolute';
btn.style.borderRadius = AreaAll[i].shape === 'circle' ? '50%' : '0';
btn.style.backgroundColor = 'transparent';
btn.style.borderColor = 'transparent';
var Coords = AreaAll[i].coords.split(',');
(function (i) {
// 目前已经没有圆形区域->放弃维护2024/3/28
if (AreaAll[i].shape === 'circle') {
btn.style.left = ImageRect.left + parseInt(Coords[0]) - parseInt(Coords[2]) + 'px';
btn.style.top = ImageRect.top + parseInt(Coords[1]) - parseInt(Coords[2]) + 'px';
btn.style.width = parseInt(Coords[2]) * 2 + 'px';
btn.style.height = parseInt(Coords[2]) * 2 + 'px';
btn.style.transition = "background-color 0.15s ease";
btn.className = AreaAll[i].className;
btn.addEventListener('mousedown', function () {
this.style.backgroundColor = "rgba(211,211,211,0.8)";
});
btn.addEventListener('mouseup', function () {
this.style.backgroundColor = 'transparent';
// 在松开时发送
if (target_setButton === 1) {
Vibra.vibraOnce();
Addd(AreaAll[i]);
} else if (target_setButton === 2) {
// 开启modal
}
});
// btn.onclick = function () {
// }
} else if (AreaAll[i].shape === 'rect') {
// 设置键盘button与按下效果
btn.style.left = ImageRect.left + parseInt(Coords[0]) + 'px';
btn.style.top = ImageRect.top + parseInt(Coords[1]) + 'px';
btn.style.width = (parseInt(Coords[2]) - parseInt(Coords[0])) + 'px';
btn.style.height = (parseInt(Coords[3]) - parseInt(Coords[1])) + 'px';
btn.style.transition = "background-color 0.15s ease";
btn.className = AreaAll[i].className;
btn.id = AreaAll[i].className;
btn.addEventListener('touchstart', function (event) {
// if (document.body.style.backgroundColor === '') {
// console.log("enter in touchstart");
// setSVG_Gery(i, 1);
// } else {
// setSVG_White(i, 1);
// }
this.isPressed = true;
event.stopPropagation();
if (document.body.style.backgroundColor === '') {
this.style.backgroundColor = "rgba(211,211,211,0.4)";
} else {
this.style.backgroundColor = "rgba(255,255,255,0.4)";
}
if (target_setButton === 1) {
if (xiushi.includes(this.className)) {// 存在修饰键
keyset.push(this.className);
console.log(1);
oneordouble = 1;
}
else {// 不在
if (keyset.length === 0) {// 没有修饰键
Vibra.vibraOnce();
console.log(2);
ifsetxiushi = 1;// 发送了修饰键
Addd(this.className);
this.timeoutId = setTimeout(() => {
this.intervalId = setInterval(() => {
Vibra.vibraOnce();
Addd(this.className);
}, 500);
}, 300);
// keyset = [];
} else if (keyset.length >= 1) {
keyset.push(this.className);
// 发送修饰键
console.log(3);
Addd(keyset.join('+'));
keyset = [];
}
}
}
});
btn.addEventListener('touchend', function (event) {
// if (document.body.style.backgroundColor === '') {
// console.log("enter in touchend");
// setSVG_Gery(i, 0);
// } else {
// setSVG_White(i, 0);
// }
// 清除计时
// 对于修饰键在结束时按下
this.style.backgroundColor = 'transparent';
if (!this.isPressed) {
return;
}
if (xiushi.includes(this.className)) {
if (keyset.length >= 2) {
if (oneordouble === 1) {
oneordouble = 0;
console.log(4);
Addd(keyset.join('+'));
keyset = [];
}
// 发送组合键
} else if (keyset.length === 1) {
// 直接发送
Vibra.vibraOnce();
console.log(5);
Addd(this.className);
keyset = [];
}
} else {
keyset = [];
}
clearTimeout(this.timeoutId);
clearInterval(this.intervalId);
// 在松开时发送
if (target_setButton === 2) {
// var top = event.clientY;
// 开启modal
showModal(this);
}
});
this.isPressed = false;
// btn.onclick = function () {
// Vibra.vibraOnce();
// Addd(AreaAll[i]);
// }
}
})(i);
BTN_SET.appendChild(btn);
}
}
②Windows上的适配
实际测试发现,Android适配很好,但Windows总是没反应,也困扰了我很长时间。
后来使用Wireshark对蓝牙抓包,发现安卓是这样的:
而Windows总是这样:
显示正在Pending,无法直接success;而且相同SCID的request最后会以PSM not support请求失败。HID-Control对应的PSM是0x0011。
在Windows开发文档上看到了以下:
接收传入 L2CAP 连接请求
若要接收来自特定 PSM 的任何远程设备的传入 L2CAP 连接请求,配置文件驱动程序应首先生成并发送 BRB_L2CA_REGISTER_标准版RVER 请求,并在请求的 _BRB_L2CA_REGISTER_标准版RVER 结构的 Psm 成员中指定 NULL,并在请求的 _BRB_L2CA_REGISTER_标准版RVER 结构的 Psm 成员中指定 NULL。 发送 BRB_L2CA_REGISTER_标准版RVER 请求时,配置文件驱动程序还必须向蓝牙驱动程序堆栈注册 L2CAP 回调函数。 这使蓝牙驱动程序堆栈能够通知配置文件驱动程序传入 L2CAP 连接请求。
然后,配置文件驱动程序应生成并发送BRB_REGISTER_PSM请求,以便蓝牙驱动程序堆栈将接受请求注册的 PSM 的连接。 否则,蓝牙驱动程序堆栈将拒绝具有未知(未注册)连接请求的所有连接请求。 有关 PSM 的详细信息,请参阅 _BRB_PSM 结构。
所以就感觉是不是驱动的问题。最后下载更新最新版的蓝牙驱动即可。注意更新完后要重启。
于是问题就解决了。抓包结果是,虽然也不是立刻success,但是最后依然请求成功。这估计是因为Windows多了以上的请求过程机制。
④一些连接问题
- 不要在手机或电脑的系统列表中点击设备进行连接。 直接在模拟键盘端使用
connect()
的代理连接即可,直接连到mac地址; - 需要两个设备提前配对。当然在发起连接的过程中配对也可以。如果无法连接,尝试删除设备后在重新配对连接;
- iOS和Mac系统,因为我没有对应的设备,没有进行测试。不过也可以参考我参考文章中的第二篇;
- 注意到很容易断开连接。所以可能需要在断开时控制继续连上。测试下来继续连接的用时是很短的。