背景
测试报了一个bug,说有些下载的文件(视频、音频)无法在Download中无法打开,文件管理器中可以打开,
并且下载应用的里文件对应图标显示不正确。
问题初步定位
从描述中可以确定,下载的文件可以在文件管理器中打开,说明文件本身没有问题,文件没有问题却打不开,
说明是"Downloads"这个程序的问题,出现这个问题一般是MimeType类型出错了,因为打开文件时文件类型是由MimeType决定的, 因此得先看看所打开文件的MimeType是否有问题。
代码分析
确定源码位置
在看代码之前,先说点别的东西,如果你是第一次改Download这种类型的bug,你第一步肯定是先要找到Download程序源码位置,这里有很多方法,比如在openGrok上搜索关键字符串,或者打开程序使用hierarchyviewer 这个工具看看包名,然后再去确定位置。
如果你打开Downloads这个程序,然后用hierarchyviewer 进行查看,你会发现你当前运行的Activity是一个名叫DocumentsActivity
的东东,好像跟Download没啥关系,然后你就能找到它的位置了,在framework/base/package/DocumentsUI
这个路径下,然后你就开始了漫长的代码之旅,然而,你看了很久,并没有发现任何关于下载的程序,既然是MimeType错误,MimeType肯定是在下载时生成的,然而都没找到下载的代码.于是你就很郁闷,是不是不是这个程序,找错位置了??,事实的确是找错源码位置了,各种折腾后,你终于找到正确的位置了,至于方法百度、Google最终都能找到,我当初是用下载程序的通知栏 "Download Complete"这个字符串去openGrok上搜索找到的。。。, 当然对Android源码非常熟悉的人一般都知道一些程序具体的位置, 有经验的人一般能很快找到.
Download这个程序正确的源码位置是 packages/providers/DownloadProvider/
这个路径下,这个DownloadProvider
其实是不提供任何UI界面的, 除了通知栏,这个是属于系统UI的.你在launcher上看到的Downloads这个程序其实只是进入另一个程序的入口,实际显示下载列表的是DocumentsUi这个程序,这个会在后面讲.
源码分析
在正式分析源代码之前,你可能想确认一下MimeType是否的确有问题,DownloadProvider中比较重要的类是DownloadService这个类,你可以在这个类中打印一下MimeType值,来确定是否是有问题, 我此次遇到的文件就是MimeType错了。我们要知道MimeType为什么会出错,首先要了解下载流程,MimeType是在哪个地方生成的,入手点肯定是从DownloadManager
这个类开始,因为DownloadProvider
是系统提供给其他应用使用的,而DownloadManager
则是DownloadProvider
和应用的媒介,我们通过调用DownloadManager
中提供的API来启动DownloadProvider
.
具体使用方法如下:
DownloadManager manager = (DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE);
DownloadManager.Request request = new DownloadManager.Request(uri);
request.setMimeType(mimeType);
manager.enqueue(request);
从上面代码可以看出,调用下载程序的应用可以设置mimeType, 然后我们再看DownloadManager中对应方法的代码:
frameworks/base/core/java/android/app/DownloadManager.java
public long enqueue(Request request) {
ContentValues values = request.toContentValues(mPackageName);
Uri downloadUri = mResolver.insert(Downloads.Impl.CONTENT_URI, values);
long id = Long.parseLong(downloadUri.getLastPathSegment());
return id;
}
/**
* Set the MIME content type of this download. This will override the content type declared
* in the server's response.
* @see <a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7">HTTP/1.1
* Media Types</a>
* @return this object
*/
public Request setMimeType(String mimeType) {
mMimeType = mimeType;
return this;
}
enqueue方法会将信息插入到Download数据库中,而setMimeType()
则会设置mMimeType
的值,这个值也会被插入到数据库,我们看一下setMimeType()
方法的注释: Set the MIME content type of this download. This will override the content type declared in the server's response.
这里也提前告诉我们还有一种生成mimeType的方法,从下载路径的服务器上获取MimeType,我们会在后面代码中看到这部分内容,enqueue()
方法是DownloadManager中提供启动一个下载的接口,调用这个方法后就能开始下载我们的文件了,源码中我们可以看到,enqueue方法中主要操作是将信息插入到数据库中,即imResolver.insert(Downloads.Impl.CONTENT_URI, values);
调用insert方法后,程序终于执行到DownloadProvider中了,在DownloadProvider这个程序文件夹下,我们可以找到一个名为DownloadProvider 的类
packages/providers/DownloadProvider/src/com/android/providers/downloads/DownloadProvider.java
/**
* Allows application to interact with the download manager.
*/
public final class DownloadProvider extends ContentProvider {
/** Database filename */
private static final String DB_NAME = "downloads.db";
......
从源码中我们可以看出,这个类继承自ContentProvider,并且注释也表明这个类是与DownloadManager进行交互的,DownloadManager中调用的insert()方法就是这ContentProvider的insert()
方法,我们来看看里面具体有什么:
......
// Always start service to handle notifications and/or scanning
final Context context = getContext();
context.startService(new Intent(context, DownloadService.class));
return ContentUris.withAppendedId(Downloads.Impl.CONTENT_URI, rowID);
}
DownloadProvider.java中insert()
方法代码较多,接近200行,其中大多数操作是将信息插入数据库,我这里值截取了最后几行代码,可以看到,在最后调用了context.startService(new Intent(context, DownloadService.class));
来启动DownloadService,从而开始下载文件, DownloadService中代码量不多,不过看起来比较乱,不容易读懂,大多是一些状态的判断,这里我们只关注主要流程, 在updateLocked()
方法中,我们可以看到如下代码:
......
// Kick off download task if ready
final boolean activeDownload = info.startDownloadIfReady(mExecutor);
// Kick off media scan if completed
final boolean activeScan = info.startScanIfReady(mScanner);
......
上面的info对象是DownloadInfo.java这个类的实例,我们到DownloadInfo.java中继续跟踪startDownloadIfReady()
这个方法,
packages/providers/DownloadProvider/src/com/android/providers/downloads/DownloadInfo.java
......
public boolean startDownloadIfReady(ExecutorService executor) {
synchronized (this) {
final boolean isReady = isReadyToDownload();
final boolean isActive = mSubmittedTask != null && !mSubmittedTask.isDone();
if (isReady && !isActive) {
if (mStatus != Impl.STATUS_RUNNING) {
mStatus = Impl.STATUS_RUNNING;
ContentValues values = new ContentValues();
values.put(Impl.COLUMN_STATUS, mStatus);
mContext.getContentResolver().update(getAllDownloadsUri(), values, null, null);
}
mTask = new DownloadThread(mContext, mSystemFacade, mNotifier, this);
mSubmittedTask = executor.submit(mTask);
}
return isReady;
}
}
......
可以看到,再这个方法中,启动了一个DownloadThread来从网络上下载文件,因为Service默认是运行在主线程的,下载这种耗时操作肯定要放到其他其他线程中执行,继续分析DownloadThread中代码,DownloadThread.java
中, 有个parseOkHeaders(HttpURLConnection conn)
方法,部分内容如下:
......
if (mInfoDelta.mMimeType == null) {
mInfoDelta.mMimeType = Intent.normalizeMimeType(conn.getContentType());
}
......
这里的mInfoDelta.mMimeType
值来源: DownloadManager通过调用DownloadProvider的insert方法,将其插入到数据库中,然后在DownloadService查询数据库,得到值后通过启动DownloadThread传递过来的,这个过程中,只有在DownloadManager中setMimeType方法中改变过其内容,也就是说mInfoDelta.mMimeType
是否为null
,取决于调用DownloadProvider的应用是否设置过MimeType,如果设置过MimeType,则不对其进行处理,使用设置的值,如果没有设置过,则通过conn.getContentType()
来从下载的服务器上获取MimeType值, 这样印证了之前setMimeType方法注释上写的内容,分析到这来,已经完全确定了MimeType是如何生成和哪些地方可能更改了MimeType.
寻找解决方法
找到了设置更改MimeType的地方,可以再次打log验证MimeType是否有问题,当然,最终确定是MimeType的问题,测试使用的是chrome下载文件的,这里MimeType类型出现错误,通过分析,是由于下载地址对应的服务器上关于MimeType类型是错误的,如果服务器上是正确的, 下载的文件就不会有问题,由于设置MimeType是系统提供的API,并且下载过程中,应用还可以通过API查询下载文件的MimeType等信息,我们是不能直接将MimeType值进行更改,只能在打开文件过程中,如果打开失败,则使用文件后缀名得到新的MimeType,然后再次进行打开,这是我想到一种解决方法,当然肯定有其他方法,接下来就讲如何在打开文件失败时,重新计算MimeType。
首先你的找到打开文件的代码,这个过程中还是有不少坑,这里就简单略过,只是从整体角度说明一下,前面提到过,我们在launcher上看到的Downloads程序只是进入DocumentsUI这个程序的入口,功能就是发送一个Intent打开DocumentsUI,我们看到的下载列表是DocumentsUi中的一种呈现形式,DocumentsUI这个程序比较奇特,具体有如下作用:
三个分别是 文件浏览,选择文件(第三方应用调用),显示下载
另外要说明的是 DownloadProvider中除了下载Service外,ui目录下包下还有个小程序,编译出来后为DownloadProviderUi.apk(packages/providers/DownloadProvider/ui/),也就是显示的Downloads程序,这个和下载的代码是分开的,他就只有两个功能,打开下载文件和显示下载列表,我们点击launcher的Downloads图标后,默认是启动DownloadList
这Activity(Android O上代码有改动, 已经没有这个类了),然后这个Activity只做了一件事,就是发送一个稍微特殊点的Intent,用来打开DocumentsUI,从而告诉DocumentsUI要显示下载列表,这样我们就可以在DocumentsUI中看到下载列表了,也就是说DownloadProvider本身不提供列表显示,交由其他应用来完成这件事。
看到下载列表后,我们点击列表中的某一项,然后流程是这样的:DocumentsUI中会发送一个Action为android.provider.action.MANAGE_DOCUMENT
的Intent,在DownloadProvider中ui包下的TrampolineActivity
会接受到这个Intent,然后根据MimeType,Uri来创建一个Intent来打开文件,
具体创建Intent的代码在DownloadProvider下OpenHelper.java
这个类的buildViewIntent()
方法中,因此我们只需修改此处代码.
解决问题
最终代码修改如下:
packages/providers/DownloadProvider/src/com/android/providers/downloads/OpenHelper.java
+++ b/LINUX/android/packages/providers/DownloadProvider/src/com/android/providers/downloads/OpenHelper.java
@@ -32,6 +32,7 @@ import android.database.Cursor;
import android.net.Uri;
import android.provider.Downloads.Impl.RequestHeaders;
import android.util.Log;
+import android.webkit.MimeTypeMap;
import java.io.File;
@@ -98,12 +99,27 @@ public class OpenHelper {
intent.setDataAndType(localUri, mimeType);
}
+ if (intent.resolveActivity(context.getPackageManager()) == null) {
+ intent.setDataAndType(localUri, getMimeTypeFromExtensionName(file.getPath()));
+ }
+
return intent;
} finally {
cursor.close();
}
}
+ private static String getMimeTypeFromExtensionName(String fileName) {
+ MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
+ if ((fileName != null) && (fileName.length() > 0)) {
+ int dot = fileName.lastIndexOf('.');
+ if ((dot >-1) && (dot < (fileName.length() - 1))) {
+ return mimeTypeMap.getMimeTypeFromExtension(fileName.substring(dot + 1));
+ }
+ }
+ return null;
+ }
+
增加一个通过文件名获取MimeType的方法,然后判断如果没有程序能打开文件,则重新设置MimeType和Uri
当然这个也不能保证文件类型是完全是正确的,但用户很容易接受,至此就差编译验证了,如果你发现你push apk 后发现修改没有效果,恭喜你,又踩到坑了,你要push的是DownloadProviderUi.apk,而不是DownloadProvider.apk, 编译后有两个apk,而你的修改是DownloadProviderUi这apk中用到的。
总结
以上是我在工作中解决一个bug的思路, Android版本为5.1, 由于不同Android版本代码会有差异, 仅作参考.