1. 程式人生 > >Android爬坑之旅之FileProvider(Failed to find configured root that contains)

Android爬坑之旅之FileProvider(Failed to find configured root that contains)

最近在測試FileProvider相關功能的時候,在從自定義相簿選擇圖片通過FileProvider來獲取content uri的時候程式突然崩潰了,報出了

Failed to find configured root that contains xxxx

的錯誤,一開始以為是自己的配置出錯了,但是參照官方文件改來改去仍然沒有任何作用,通過絞盡腦汁地排查,終於發現了錯誤原因,並找到了正確的解決方案,在瞭解最終的解決方案之前我們先對FileProvider做個簡單的瞭解和回顧。

FileProvider簡介

很久之前就知道FileProvider了,然而之前做過的實際專案中卻很少用到,一方面自己的專案沒有涉及到相關的場景,一方面也是自己對檔案安全沒有太在意,雖然看官方文件的時候多次讀過,卻從來沒想到去使用它。

但隨著Android 7.0的到來,為了進一步提高私有檔案的安全性,Android不再由開發者放寬私有檔案的訪問許可權,之前我們一直使用”file:///”絕對路徑來傳遞檔案地址的方式,在接收方訪問時很容易觸發SecurityException的異常。

因此,為了更好的適配Android 7.0,例如相機拍照這類涉及到檔案地址傳遞的地方就用上了FileProvider,FileProvider也更好地進入了大家的視野。

其實FileProvider是ContentProvider的一個特殊子類,本質上還是基於ContentProvider的實現,FileProvider會把”file:///”的路徑轉換為特定的”content://”形式的content uri,接收方通過這個uri再使用ContentResolver去媒體庫查詢解析。

FileProvider使用方法:

  1. 在AndroidManifest.xml裡宣告Provider
<manifes xmlns:android="http://schemas.android.com/apk/res/android"    package="com.example.myapp"> 
      <application        
          ...>  
          <provider     
              android:name="android.support.v4.content.FileProvider"
//指向v4包裡的FileProvider類 android:authorities="com.example.myapp.fileprovider" //對應你的content uri的基礎域名,生成的uri將以content://com.example.myapp.fileprovider作為開頭 android:grantUriPermissions="true" //設定允許獲取訪問檔案的臨時許可權 android:exported="false"//設定不允許匯出,我們的FileProvider應該是私有的 > <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/filepaths" //用於設定FileProvider的檔案訪問路徑 /> </provider> ... </application> </manifest>

2.配置FileProvider檔案共享的路徑

接下來在我們的res目錄下建立一個xml目錄,在xml目錄下新建一個filepaths.xml,這個xml的名字可以根據專案的具體情況來取,對應第一步中mainifest配置裡的FileProvider路徑的配置中指定的檔案

<paths xmlns:android="http://schemas.android.com/apk/res/android"> 
    <files-path name="my_images" path="images/"/> 
    ...
</paths>

在標籤中我們必須配置至少一個或多個path子元素,
path子元素則用來定義content uri所對應的路徑目錄。
這裡以為例:
* name屬性:指明瞭FileProvider在content uri中需要新增的部分,這裡的name為my_images,所以對應的content uri為

content://com.example.myapp.fileprovider/my_images
  • path屬性:標籤對應的路徑地址為Context.getFilesDir()]()返回的路徑地址,而path屬性的值則是該路徑的子路徑,這裡的path值為”images/”,那組合起來的路徑如下所示:
Content.getFilesDir() + "/images/"
  • name屬性跟path屬性一一對應,根據上面的配置,當訪問到檔案
content://com.example.myapp.fileprovider/my_images/xxx.jpg

就會找到這裡配置的path路徑

Content.getFilesDir() + "/images/"

並查詢xxx.jpg檔案

對應路徑的配置,官方文件列出瞭如下配置項:

<files-path name="*name*" path="*path*" />
<cache-path name="*name*" path="*path*" />
<external-path name="*name*" path="*path*" />
<external-files-path name="*name*" path="*path*" />
<external-cache-path name="*name*" path="*path*" />

