利用 Android 系統原生 API 實現分享功能(2)

亦默亦風發表於2018-05-07

在之前的一篇文章 利用 Android 系統原生 API 實現分享功能 中主要說了下實現流程,但具體實施起來其實還是有許多坑要面對。那這篇文章就是提供一個封裝好的 Share2 庫供大家參考。

GitHub 專案地址:Share2


看過上一篇文章的同學應該知道,要呼叫 Android 系統內建的分享功能,主要有三步流程:

  • 建立一個 Intent ,指定其 ActionIntent.ACTION_SEND,表示要建立一個傳送指定內容的隱式意圖。

  • 然後指定需要傳送的內容和型別,即設定分享的文字內容或檔案的 Uri ,以及宣告檔案的型別,便於支援該型別內容的應用開啟。

  • 最後向系統傳送隱式意圖,開啟系統分享選擇器,分享完成後收到結果返回。

更多相關內容請參考上一篇,這裡就不再重複贅述了。


知道大致的實現流程後,其實只要解決下面幾個問題後就可以具體實施了。

確定要分享的內容型別

分享的內容型別,這其實是直接決定了最終的實現形態。我們知道常見的使用場景中,是為了在應用間分享圖片和一些檔案,而對於那些只是分享文字的產品而言,兩者實現起來要考慮的問題完全不同。

所以為了解決這個問題,我們可以預先定好支援的分享內容型別,針對不同型別可以進行不同的處理。

@StringDef({ShareContentType.TEXT, ShareContentType.IMAGE, ShareContentType.AUDIO, ShareContentType.VIDEO, ShareContentType.File})
@Retention(RetentionPolicy.SOURCE)
@interface ShareContentType {
    /**
     * Share Text
     */
    final String TEXT = "text/plain";

    /**
     * Share Image
     */
    final String IMAGE = "image/*";

    /**
     * Share Audio
     */
    final String AUDIO = "audio/*";

    /**
     * Share Video
     */
    final String VIDEO = "video/*";

