背景
最近客户那边反馈需求希望我司的设备能像三星的机器一样,usb连接电脑的时候默认是mtp模式,同时可以解决电脑查看手机上的文件有时候不一致的问题(也就是手机上创建的新文件或者目录,电脑上不能及时看到)。
需求分解
需求我们分解一下,其实是两个需求。
1.usb连接电脑默认mtp模式
2.实时文件扫描
需求实现思路
一般来说,如果需求可以不动os测实现我们尽量不去动os。
默认mtp模式:我们可以监测插入usb和断开usb,插入usb线的时候就把从充电模式切换到mtp模式。
实时文件扫描:这个需求其实有两种实现,方式一是以切换到mtp模式为trigger,来做一次全盘扫描。方式二是对存储空间进行监视,当有文件create或者delete的时候,去扫描该文件。
1.默认mtp模式
思路:
注册个广播A接受开机广播,广播A中start一个Service,Service动态注册广播来接受"android.hardware.usb.action.USB_STATE",判断好插入usb线的条件,保证在插入usb线后跳到mtp模式。
代码:
private boolean mUsbModeInit=true;
IntentFilter filter = new IntentFilter();
filter.addAction("android.hardware.usb.action.USB_STATE");
registerReceiver(mReceiver, filter, null, null);
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals("android.hardware.usb.action.USB_STATE")){
boolean connected = intent.getExtras().getBoolean("connected");
boolean configured = intent.getExtras().getBoolean("configured");
boolean unlocked = intent.getExtras().getBoolean("unlocked");
if(!connected&&!configured){
mUsbModeInit=true;
}
if (connected&&configured&&!unlocked&&mUsbModeInit){
UsbManager usbManager = (UsbManager) getSystemService(UsbManager.class);
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.O){
try {
Class<?> clazz=Class.forName("android.hardware.usb.UsbManager");
Method method=clazz.getMethod("setCurrentFunction", String.class);
method.invoke(usbManager,UsbManager.USB_FUNCTION_MTP);
Method method1=clazz.getMethod("setUsbDataUnlocked", boolean.class);
method1.invoke(usbManager,true);
} catch (Exception e) {
e.printStackTrace();
}
}else {
usbManager.setCurrentFunction(UsbManager.USB_FUNCTION_MTP, true);
}
mUsbModeInit=false;
}
}
}
分析总结:
我们注册广播来监听"android.hardware.usb.action.USB_STATE",顾名思义,只要usb的state发生变化,系统就会发出该广播。
有兴趣的朋友可以实测一下,当我们插入usb线或断开usb线的时候,该广播会触发多次。这种state change的广播在android系统中有很多,比如蓝牙,wifi的打开和关闭也是一样的。打开和关闭也不仅仅是发了一次广播。
所以,在广播中根据广播携带的内容来做判断就显得至关重要了,毕竟我们只希望我们的代码只执行一次。
源码分析
我们去追一追源码,涉及的相关类有:
frameworks/base/core/java/android/hardware/usb/UsbManager.java
frameworks/base/services/usb/java/com/android/server/usb/UsbDeviceManager.java
frameworks/base/services/core/java/com/android/server/connectivity/Tethering.java
广播是在UsbDeviceManager.java里面发出来的:
private void updateUsbStateBroadcastIfNeeded(boolean configChanged) {
// send a sticky broadcast containing current USB state
Intent intent = new Intent(UsbManager.ACTION_USB_STATE);
intent.addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING
| Intent.FLAG_RECEIVER_INCLUDE_BACKGROUND
| Intent.FLAG_RECEIVER_FOREGROUND);
intent.putExtra(UsbManager.USB_CONNECTED, mConnected);
intent.putExtra(UsbManager.USB_HOST_CONNECTED, mHostConnected);
intent.putExtra(UsbManager.USB_CONFIGURED, mConfigured);
intent.putExtra(UsbManager.USB_DATA_UNLOCKED,
isUsbTransferAllowed() && mUsbDataUnlocked);
intent.putExtra(UsbManager.USB_CONFIG_CHANGED, configChanged);
if (mCurrentFunctions != null) {
String[] functions = mCurrentFunctions.split(",");
for (int i = 0; i < functions.length; i++) {
final String function = functions[i];
if (UsbManager.USB_FUNCTION_NONE.equals(function)) {
continue;
}
intent.putExtra(function, true);
}
}
// send broadcast intent only if the USB state has changed
if (!isUsbStateChanged(intent) && !configChanged) {
if (DEBUG) {
Slog.d(TAG, "skip broadcasting " + intent + " extras: " + intent.getExtras());
}
return;
}
if (DEBUG) Slog.d(TAG, "broadcasting " + intent + " extras: " + intent.getExtras());
mContext.sendStickyBroadcastAsUser(intent, UserHandle.ALL);
mBroadcastedIntent = intent;
}
可以看出这个广播携带了很多extra。而且这个广播是黏性广播,这意味着你在注册broadcastReceiver后马上就能收到一次。所以即使我们插着usb线开机,没有插入usb线的操作去trigger,我们注册广播后依然可以把usb模式转换为mtp模式。
关于extras的解释我们在Tethering.java可以一探究竟:
它的handleUsbAction方法中有注释提到:
// There are three types of ACTION_USB_STATE:
//
// - DISCONNECTED (USB_CONNECTED and USB_CONFIGURED are 0)
// Meaning: USB connection has ended either because of
// software reset or hard unplug.
//
// - CONNECTED (USB_CONNECTED is 1, USB_CONFIGURED is 0)
// Meaning: the first stage of USB protocol handshake has
// occurred but it is not complete.
//
// - CONFIGURED (USB_CONNECTED and USB_CONFIGURED are 1)
// Meaning: the USB handshake is completely done and all the
// functions are ready to use.
这样就一目了然了。这里我们比较关注的是DISCONNECTED和CONFIGURED。
boolean connected = intent.getExtras().getBoolean("connected");
boolean configured = intent.getExtras().getBoolean("configured");
boolean unlocked = intent.getExtras().getBoolean("unlocked");
connected和configured配合可以表明DISCONNECTED和CONFIGURED状态。
Unlocked可以表明是否是mtp模式。
Android O与N的适配
代码里面还有一个注意点是android不同版本间的适配,android O上是用的
UsbManager usbManager = (UsbManager) getSystemService(UsbManager.class);
usbManager.setCurrentFunction(UsbManager.USB_FUNCTION_MTP, true);
而android N上面则是
UsbManager usbManager = (UsbManager) getSystemService(UsbManager.class);
usbManager.setCurrentFunction(UsbManager.USB_FUNCTION_MTP);
usbManager. setUsbDataUnlocked (true);
因为O上api变了,所以直接调用会编译不过。因为我这边android studio的project的compileSdkVersion是26,也就是android O。所以N的api只能用反射来做了。
其它注意点
代码里面我们需要添加一个标志位,保证只会在插入usb线的时候切换成mtp模式。如果没有mUsbModeInit这个标志位,我们在连接usb的情况下,手动切换到充电模式也会自己跳到mtp模式,这显然不是我们想要的。
实时文件扫描:Mtp模式触发全盘扫描
思路:
跟上文一样,我们监听usb state的变化,当判断切换到mtp模式的时候,触发一次全盘扫描。可能会有人疑惑是否需要等扫描结束后再切换到mtp模式在电脑上显示出设备的文件系统,实测不用。
其实网上比较通用的扫描文件的api一个是发送广播
public void scanPath(String path) {
mIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
mIntent.setData(Uri.fromFile(new File(path)));
mContext.sendBroadcast(mIntent);
}
一个是用MediaScannerConnection
public void mediaScan(File file) {
MediaScannerConnection.scanFile(this,
new String[]{file.getAbsolutePath()}, null,
new MediaScannerConnection.OnScanCompletedListener() {
@Override
public void onScanCompleted(String path, Uri uri) {
Log.v("MediaScanWork", "file " + path
+ " was scanned seccessfully: " + uri);
}
});
}
但是这两个方式都只能对一个文件或者多个文件进行扫描。如果想要对指定目录扫描或storage/emulated/0进行扫描就做不到了,除非咱们遍历所有文件去扫描。我们的优先目标肯定是最好有现成的简洁的api来调。
网上还有提到ACTION_MEDIA_MOUNTED,这个action在android O里面可谓名存实亡,并没有在源码里面找到相关的收这个广播的实际处理代码。
而ACTION_MEDIA_SCANNER_SCAN_DIR,在高版本的源码里面根本没这个action。
所以我们只能另辟蹊径,很偶然的机会,我在测试的过程中发现设备上自带的文件管理器(高通平台)创建的文件夹可以马上同步到电脑上,而我们使用File类的mkdir方法创建的文件夹却不能马上同步到电脑上。
追了下文件管理器的源码:
packages\apps\CMFileManager\src\com\cyanogenmod\filemanager\util\CommandHelper.java
createDirectory方法中有doMediaScan(context),把这个方法移到我们的代码中,果然生效了。
代码:
private boolean mUsbModeInit=true;
IntentFilter filter = new IntentFilter();
filter.addAction("android.hardware.usb.action.USB_STATE");
registerReceiver(mReceiver, filter, null, null);
private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals("android.hardware.usb.action.USB_STATE")){
boolean connected = intent.getExtras().getBoolean("connected");
boolean configured = intent.getExtras().getBoolean("configured");
boolean unlocked = intent.getExtras().getBoolean("unlocked");
if(!connected&&!configured){
mUsbModeInit=true;
}
if (connected&&configured&&unlocked&&mUsbModeInit){
doMediaScan(mContext);
mUsbModeInit=false;
}
}
}
public void doMediaScan(Context context) {
Bundle args = new Bundle();
args.putString("volume", "external");
Intent startScan = new Intent();
startScan.putExtras(args);
startScan.setComponent(new ComponentName("com.android.providers.media",
"com.android.providers.media.MediaScannerService"));
context.startService(startScan);
}
分析总结:
源码分析:
我们去追一下doMediaScan这个方法中的MediaScannerService具体是怎么进行全盘扫描的。
packages\providers\MediaProvider\src\com\android\providers\media\MediaScannerService.java
public int onStartCommand(Intent intent, int flags, int startId) {
while (mServiceHandler == null) {
synchronized (this) {
try {
wait(100);
} catch (InterruptedException e) {
}
}
}
if (intent == null) {
Log.e(TAG, "Intent is null in onStartCommand: ",
new NullPointerException());
return Service.START_NOT_STICKY;
}
Message msg = mServiceHandler.obtainMessage();
msg.arg1 = startId;
msg.obj = intent.getExtras();
mServiceHandler.sendMessage(msg);
// Try again later if we are killed before we can finish scanning.
return Service.START_REDELIVER_INTENT;
}
onStartCommand里面把传进来的intent拿到,拿到intent的数据包装成一个Message,然后丢到消息队列里面去
private final class ServiceHandler extends Handler {
@Override
public void handleMessage(Message msg) {
…..
else if (MediaProvider.EXTERNAL_VOLUME.equals(volume)) {
// scan external storage volumes
if (getSystemService(UserManager.class).isDemoUser()) {
directories = ArrayUtils.appendElement(String.class,
mExternalStoragePaths,
Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
} else {
directories = mExternalStoragePaths;
}
}
if (directories != null) {
if (false) Log.d(TAG, "start scanning volume " + volume + ": "
+ Arrays.toString(directories));
scan(directories, volume);
if (false) Log.d(TAG, "done scanning volume " + volume);
}
…
}
这里有兴趣的人,可以去改下log,然后可以看到scan的过程的耗时。
核心是scan方法
private void scan(String[] directories, String volumeName) {
Uri uri = Uri.parse("file://" + directories[0]);
// don't sleep while scanning
mWakeLock.acquire();
try {
ContentValues values = new ContentValues();
values.put(MediaStore.MEDIA_SCANNER_VOLUME, volumeName);
Uri scanUri = getContentResolver().insert(MediaStore.getMediaScannerUri(), values);
sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_STARTED, uri));
try {
if (volumeName.equals(MediaProvider.EXTERNAL_VOLUME)) {
openDatabase(volumeName);
}
try (MediaScanner scanner = new MediaScanner(this, volumeName)) {
scanner.scanDirectories(directories);
}
} catch (Exception e) {
Log.e(TAG, "exception in MediaScanner.scan()", e);
}
getContentResolver().delete(scanUri, null, null);
} finally {
sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_FINISHED, uri));
mWakeLock.release();
}
}
Scan方法里面还持有了wakelock。原来是调用的MediaScanner对象的scanDirectories方法。MediaScanner类在以下路径
frameworks/base/media/java/android/media/MediaScanner.java
到这里,我们就暂时不用追了也真相大白了。
实时文件扫描:全盘监听
思路:
使用FileObserver监听storage/emulated/0这个目录,当有文件create或者delete,就触发一次扫描该文件。
代码:
private RecursiveFileObserver mRecursiveFileObserver;
public void onCreate() {
mRecursiveFileObserver=new RecursiveFileObserver(FILE_OBSERVER_DIR,FileObserver.CREATE | FileObserver.DELETE,this);
mRecursiveFileObserver.startWatching();
}
public void onDestroy() {
mRecursiveFileObserver.stopWatching();
}
package com.honeywell.ezservice.utils;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Bundle;
import android.os.FileObserver;
import android.util.ArrayMap;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import java.util.Iterator;
import java.util.Map;
import java.util.Stack;
public class RecursiveFileObserver extends FileObserver {
Map<String, SingleFileObserver> mObservers;
String mPath;
int mMask;
Context mContext;
Intent mIntent;
public RecursiveFileObserver(String path, Context context) {
this(path, ALL_EVENTS, context);
}
public RecursiveFileObserver(String path, int mask, Context context) {
super(path, mask);
mPath = path;
mMask = mask;
mContext = context.getApplicationContext();
}
public void scanPath(String path) {
mIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
mIntent.setData(Uri.fromFile(new File(path)));
mContext.sendBroadcast(mIntent);
}
public void scanEmptyFolder(final Context context, File targetFile) {
final File dummy = new File(targetFile, "init");
try {
dummy.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
MediaScannerConnection.scanFile(context, new String[]{dummy.getAbsolutePath()}, null, new MediaScannerConnection.OnScanCompletedListener() {
@Override
public void onScanCompleted(String s, Uri uri) {
// delete file and scan again (because file should not be displayed)
dummy.delete();
MediaScannerConnection.scanFile(context, new String[]{dummy.getAbsolutePath()}, null, null);
}
});
}
@Override
public void startWatching() {
if (mObservers != null)
return;
mObservers = new ArrayMap<>();
Stack stack = new Stack();
stack.push(mPath);
while (!stack.isEmpty()) {
String temp = (String) stack.pop();
mObservers.put(temp, new SingleFileObserver(temp, mMask));
File path = new File(temp);
File[] files = path.listFiles();
if (null == files)
continue;
for (File f : files) {
// 递归监听目录
if (f.isDirectory() && !f.getName().equals(".") && !f.getName()
.equals("..")) {
stack.push(f.getAbsolutePath());
}
}
}
Iterator<String> iterator = mObservers.keySet().iterator();
while (iterator.hasNext()) {
String key = iterator.next();
mObservers.get(key).startWatching();
}
}
@Override
public void stopWatching() {
if (mObservers == null)
return;
Iterator<String> iterator = mObservers.keySet().iterator();
while (iterator.hasNext()) {
String key = iterator.next();
mObservers.get(key).stopWatching();
}
mObservers.clear();
mObservers = null;
}
@Override
public void onEvent(int event, String path) {
int el = event & FileObserver.ALL_EVENTS;
switch (el) {
case FileObserver.ATTRIB:
Log.i("RecursiveFileObserver", "ATTRIB: " + path);
break;
case FileObserver.CREATE:
File file = new File(path);
if (file.isDirectory()) {
Stack stack = new Stack();
stack.push(path);
while (!stack.isEmpty()) {
String temp = (String) stack.pop();
if (mObservers.containsKey(temp)) {
continue;
} else {
SingleFileObserver sfo = new SingleFileObserver(temp, mMask);
sfo.startWatching();
mObservers.put(temp, sfo);
}
File tempPath = new File(temp);
File[] files = tempPath.listFiles();
if (null == files)
continue;
for (File f : files) {
// 递归监听目录
if (f.isDirectory() && !f.getName().equals(".") && !f.getName()
.equals("..")) {
stack.push(f.getAbsolutePath());
}
}
}
}
/*
分几种情况
1.已监听目录创建文件
2.创建未监听目录
3.创建未监听目录并同时创建文件(代码创建很快,这时候监听目录晚了,文件创建会监听不到)
*/
//potter add
Log.i("RecursiveFileObserver", "CREATE: " + path);
//case 1 begin
if (file.isFile()) {
scanPath(path);
}
//case 1 end
//如果是用代码创建的一个长目录带文件,而且目录不存在,这时候监听的时候,文件已经创建结束了,就会导致没有监听到,所以得遍历了scan一次
if (file.isDirectory()) {
//case 3 begin
File[] files = file.listFiles();
for (File f : files) {
scanPath(f.getAbsolutePath());
}
//case 3 end
//case 2 begin
if (files.length == 0) {
scanEmptyFolder(mContext, file);
}
//case 2 end
}
//potter end
break;
case FileObserver.DELETE:
Log.i("RecursiveFileObserver", "DELETE: " + path);
break;
case FileObserver.DELETE_SELF:
Log.i("RecursiveFileObserver", "DELETE_SELF: " + path);
break;
case FileObserver.MODIFY:
Log.i("RecursiveFileObserver", "MODIFY: " + path);
break;
case FileObserver.MOVE_SELF:
Log.i("RecursiveFileObserver", "MOVE_SELF: " + path);
break;
case FileObserver.MOVED_FROM:
Log.i("RecursiveFileObserver", "MOVED_FROM: " + path);
break;
case FileObserver.MOVED_TO:
Log.i("RecursiveFileObserver", "MOVED_TO: " + path);
break;
}
}
class SingleFileObserver extends FileObserver {
String mPath;
public SingleFileObserver(String path) {
this(path, ALL_EVENTS);
mPath = path;
}
public SingleFileObserver(String path, int mask) {
super(path, mask);
mPath = path;
}
@Override
public void onEvent(int event, String path) {
if (path != null) {
String newPath = mPath + "/" + path;
RecursiveFileObserver.this.onEvent(event, newPath);
}
}
}
}
分析总结:
代码里还是有一些需要关注的点的。
一个是FileObserver进行监听的时候,只能监听指定目录,它的子目录是监听不到了。
这里参考了https://www.jianshu.com/p/65fb687d3458,它的思路是对已有目录迭代进行监听,并对新建的目录也进行了监听。
然后我们是前面提到的ACTION_MEDIA_SCANNER_SCAN_FILE发广播或者MediaScannerConnection来做扫描,这两种方法都只是针对文件,对于文件夹达不到效果的,即使你传入的参数是一个文件夹,你连接电脑后看到的却是一个同名的文件。
举个例子:
Case1:已有目录,创建a1文件。传入a1文件路径,可以成功扫描。
Case2:创建新的目录folder2,传入folder2路径,电脑上识别的是一个叫folder2的文件,而不是文件夹。
Case3:创建新的目录folder1,新目录里面创建a2文件,传入b文件路径,可以成功扫描。Folder也能成功识别。
针对这三种情况,代码里面进行了处理和备注。
Case1,直接发广播或者调用MediaScannerConnection就ok。
Case2,我们采用创建一个文件,扫描结束后再删除的方式。这里参考了https://stackoverflow.com/questions/32637993/android-scanfile-on-empty-directory
Case3,这里要分两个情况,如果是手动在文件管理类的apk里面新增目录,然后目录里面添加文件是能够监听到这个文件的create的。但是如果是代码创建的目录和文件,会出现目录创建ok,文件创建ok,这时候新的目录的监听才加上,导致新增的文件的create没监听到。所以我们在发现新目录后就做下遍历操作来扫描下就可以解决这个bug。
总结
对于默认mtp模式,没什么好说的。用上面提到的方法即可。
而实时文件扫描的两种方式:
有一些区别,mtp模式触发全盘扫描就稍微路子正一点,因为已知的有的文件管理器就是采用的doMediaScan来做的。
而全盘监听呢,路子有点野,理论上是优于mtp模式触发的,因为理论上采用全盘监听一个文件只会在创建的时候扫描一次,而mtp模式触发则可能多次扫描这个文件。但是监听的开销又不好评估了,毕竟现在用来测试的机器并没有太多迭代的目录,文件个数也不算多。
全盘监听还比mtp模式触发有一个优势就是连接电脑并且已经是mtp模式下,如果这时候创建删除文件,全盘监听的方式是可以反映到电脑上去的,而mtp模式触发则做不到。
综上,我个人项目中使用的还是mtp模式触发的方式。
Ok,大功告成!希望对各位有用。