Wifi Direct功能早在Android 4.0就以经加入Android系统了,但是一直没有很好的被支持,主要原因是比较耗电而且连接并不是很稳定。但是也有很大的好处,就是范围广而且速度快,适合设备间在无网络的情况下进行大文件传输。目前Android系统中只是内置了设备的搜索和链接功能,并没有像蓝牙那样有许多应用。
有关WiFiP2P的相关API都在android.net.wifi.p2p下,类并不多,如下图:
在做开发之前,我们首先简单了解一下WIFi P2P的模型。P2P模型中,是以一个组形式存在的,当两台设备通过P2P连接后,会随机(也可以手动指定)指派其中一台设备为组拥有者(Group Owner),相当于一台服务器,另一台设备为组成员(Group Client)。其他设备可以通过与GO设备连接加入组,但是不能直接和GC设备连接。在组内,成员可以直接获取到组长的IP地址,组长能直接获得组内成员的信息,但直接获取不到组员的IP地址,组员也不能直接获取到其他成员的信息。
这里就相当于一个服务器--客户端模型。客户端能直接连接到服务器,服务器事先并不能连接到客户端,客户端本身也不知道其他客户端的存在,也不能直接建立联系。不过这些问题只是API没有提供对应方法而已,我们都可以通过软件手段进行解决。
在做开发之前,我们先梳理一下逻辑。如果通过P2P传输文件,首先要建立连接,这里需要借助WifiP2pManager等类,建立连接后传输文件就用到Socket,这里就和P2P相关API无关了,属于Java的范畴。(有关TCP和UDP的实现可以参考的我两篇笔记).
第一步是建立连接,我们可以直接选择转到系统设置界面,代码如下:
Intent p2pSettings = new Intent();
p2pSettings.setComponent(new ComponentName("com.android.settings","com.android.settings.Settings$WifiP2pSettingsActivity"));
try{
startActivity(p2pSettings);
}catch (Exception e){
e.printStackTrace();
}
也可以自己实现搜索连接的逻辑,下面我们学习一下相关逻辑,可以参考Google开发文档或者Settings源码。
首先我们要申请一些权限
若要传输文件,还要读写的权限,这组权限要动态申请
WifiP2pManager的获取和初始化如下
wifiP2pManager = (WifiP2pManager) getSystemService(Context.WIFI_P2P_SERVICE);
channel = wifiP2pManager.initialize(this,getMainLooper(),()->Utils.d("onChannelDisconnected"));
其中channel是许多操作的真正执行者,而且许多操作都是异步的,需要借助很多接口实现。关于WiFi P2P的开发,大部分都借助于WifiP2pManager实现,而且一些状态的获取都是基于广播的,所以我们需要建立一个广播接受者,来接受各种相关的广播,首先需要注册下面几个广播:
IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION);
intentFilter.addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION);
intentFilter.addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION);
intentFilter.addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION);
intentFilter.addAction(WifiP2pManager.WIFI_P2P_DISCOVERY_CHANGED_ACTION);
registerReceiver(receiver, intentFilter);
- WIFI_P2P_STATE_CHANGED_ACTION:P2P状态改变的广播,有两个状态:可用:WifiP2pManager.WIFI_P2P_STATE_ENABLED,不可用:WifiP2pManager.WIFI_P2P_STATE_DISABLED,在广播接受者内通过下面代码获取:
case WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION:
boolean p2pIsEnable = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE,WifiP2pManager.WIFI_P2P_STATE_DISABLED) == WifiP2pManager.WIFI_P2P_STATE_ENABLED;
break
- WIFI_P2P_PEERS_CHANGED_ACTION:当发现周围设备时的广播,一般在接到次广播时可以更新设备列表,与蓝牙不同,这里的API是以列表的形式将所有搜索到的设备都返回给我们的,具体如下:
case WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION:
//获取到设备列表信息
WifiP2pDeviceList mPeers = intent.getParcelableExtra(WifiP2pManager.EXTRA_P2P_DEVICE_LIST);
list.clear(); //清除旧的信息
list.addAll(mPeers.getDeviceList()); //更新信息
adapter.notifyDataSetChanged(); //更新列表
break;
这里很多教程中都用提出用wifiP2pManager.requestPeers()方法获取列表,但亲测这个方法并不好用,很多时候返回的列表为空,但实际上已搜索到设备了。有疑问的朋友可以两种方法都尝试一下,也可能我的手机framework层代码有变化。另外有些朋友可能觉得addAll方法比较消耗性能,其实这个广播不仅在周围设备增减时发送,而且在周围设备和本机设备的连接状态发送变化时,也会发出,这样可以及时更新设备状态。
- WIFI_P2P_CONNECTION_CHANGED_ACTION:这是连接状态发送变化时的广播,如连接了一个设备,断开了一个设备都会接收到广播。着这个广播到来时,可以获得如下信息:
case WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION:
NetworkInfo networkInfo = intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO);
WifiP2pInfo wifiP2pInfo = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO);
WifiP2pGroup wifiP2pGroup = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP);
break;
NetworkInfo 的isConnected()可以判断时连接还是断开时接收到的广播。
WifiP2pInfo保存着一些连接的信息,如groupFormed字段保存是否有组建立,groupOwnerAddress字段保存GO设备的地址信息,isGroupOwner字段判断自己是否是GO设备。WifiP2pInfo也可以随时用过wifiP2pManager.requestConnectionInfo来获取。
WifiP2pGroup存放着当前组成员的信息,这个信息只有GO设备可以获取。同样这个信息也可以通过wifiP2pManager.requestGroupInfo获取,一些方法如下,都比较简单易懂:
- WIFI_P2P_THIS_DEVICE_CHANGED_ACTION:这个广播与当前设备的改变有关,一般注册这个广播后,就会收到,以此来获取当前设备的信息,具体如下
case WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION:
WifiP2pDevice device = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE);
deviceInfo.setText(getString(R.string.device_info) + device.deviceName );
break;
- WIFI_P2P_DISCOVERY_CHANGED_ACTION:这个是搜索状态有关的广播,开始搜索和结束搜索时会收到,用法如下:
case WifiP2pManager.WIFI_P2P_DISCOVERY_CHANGED_ACTION:
int discoveryState = intent.getIntExtra(WifiP2pManager.EXTRA_DISCOVERY_STATE,WifiP2pManager.WIFI_P2P_DISCOVERY_STOPPED);
isDiscover = discoveryState == WifiP2pManager.WIFI_P2P_DISCOVERY_STARTED;
updateState();
break;
通过广播我们可以及时获取到各种状态的改变。
启动搜索 的方法如下
wifiP2pManager.discoverPeers(channel, new WifiP2pManager.ActionListener() {
@Override
public void onSuccess() {
Utils.d("discoverPeers Success");
}
@Override
public void onFailure(int reason) {
Utils.d("discoverPeers Failure");
}
});
停止搜索如下:
wifiP2pManager.stopPeerDiscovery(channel, null);
一般在退出时停止搜索,也就不需要进行状态监听。需要注意的是,在开启搜索时设备才是可见,停止时设备就变得不可见了。
连接设备 操作如下:
WifiP2pDevice selectDevice = list.get(position);
if (selectDevice.status == WifiP2pDevice.AVAILABLE) {
WifiP2pConfig config = new WifiP2pConfig();
config.deviceAddress = selectDevice.deviceAddress;
config.wps.setup = WpsInfo.PBC;
wifiP2pManager.connect(channel, config, new WifiP2pManager.ActionListener() {
@Override
public void onSuccess() {
Utils.d("connect success");
}
@Override
public void onFailure(int reason) {
Utils.d("connect failure");
}
});
}
基本就是选择一个要连接的设备,配置一个WifiP2pConfig对象,调用connect方法进行连接,由于p2p相关API大多都是异步的,需要一个监听成功与否的操作。需要注意的是,设备有很多状态,只有处于
Available状态时才可以尝试连接,另外还有Invited和Connected状态等。Connected就是已经连接,Invited是指请求过连接,对方可能处于另外一个组中并且是GC身份,不能与其他设备连接,请求被搁置。
当处于Invited时可以取消邀请:
WifiP2pDevice selectDevice = list.get(position);
if (selectDevice.status == WifiP2pDevice.INVITED){
wifiP2pManager.cancelConnect(channel, new WifiP2pManager.ActionListener() {
public void onSuccess() {
Utils.d(" cancel connect success");
}
public void onFailure(int reason) {
Utils.d(" cancel connect fail ");
}
});
}
当处于Connected状态时可以断开连接:
WifiP2pDevice selectDevice = list.get(position);
if (selectDevice.status == WifiP2pDevice.CONNECTED) {
wifiP2pManager.removeGroup(channel, new WifiP2pManager.ActionListener() {
public void onSuccess() {
Utils.d(" remove group success");
}
public void onFailure(int reason) {
Utils.d(" remove group fail " );
}
});
}
我们之前说,建立连接时,身份是随机分配的,不过我们可以指定自己作为GO设备,通过wifiP2pManager.createGroup创建组,来等待客户端连接。
在连接成功后,利用WifiP2pInfo的groupOwnerAddress获取到GO设备也就是服务器的地址,进行通信。
可以看到以上的操作进行完之后,若是利用socket等基于IP地址的通信方式,只能GC设备主动和GO设备通信后,GO设备才能和GC设备通信。
我的做法是服务端维护一个表,通过mac地址区分设备,当一个连接建立时,客户端主动和服务端进行一次极短的通信,服务端借此保存客户端的地址和端口,当连接中断后或者新设备加入时更新这个表。
建立连接后,可以利用TCP协议进行通信,在设计模型时,我的做法是,在P2P服务可用时,就启动一个服务,建立一个ServerSocket,并设立一个死循环,不断接受外部的请求,由于在主线程中不能这样做,可以借助IntentService,实现如下,以接受文件为例:
public class WifiP2PReceiveService extends IntentService {
public WifiP2PReceiveService() {
super("WifiP2PReceiveService");
}
@Override
protected void onHandleIntent(Intent intent) {
Utils.d("receive start");
try (ServerSocket service = new ServerSocket(10101)){
while (true){
Utils.d("wait accept");
try (Socket socket = service.accept()){
Utils.d("get accept");
InputStream in = socket.getInputStream();
ObjectInputStream objectInputStream = new ObjectInputStream(in);
//获取文件信息
FileInfo info = (FileInfo) objectInputStream.readObject();
//创建存储目录
File downdir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),"wifip2p");
if(!downdir.exists())
downdir.mkdir();
File file = new File(downdir,info.getName());
//存储文件
FileOutputStream fileOutputStream = new FileOutputStream(file);
int len;
byte[] buffer = new byte[1024*8];
long total = 0;
while((len = in.read(buffer))!=-1){
fileOutputStream.write(buffer,0,len);
total += len;
Utils.d("current : " + total + " / total : " + info.getLength());
}
//MD5校验
Utils.d(info.getMd5().equals(Utils.getMD5(file)) ? "传输成功" : "传输失败,MD5不一致");
Utils.d("info:"+file.toString());
}catch (Exception e){
Utils.d(e.getMessage());
}
}
}catch (Exception e){
Utils.d(e.getMessage());
}
}
}
当然上面也可以利用线程池进行设计,减少阻塞,具体见我的TCP相关笔记。发送文件也比较简单,发送时不必让服务一直存在,还是可以借助IntentService,用完自动回收,而且在子线程操作:
public class WifiP2PSendService extends IntentService {
private InetAddress address = null;
private FileInfo fileinfo;
public WifiP2PSendService() {
super("WifiP2PSendService");
}
@Override
protected void onHandleIntent(Intent intent) {
//传入文件信息
fileinfo = (FileInfo) intent.getSerializableExtra("fileinfo");
//传入地址信息
address = (InetAddress) intent.getSerializableExtra("address");
try (Socket socket = new Socket(address,10101)){
OutputStream out = socket.getOutputStream();
//开始传输
ObjectOutputStream objectOutputStream = new ObjectOutputStream(out);
objectOutputStream.writeObject(fileinfo);
FileInputStream fileInputStream = new FileInputStream(new File(fileinfo.getPath()));
int len = 0;
byte[] buffer = new byte[1024];
long total = 0;
while((len = fileInputStream.read(buffer))!=-1) {
out.write(buffer, 0, len);
total += len;
Utils.d("current : " + total + " / total : " + fileinfo.getLength());
}
}catch (Exception e){
e.printStackTrace();
}
}
}
记得在退出时结束WifiP2PReceiveService。
最后提供一下文件选择时的操作代码:
public void send(View view) {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
intent.addCategory(Intent.CATEGORY_OPENABLE);
startActivityForResult(intent, 1);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if(requestCode == 1){
if (resultCode == RESULT_OK){
Uri uri = data.getData();
if (uri != null) {
String path = Utils.getPath(this,uri);
if (path != null) {
final File file = new File(path);
if (!file.exists() ) {
d("找不到文件");
return;
}
//计算MD5值时建议放在发送文件的服务中,也就是子进程中完成
String md5 = Utils.getMD5(file);
FileInfo fileinfo = new FileInfo(file.getName(), file.length(), md5,file.getAbsolutePath());
Intent intent = new Intent();
intent.putExtra("address",connectDevice.groupOwnerAddress);
intent.putExtra("fileinfo",fileinfo);
intent.setClass(this,WifiP2PSendService.class);
startService(intent);
}
}
}
}
}
通过URI解析文件路径:
public static String getPath(Context context, Uri uri){
if (context == null || uri == null)
return null;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT && DocumentsContract.isDocumentUri(context, uri)) {
if (isExternalStorageDocument(uri)) {
String docId = DocumentsContract.getDocumentId(uri);
String[] split = docId.split(":");
String type = split[0];
if ("primary".equalsIgnoreCase(type)) {
return Environment.getExternalStorageDirectory() + "/" + split[1];
}
} else if (isDownloadsDocument(uri)) {
String id = DocumentsContract.getDocumentId(uri);
Uri contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), Long.valueOf(id));
return getDataColumn(context, contentUri, null, null);
} else if (isMediaDocument(uri)) {
String docId = DocumentsContract.getDocumentId(uri);
String[] split = docId.split(":");
String type = split[0];
Uri contentUri = null;
if ("image".equals(type)) {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
} else if ("video".equals(type)) {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
} else if ("audio".equals(type)) {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}
String selection = MediaStore.Images.Media._ID + "=?";
String[] selectionArgs = new String[]{split[1]};
return getDataColumn(context, contentUri, selection, selectionArgs);
}
}
else if ("content".equalsIgnoreCase(uri.getScheme())) {
if (isGooglePhotosUri(uri))
return uri.getLastPathSegment();
return getDataColumn(context, uri, null, null);
}
else if ("file".equalsIgnoreCase(uri.getScheme())) {
return uri.getPath();
}
return null;
}
public static boolean isExternalStorageDocument(Uri uri) {
return "com.android.externalstorage.documents".equals(uri.getAuthority());
}
public static boolean isDownloadsDocument(Uri uri) {
return "com.android.providers.downloads.documents".equals(uri.getAuthority());
}
public static boolean isMediaDocument(Uri uri) {
return "com.android.providers.media.documents".equals(uri.getAuthority());
}
public static boolean isGooglePhotosUri(Uri uri) {
return "com.google.android.apps.photos.content".equals(uri.getAuthority());
}
public static String getDataColumn(Context context, Uri uri, String selection, String[] selectionArgs) {
Cursor cursor = null;
String column = MediaStore.Images.Media.DATA;
String[] projection = {column};
try {
cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null);
if (cursor != null && cursor.moveToFirst()) {
int index = cursor.getColumnIndexOrThrow(column);
return cursor.getString(index);
}
} finally {
if (cursor != null)
cursor.close();
}
return null;
}
计算MD5值:
public static String getMD5(File file){
InputStream inputStream = null;
byte[] buffer = new byte[2048];
int numRead;
MessageDigest md5;
try {
inputStream = new FileInputStream(file);
md5 = MessageDigest.getInstance("MD5");
while ((numRead = inputStream.read(buffer)) > 0) {
md5.update(buffer, 0, numRead);
}
StringBuilder hexValue = new StringBuilder();
for (byte b : md5.digest()) {
int val = ((int) b) & 0xff;
if (val < 16) {
hexValue.append("0");
}
hexValue.append(Integer.toHexString(val));
}
return hexValue.toString();
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}