    /**
     * Share File
     */
    final String File = "*/*";
}`
複製程式碼

在 Share2 中,一共定義了 5 種類別的分享內容,基本能覆蓋常見的使用場景。在呼叫分享介面時可以直接指定內容型別,比如像文字、圖片、音視訊、以及其他各種型別檔案。

確定分享的內容來源

對於不同型別的內容,可能會有不同的來源。比如文字可能就只是一個字串物件。而對於分享圖片或其他檔案,我們通常需要一個 Uri 來標識一個資源。這其實就引出了在具體實施時的一個關鍵問題:如何獲取被分享檔案的 Uri,並且這個 Uri 可以被接收的應用處理?

再把這個問題進一步細化,轉化為需要解決的具體問題時就是:

  1. 如何獲取要分享內容檔案的 Uri
  2. 如何才能讓接收方也能夠根據 Uri 獲取到檔案?

要回答上面這些問題,我們先來看看分享檔案的來源。通常我們在應用中獲取一個檔案的具體方式有:

  • 使用者通過開啟檔案選擇器或圖片選擇器來獲取一個指定的檔案;
  • 使用者通過拍照或錄製音視訊來獲取一個媒體檔案;
  • 使用者通過下載或直接通過本地檔案路徑來獲取一個檔案。

那下面我們就按照獲取檔案來源把檔案的 Uri 劃分為下面幾種型別:

1. 系統返回的 Uri

常見場景:通過檔案選擇器獲取一個檔案的 Uri

  private static final int REQUEST_FILE_SELECT_CODE = 100;
  private @ShareContentType String fileType = ShareContentType. File;

  /**
   * 開啟檔案管理選擇檔案
   */
   private void openFileChooser() {
        Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
        intent.setType("*/*");
        intent.addCategory(Intent.CATEGORY_OPENABLE);

        try {
            startActivityForResult(Intent.createChooser(intent, "Choose File"), REQUEST_FILE_SELECT_CODE);
        } catch (Exception ex) {
            // not install file manager.
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, final Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == FILE_SELECT_CODE && resultCode == RESULT_OK) {
            // 獲取到的系統返回的 Uri
            Uri shareFileUrl = data.getData();
        }
    }
複製程式碼

通過這種方式獲取到的 Uri 是由系統 ContentProvider 返回的,在 Android 4.4 之前的版本和之後的版本有較大的區別,我們後面再說怎麼處理。只要先記住這種系統返回給我們的 Uri 就行了。

系統返回的檔案 Uri 中的一些常見樣式: content://com.android.providers.media.documents.. content://com.android.providers.downloads... content://media/external/images/media/... content://com.android.externalstorage.documents..

2. 自定義 FileProvider 返回的 Uri

常見場景:比如呼叫系統相機進行拍照或錄製音視訊,要傳入一個生成目標檔案的 Uri,從 Android 7.0 開始我們需要用到 FileProvider 來實現。

  private static final int REQUEST_FILE_SELECT_CODE = 100;
   /**
     * 開啟系統相機進行拍照
     */
    private void openSystemCamera() {
        //呼叫系統相機
        Intent takePhotoIntent = new Intent();
        takePhotoIntent.setAction(MediaStore.ACTION_IMAGE_CAPTURE);

        if (takePhotoIntent.resolveActivity(getPackageManager()) == null) {
            Toast.makeText(this, "當前系統沒有可用的相機應用", Toast.LENGTH_SHORT).show();
            return;
        }

        String fileName = "TEMP_" + System.currentTimeMillis() + ".jpg";
        File photoFile = new File(FileUtil.getPhotoCacheFolder(), fileName);

        // 7.0 和以上版本的系統要通過 FileProvider 建立一個 content 型別的 Uri
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            currentTakePhotoUri = FileProvider.getUriForFile(this, getPackageName() + ".fileProvider", photoFile);
            takePhotoIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION|);
        } else {
            currentTakePhotoUri = Uri.fromFile(photoFile);
        }

        //將拍照結果儲存至 outputFile 的Uri中,不保留在相簿中
        takePhotoIntent.putExtra(MediaStore.EXTRA_OUTPUT, currentTakePhotoUri);
        startActivityForResult(takePhotoIntent, TAKE_PHOTO_REQUEST_CODE);
    }

     // 呼叫系統相機進行拍照與上面通過檔案選擇器獲得檔案 uri 的方式類似
     // 在 onActivityResult 進行回撥處理,此時 Uri 是自定義 FileProvider 中指定的,注意與檔案選擇器獲取的系統返回 Uri 的區別。
複製程式碼

如果用到了 FileProvider 就要注意跟系統 ContentProvider 返回 Uri 的區別,比如我們在 Manifest 中對 FileProvider 配置 android:authorities="com.xx.xxx.fileProvider" 屬性,那這時系統返回的 Uri 格式就變成了:content://com.xx.xxx.fileProvider...,對於這種型別的 Uri 我們姑且叫自定義 FileProvider 返回的 Uri

3. 通過檔案的路徑獲取到的 Uri

這其實不能單獨作為一種檔案 Uri 型別,但這是很常見的一種呼叫場景,所以單獨拿出來進行說明。

我們呼叫 new File(String path) 時需要傳入指定的檔案路徑,這個絕對路徑通常是:/storage/emulated/0/... 這種樣式,那麼如何把一個檔案路徑變成一個檔案 Uri 的形式?要回答這個問題,其實就需要對分享檔案進行處理。

分享檔案 Uri 的處理

處理訪問許可權

前面提到了檔案 Uri 的三種來源,對應不同型別處理方式也不同,不然你最先遇到的問題就是:

java.lang.SecurityException: Uid xxx does not have permission to uri 0 @ content://com.android.providers...
複製程式碼

這是由於對系統返回的 Uri 缺失訪問許可權導致,所以要對應用進行臨時訪問 Uri 的授權才行,不然會提示許可權缺失。

對於要分享系統返回的 Uri 我們可以這樣進行處理:

// 1. 可以對發起分享的 Intent 新增臨時訪問授權
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

// 2. 也可以這樣:由於不知道終端使用者會選擇哪個app,所以授予所有應用臨時訪問許可權
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
    List<ResolveInfo> resInfoList = activity.getPackageManager().queryIntentActivities(shareIntent, PackageManager.MATCH_DEFAULT_ONLY);
    for (ResolveInfo resolveInfo : resInfoList) {
        String packageName = resolveInfo.activityInfo.packageName;
        activity.grantUriPermission(packageName, shareFileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
    }
}
複製程式碼

處理 FileProvider 返回 Uri

需要注意的是對於自定義 FileProvider 返回 Uri 的處理,即使是設定臨時訪問許可權,但是分享到第三方應用也會無法識別該 Uri

典型的場景就是,我們如果把自定義 FileProvider 的返回的 Uri 設定分享到微信或 QQ 之類的第三方應用時提示檔案不存在,這是因為他們無法識別該 Uri

關於這個問題的處理其實跟下面要說的把檔案路徑變成系統返回的 Uri 一樣,我們只需要把自定義 FileProvider 返回的 Uri 變成第三方應用可以識別系統返回的 Uri 就行了。

建立 FileProvider 時需要傳入一個 File 物件,所以直接可以知道檔案路徑,那就把問題都轉換成了:如何通過檔案路徑獲取系統返回的 Uri

通過檔案路徑獲取系統返回的 Uri

對於 Android 7.0 以下版本的系統,要回答這個問題很簡單:

Uri uri = Uri.fromFile(file);
複製程式碼

但在 Android 7.0 及以上系統處理起來就要繁瑣許多,下面就來說說如何在不同系統版本下的進行適配。下面的 getFileUri 方法實現了通過傳入的 File 物件和型別來查詢系統 ContentProvider 的方式獲取相應的檔案 Uri

   public static Uri getFileUri (Context context, @ShareContentType String shareContentType, File file){

        if (context == null) {
            Log.e(TAG,"getFileUri current activity is null.");
            return null;
        }

        if (file == null || !file.exists()) {
            Log.e(TAG,"getFileUri file is null or not exists.");
            return null;
        }

        Uri uri = null;
        
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
            uri = Uri.fromFile(file);
        } else {

            if (TextUtils.isEmpty(shareContentType)) {
                shareContentType = "*/*";
            }

            switch (shareContentType) {
                case ShareContentType.IMAGE :
                    uri = getImageContentUri(context, file);
                    break;
                case ShareContentType.VIDEO :
                    uri = getVideoContentUri(context, file);
                    break;
                case ShareContentType.AUDIO :
                    uri = getAudioContentUri(context, file);
                    break;
                case ShareContentType.File :
                    uri = getFileContentUri(context, file);
                    break;
                default: break;
            }
        }
        
        if (uri == null) {
            uri = forceGetFileUri(file);
        }
        
        return uri;
    }


    private static Uri getFileContentUri(Context context, File file) {
        String volumeName = "external";
        String filePath = file.getAbsolutePath();
        String[] projection = new String[]{MediaStore.Files.FileColumns._ID};
        Uri uri = null;

        Cursor cursor = context.getContentResolver().query(MediaStore.Files.getContentUri(volumeName), projection,
                MediaStore.Images.Media.DATA + "=? ", new String[] { filePath }, null);
        if (cursor != null) {
            if (cursor.moveToFirst()) {
                int id = cursor.getInt(cursor.getColumnIndex(MediaStore.Files.FileColumns._ID));
                uri = MediaStore.Files.getContentUri(volumeName, id);
            }
            cursor.close();
        }

        return uri;
    }

    private static Uri getImageContentUri(Context context, File imageFile) {
        String filePath = imageFile.getAbsolutePath();
        Cursor cursor = context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                new String[] { MediaStore.Images.Media._ID }, MediaStore.Images.Media.DATA + "=? ",
                new String[] { filePath }, null);
        Uri uri = null;

        if (cursor != null) {
            if (cursor.moveToFirst()) {
                int id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID));
                Uri baseUri = Uri.parse("content://media/external/images/media");
                uri = Uri.withAppendedPath(baseUri, "" + id);
            }
            
            cursor.close();
        }
        
        if (uri == null) {
            ContentValues values = new ContentValues();
            values.put(MediaStore.Images.Media.DATA, filePath);
            uri = context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
        }

        return uri;
    }

    private static Uri getVideoContentUri(Context context, File videoFile) {
        Uri uri = null;
        String filePath = videoFile.getAbsolutePath();
        Cursor cursor = context.getContentResolver().query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
                new String[] { MediaStore.Video.Media._ID }, MediaStore.Video.Media.DATA + "=? ",
                new String[] { filePath }, null);
        
        if (cursor != null) { 
            if (cursor.moveToFirst()) { 
                int id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID));
                Uri baseUri = Uri.parse("content://media/external/video/media");
                uri = Uri.withAppendedPath(baseUri, "" + id);
            }
            
            cursor.close();
        } 
        
        if (uri == null) {
            ContentValues values = new ContentValues();
            values.put(MediaStore.Video.Media.DATA, filePath);
            uri = context.getContentResolver().insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
        }
        
        return uri;
    }


    private static Uri getAudioContentUri(Context context, File audioFile) {
        Uri uri = null;
        String filePath = audioFile.getAbsolutePath();
        Cursor cursor = context.getContentResolver().query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
                new String[] { MediaStore.Audio.Media._ID }, MediaStore.Audio.Media.DATA + "=? ",
                new String[] { filePath }, null);
        
        if (cursor != null) {
            if (cursor.moveToFirst()) {
                int id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID));
                Uri baseUri = Uri.parse("content://media/external/audio/media");
                uri = Uri.withAppendedPath(baseUri, "" + id);
            }
            
            cursor.close();
        }
        if (uri == null) {
            ContentValues values = new ContentValues();
            values.put(MediaStore.Audio.Media.DATA, filePath);
            uri = context.getContentResolver().insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, values);
        } 
        
        return uri;
    }

    private static Uri forceGetFileUri(File shareFile) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            try {
                @SuppressLint("PrivateApi")
                Method rMethod = StrictMode.class.getDeclaredMethod("disableDeathOnFileUriExposure");
                rMethod.invoke(null);
            } catch (Exception e) {
                Log.e(TAG, Log.getStackTraceString(e));
            }
        }

        return Uri.parse("file://" + shareFile.getAbsolutePath());
    }
複製程式碼

其中 forceGetFileUri 方法是通過反射實現的,Android 7.0 開始不允許 file:// Uri 的方式在不同的 App 間共享檔案,但是如果換成 FileProvider 的方式依然是無效的,我們可以通過反射把該檢測幹掉。

通過 File Path 轉成 Uri 的方式,我們最終統一了呼叫系統分享時傳入內容 Uri 的三種不同場景,最終全部轉換為傳遞系統返回的 Uri,讓第三方應用能夠正常的獲取到分享內容。

最終實現

Share2 按照上述方法進行了具體實施,可以通過下面的方式進行整合:

// 新增依賴
compile 'gdut.bsx:share2:0.9.0'
複製程式碼

根據 FilePath 獲取 Uri

 public Uri getShareFileUri() {
       return FileUtil.getFileUri(this, ShareContentType.FILE, new File(filePath));;
 }
複製程式碼

分享文字

new Share2.Builder(this)
    .setContentType(ShareContentType.TEXT)
    .setTextContent("This is a test message.")
    .setTitle("Share Text")
    .build()
    .shareBySystem();
複製程式碼

分享圖片

new Share2.Builder(this)
    .setContentType(ShareContentType.IMAGE)
    .setShareFileUri(getShareFileUri())
    .setTitle("Share Image")
    .build()
    .shareBySystem();
複製程式碼

分享圖片到指定介面,比如分享到微信朋友圈

new Share2.Builder(this)
    .setContentType(ShareContentType.IMAGE)
    .setShareFileUri(getShareFileUri())
    .setShareToComponent("com.tencent.mm", "com.tencent.mm.ui.tools.ShareToTimeLineUI")
    .setTitle("Share Image To WeChat")
    .build()
    .shareBySystem();
複製程式碼

分享檔案

new Share2.Builder(this)
    .setContentType(ShareContentType.FILE)
    .setShareFileUri(getShareFileUri())
    .setTitle("Share File")
    .build()
    .shareBySystem();
複製程式碼

最終效果

GitHub 專案地址:Share2

利用 Android 系統原生 API 實現分享功能(2)

相關文章