Android 儲存圖片及 6.0 以上的權限授權機制 (Android save image and Marshmallow Requesting Permissions at Run Time )

Permissions demo

Android 對於權限的使用授權,在 6.0 Marshmallow (棉花糖) 之後做了一些更動,除了必須在 AndroidManifest.xml 中做設定,某些權限還必須在執行時取得使用者的授權。這篇文章以儲存圖片做為範例,說明新的授權機制如何使用。

關於權限及授權

在 Android 6.0 之前,要使用權限,只要在 AndroidManifest.xml 中加入權限的請求,例如:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
這樣就可以讓 App 擁有可以寫入檔案的權限。可是這樣有個問題,就是使用者無法對單一項目允許或禁止授權,使用者在安裝 App 時就必須全部接受這些權限的請求,否則便無法安裝。

新的授權機制就是為了解決這個問題。首先你仍必須在 AndroidManifest.xml 中指定要使用的權限,當使用者安裝了該 App 後,如果某項權限是屬於危除層級的,那該權限在此時並沒有真的被授權,你可以開啟裝置的設定->應用程式->進入該App來查看,如下圖: 點選「權限」項目後會看到: 灰色表示該權限還未被使用者允許。

使用者可以在這裡將權限打開,或是在 App 中,當它執行到需要該權限時,App 才發出請求授權的要求,如下圖:

一般權限與危險權限

App 執行時才請求授權的機制並不是全部的權限都需要,因此權限分為兩級,一般權限只要在 AndroidManifest.xml 中指定即可,危險權限才須要額外在需要時請求授權。

危險權限大都和存取使用者資料有關,像是讀寫檔案、讀取電話狀態等等,可以在 Dangerous Permissions 查看,一般權限在 Normal Permissions

在請求危險權限時要做確認的動作,如果已經取得授權,下次執行時就可以直接執行該功能,不必做重覆請求授權的動作。

範例:儲存圖片

指定使用權限

這個範例會儲存圖片到裝置中,所以要有寫入檔案的權限,在 AndroidManifest.xml 加入存取檔案的使用權限:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

Layout

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    android:orientation="vertical"
    tools:context="com.tonycube.demo.permissionsdemo.MainActivity">

    <ImageView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/imgPicture"
        android:src="@drawable/coding"
        android:scaleType="fitCenter"
        android:adjustViewBounds="true"/>

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Save Picture"
        android:id="@+id/btnSavePicture"
        />

</LinearLayout>
只有 ImageView 和 Button 兩個元件,當按下按鈕時,將 ImageView 中的圖片儲存到裝罝中。看起來像這樣:

MainActivity.java

public class MainActivity extends AppCompatActivity {