以此類推,我們可以根據自己所需共享的目錄來配置不同的path路徑.

3.配置完共享地址後,獲取content uri的值

File imagePath = new File(Context.getFilesDir(), "images");
File newFile = new File(imagePath, "default_image.jpg");
Uri contentUri = getUriForFile(getContext(), "com.mydomain.fileprovider", newFile);

4.授予一個uri的臨時許可權,並將值傳給接收方app
我們假設接收方app使用startActivityForResult來請求app的圖片資源,
則請求方獲取請求後根據上面的程式碼獲取

Intent intent = new Intent()
intent.setDataAndType(fileUri,getContentResolver().getType(fileUri));
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
setResult(intent);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

5.接收方在onActivityResult里根據獲取的content uri來查詢資料

Uri returnUri = returnIntent.getData();    
Cursor returnCursor =  getContentResolver().query(returnUri, null, null, null, null);    
int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);   
int sizeIndex = returnCursor.getColumnIndex(OpenableColumns.SIZE);
returnCursor.moveToFirst();    
TextView nameView = (TextView) findViewById(R.id.filename_text); 
TextView sizeView = (TextView) findViewById(R.id.filesize_text); 
nameView.setText(returnCursor.getString(nameIndex)); 
sizeView.setText(Long.toString(returnCursor.getLong(sizeIndex)));

這下我們FileProvider的整個使用流程就完整了

FileProvider的坑(Failed to find configured root that contains)

failed to find configured root that contains

經過對FileProvider的進一步瞭解和鞏固,終於到了揭開錯誤原因的時候了

原來手上這臺紅米手機的相簿選擇了儲存位置在外接SD卡上,因此部分相簿照片的存放地址實際上是在外接sd卡上的。
下面是我的path配置

<paths xmlns:android="http://schemas.android.com/apk/res/android>
    <external-path name="external_files" path="."/>
</paths>

乍一看貌似沒什麼問題,path設定的是external的根路徑,對應Environment.getExternalStorageDirectory()
然而這個方法所獲取的只是內建SD卡的路徑,所以當選擇的相簿中的圖片是外接SD卡的時候,就查詢不到圖片地址了,因此便丟擲了failed to find configured root that contains的錯誤。

那怎麼解決呢,官方文件給出的配置項並沒有能夠對應外部地址的,那我們只有從FileProvider的原始碼入手看看有沒有什麼辦法了.

因為FileProvider是通過path的配置來限制路徑範圍的,因此我們可以通過path的解析作為入口點來進行分析,
於是乎便找到了parsePathStrategy()這個方法,
它就是專門用來解析我們的path路徑的xml檔案的.

private static final String TAG_ROOT_PATH = "root-path";
private static final String TAG_FILES_PATH = "files-path";
private static final String TAG_CACHE_PATH = "cache-path";
private static final String TAG_EXTERNAL = "external-path";

private static final File DEVICE_ROOT = new File("/");

private static PathStrategy parsePathStrategy(Context context, String authority)        
          throws IOException, XmlPullParserException {    
       ...
      while ((type = in.next()) != END_DOCUMENT) {        
          if (type == START_TAG) {            
              //<external-path>等根標籤
              final String tag = in.getName();
              //標籤中的name屬性
              final String name = in.getAttributeValue(null, ATTR_NAME);
              //標籤中的path屬性  
              String path = in.getAttributeValue(null, ATTR_PATH);            
              File target = null;            
              //標籤對比
              if (TAG_ROOT_PATH.equals(tag)) {                
                  target = buildPath(DEVICE_ROOT, path);            
              } else if (TAG_FILES_PATH.equals(tag)) {
                  target = buildPath(context.getFilesDir(), path);            
              } else if (TAG_CACHE_PATH.equals(tag)) {                
                  target = buildPath(context.getCacheDir(), path);            
              } else if (TAG_EXTERNAL.equals(tag)) {                
                  target = buildPath(Environment.getExternalStorageDirectory(), path);            
            }            
          if (target != null) {                
              strat.addRoot(name, target);            
          }        
    }    
}   
 return strat;
}


