Android WiFi P2P开发实践笔记

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();
                }
            }
        }
    }

你可能感兴趣的:(Android WiFi P2P开发实践笔记)