    private ImageView imgPicture;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initView();
    }

    private void initView() {
        imgPicture = (ImageView)findViewById(R.id.imgPicture);
        //
        Button btnSavePicture = (Button)findViewById(R.id.btnSavePicture);
        btnSavePicture.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                doSavePicture();
            }
        });
    }

    private void doSavePicture() {
        if (saveToPictureFolder()) {
            Toast.makeText(MainActivity.this, "儲存成功", Toast.LENGTH_SHORT).show();
        } else {
            Toast.makeText(MainActivity.this, "儲存失敗", Toast.LENGTH_SHORT).show();
        }
    }

    /**
     * 儲存到圖片庫
     */
    private boolean saveToPictureFolder() {
        //取得 Pictures 目錄
        File picDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
        Log.d(">>>", "Pictures Folder path: " + picDir.getAbsolutePath());
        //假如有該目錄
        if (picDir.exists()) {
            //儲存圖片
            File pic = new File(picDir, "pic.jpg");
            imgPicture.setDrawingCacheEnabled(true);
            imgPicture.setDrawingCacheQuality(View.DRAWING_CACHE_QUALITY_AUTO);
            Bitmap bmp = imgPicture.getDrawingCache();
            return saveBitmap(bmp, pic);
        }
        return false;
    }

    private boolean saveBitmap(Bitmap bmp, File pic) {
        if (bmp == null || pic == null) return false;
        //
        FileOutputStream out = null;
        try {
            out = new FileOutputStream(pic);
            bmp.compress(Bitmap.CompressFormat.JPEG, 80, out);
            out.flush();

            scanGallery(this, pic);
            Log.d(">>>", "bmp path: " + pic.getAbsolutePath());
            return true;
        } catch (Exception e) {
            Log.e(">>>", "save bitmap failed!");
            e.printStackTrace();
        } finally {
            try {
                if (out != null) {
                    out.close();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return false;
    }

    private void scanGallery(Context ctx, File file) {
        Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
        Uri contentUri = Uri.fromFile(file);
        mediaScanIntent.setData(contentUri);
        ctx.sendBroadcast(mediaScanIntent);
    }
}
當按下按鈕後,會先將 ImageView 中的圖片轉存成 Bitmap,接著取得預設的圖片庫目錄,然後就是把 Bitmap 輸出到圖片庫中成為圖檔。scanGallery() 是要求圖片庫做重新掃描的動作,不然即使儲存成功了,在圖片庫中也看不到。

以上的功能在 Android 6.0 (API 23) 之前的裝置上是可以正常運作的,但是在 Android 6.0 之後的裝置上就會失敗。接下來就是要處理請求授權的動作。

範例:請求授權

在 MainActivity.java 中加入處理授權的部份後就是整個完整的程式碼:
public class MainActivity extends AppCompatActivity {

    private final String PERMISSION_WRITE_STORAGE = "android.permission.WRITE_EXTERNAL_STORAGE";
    private ImageView imgPicture;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initView();
    }

    private void initView() {
        imgPicture = (ImageView)findViewById(R.id.imgPicture);
        //
        Button btnSavePicture = (Button)findViewById(R.id.btnSavePicture);
        btnSavePicture.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                if (!hasPermission()) {
                    if (needCheckPermission()) {
                        //如果須要檢查權限,由於這個步驟要等待使用者確認,
                        //所以不能立即執行儲存的動作,
                        //必須在 onRequestPermissionsResult 回應中才執行
                        return;
                    }
                }

                doSavePicture();
            }
        });
    }

    private void doSavePicture() {
        if (saveToPictureFolder()) {
            Toast.makeText(MainActivity.this, "儲存成功", Toast.LENGTH_SHORT).show();
        } else {
            Toast.makeText(MainActivity.this, "儲存失敗", Toast.LENGTH_SHORT).show();
        }
    }

    /**
     * 儲存到圖片庫
     */
    private boolean saveToPictureFolder() {
        //取得 Pictures 目錄
        File picDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
        Log.d(">>>", "Pictures Folder path: " + picDir.getAbsolutePath());
        //假如有該目錄
        if (picDir.exists()) {
            //儲存圖片
            File pic = new File(picDir, "pic.jpg");
            imgPicture.setDrawingCacheEnabled(true);
            imgPicture.setDrawingCacheQuality(View.DRAWING_CACHE_QUALITY_AUTO);
            Bitmap bmp = imgPicture.getDrawingCache();
            return saveBitmap(bmp, pic);
        }
        return false;
    }

    private boolean saveBitmap(Bitmap bmp, File pic) {
        if (bmp == null || pic == null) return false;
        //
        FileOutputStream out = null;
        try {
            out = new FileOutputStream(pic);
            bmp.compress(Bitmap.CompressFormat.JPEG, 80, out);
            out.flush();

            scanGallery(this, pic);
            Log.d(">>>", "bmp path: " + pic.getAbsolutePath());
            return true;
        } catch (Exception e) {
            Log.e(">>>", "save bitmap failed!");
            e.printStackTrace();
        } finally {
            try {
                if (out != null) {
                    out.close();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return false;
    }

    private void scanGallery(Context ctx, File file) {
        Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
        Uri contentUri = Uri.fromFile(file);
        mediaScanIntent.setData(contentUri);
        ctx.sendBroadcast(mediaScanIntent);
    }

    ////////////////////////////////////////////////
    /**
     * 確認是否要請求權限(API > 23)
     * API < 23 一律不用詢問權限
     */
    private boolean needCheckPermission() {
        //MarshMallow(API-23)之後要在 Runtime 詢問權限
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            String[] perms = {PERMISSION_WRITE_STORAGE};
            int permsRequestCode = 200;
            requestPermissions(perms, permsRequestCode);
            return true;
        }

        return false;
    }

    /**
     * 是否已經請求過該權限
     * API < 23 一律回傳 true
     */
    private boolean hasPermission(){
        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
            return(ActivityCompat.checkSelfPermission(this, PERMISSION_WRITE_STORAGE) == PackageManager.PERMISSION_GRANTED);
        }

        return true;
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == 200){
            if (grantResults.length > 0) {
                if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    Log.d(">>>", "取得授權,可以執行動作了");
                    doSavePicture();
                }
            }
        }
    }

}

needCheckPermission()

用來檢查是否需要請求授權,因為只需對 API >= 23 的裝置做動作,所以小於這個版本的裝罝全部忽略。

因為這個請求授權的動作是非同步的,要等使用者「允許」後才能繼續,所以如果我們發出 requestPermissions() 的請求時,就要把之後的 doSavePicture() 忽略,這裡我直接 return。

hasPermission()

這是用來檢查該項權限是否已經被授權允許 (GRANT),如果使用者已經允許,那什麼都不用做,直接執行 doSavePicture() 。

和前一個方法一樣,如果裝罝小於 API 23,就沒有允不允許的問題,一律視同允許。

onRequestPermissionsResult()

這個方法是覆寫我們繼承 AppCompatActivity 時所擁有的方法,用來接收使用者對授權請求的回應。如果結果是允許使用該權限,就可以直接執行 doSavePicture() 了。

參考資料

本文網址:https://blog.tonycube.com/2016/07/android-60-android-save-image-and.html
Tony Blog 撰寫,請勿全文複製,轉載時請註明出處及連結,謝謝 😀

3 則留言

  1. 大讚!!!
    API target版本 25 檔案都讀不到
    看到這篇文 總算有了改善
    辛苦了 謝謝整理C":

    回覆刪除
  2. APP開啟第一次儲存圖片都是會空白,之後才儲存正常,甚麼原因?

    回覆刪除
    回覆
    1. 看看儲存圖片時,資料是否有真的拿到,檢查 bitmap 物件是不是 null。

      刪除

留言小提醒:
1.回覆時間通常在晚上,如果太忙可能要等幾天。
2.請先瀏覽一下其他人的留言,也許有人問過同樣的問題。
3.程式碼請先將它編碼後再貼上。(線上編碼:http://bit.ly/1DL6yog)
4.文字請加上標點符號及斷行,難以閱讀者恕難回覆。
5.感謝您的留言,您的問題也可能幫助到其他有相同問題的人。