private static File buildPath(File base, String... segments) {    
    File cur = base;    //根檔案
    for (String segment : segments) {        
        if (segment != null) {            
            //建立以cur為根檔案,segment為子目錄的檔案
            cur = new File(cur, segment);       
        }    
    }    
    return cur;
}

這裡我把一些關鍵程式碼摘抄了出來,通過這些關鍵程式碼我們可以看到,
在xml解析到對應的標籤後,會執行 buildPath() 方法來將根標籤(等)對應的路徑作為檔案根路徑,
同時將標籤中的path屬性所指定的路徑作為子路徑建立對應的File物件,最終生成了一個cur檔案物件來作為目標檔案,從而限制FileProvider的檔案訪問路徑位於cur的path路徑下。

其中一段程式碼讓我發現了一絲異樣

 if (TAG_ROOT_PATH.equals(tag)) {                
        target = buildPath(DEVICE_ROOT, path);            
 }

TAG_ROOT_PATH對應了常量”root-path”,
而DEVICE_ROOT則對應了new File(“/”);
沒錯,雖然官方文件沒有寫出來,但是程式碼裡竟然留了個的根標籤,而它的路徑對應的是DEVICE_ROOT指向的整個儲存的根路徑。

於是根據程式碼的邏輯,將我們的路徑的配置檔案做了調整,
改成了

<paths xmlns:android="http://schemas.android.com/apk/res/android>
      <root-path name="name" path="" />
</paths>

這時執行程式,會發現FileProvider已經不會報錯了,因為檔案的搜尋路徑已經變成了我們客戶端的整個根路徑,就這樣,我們的問題終於得到了順利地解決!

通過對FileProvider的原始碼分析,正如開頭所說的,FileProvider繼承自ContentProvider,因此還有一種方法就是繼承ContentProvider然後自定義Provider來處理,不過FileProvider本身的程式碼寫得已經很完善了,所以一般沒有這個必要,這裡我給出了一段簡易程式碼僅做參考:
同樣在AndroidManifest裡配置我們的provider

<provider    
      android:name="org.test.img.MyProvider"//指向自定義的Provider
      android:authorities="com.test.img" 
/>

然後自定義一個MyProvider

public class MyProvider extends ContentProvider {    

    private String mAuthority;

    @Override
    public void attachInfo(Context context, ProviderInfo info) {    super.attachInfo(context, info);    // Sanity check our security    
        if (info.exported) {        
            throw new SecurityException("Provider must not be exported");    
        }    
        if (!info.grantUriPermissions) {        
            throw new SecurityException("Provider must grant uri permissions");    
        }    
        mAuthority = info.authority;
    }

    @Override    
    public ParcelFileDescriptor openFile(Uri uri, String mode)  throws FileNotFoundException {        
      File file = new File(uri.getPath());        
      ParcelFileDescriptor parcel = ParcelFileDescriptor.open(file,ParcelFileDescriptor.MODE_READ_ONLY);       
       return parcel;    
    }    

    @Override    
    public boolean onCreate() {        
      return true;    
    }   

    @Override    
    public int delete(Uri uri, String s, String[] as) {        
      throw new UnsupportedOperationException("Not supported by this provider");   
     }    

    @Override    
    public String getType(Uri uri) {        
          throw new UnsupportedOperationException("Not supported by this provider");    
    }

    @Override    
        public Uri insert(Uri uri, ContentValues contentvalues) {        
          throw new UnsupportedOperationException("Not supported by this provider");    
    }    

    @Override    
    public Cursor query(Uri uri, String[] as, String s, String[] as1, String s1) {        throw new UnsupportedOperationException("Not supported by this provider");   
     }    

    @Override    
    public int update(Uri uri, ContentValues contentvalues, String s, String[] as) {        
        throw new UnsupportedOperationException("Not supported by this provider");    
    }

    public static Uri getUriForFile(File file) {    
        return new Uri.Builder().scheme("content").authority(mAuthority).encodedPath(file.getPath).build();
    }
}

這樣我們的程式碼就可以通過自定義的getUriForFile來獲取content uri了