Android

Android 画像ファイルを扱う際のFileとUriまとめ

More than 1 year has passed since last update.

概要

Android アプリ開発をしていると、しばしば画像ファイルを扱う場面が訪れます。
私が製作している Android アプリ SobaCha においても、画像ファイルを Twitter へ投稿する為の機能が存在しています。この画像投稿機能を改修している際に、Android の持つ画像ファイルの独特な扱い方と対面しました。
そこで私がつまづいてしまった点である FileUri の関係をまとめ、後々調べる方のお役に立てればと思い書いてみました。

要約

画像ファイルはなるべく画像データが必要になる瞬間まで Uri で扱い、最後に Uri から InputStream を開いて Bitmap を取得しましょう。途中でファイルのパスを得ようとしてはいけません。

Android における画像ファイル

Android において、画像ファイルを扱う際に使用するクラスは 2 種類存在します。

  • File
  • Uri

画像データを扱う場合は、これらのインスタンスから InputStream を取得し、画像を Bitmap として取り出します。

File とは

java.io.File という名称の通り、ファイルを取り扱う Java の標準的なクラスです。
Android では内部ストレージや microSD 上のファイルを指定できます。
File インスタンスのメソッドを使用すると、ファイルの絶対パスやファイルサイズの取得が可能です。

Uri とは

android.net.Uri という名称の通り、URI 形式の表記を取り扱う為に Android 用に作成されたクラスです。

URI は主に スキーム、オーソリティ、パス の 3 要素から構成されます。
画像ファイルの URI には、file:// スキームから始まる URI と、content:// スキームから始まる URI の 2 種類が存在します。

file:// スキーム

  • file:///storage/emulated/0/Pictures/1460293547698.jpg
    • スキーム: file://
    • オーソリティ:
    • パス: /storage/emulated/0/Pictures/1460293547698.jpg

file:// スキームの URI は、ファイルのパスを直接表現しています。

content:// スキーム

  • content://media/external/images/media/33612
    • スキーム: content://
    • オーソリティ: media
    • パス: /external/images/media/33612

Android には ContentProvider という、SQLite データベースに格納されている情報を他のアプリと共有する仕組みがあります。ContentProvider を利用して格納された情報は ContentResolver を使用すると取り出す事が出来ます。

また、Android には画像や音楽ファイルなどのメディア情報を収集する MediaScanner という仕組みがあり、収集した情報は ContentProvider を利用した MediaStore というデータベースに格納されます。MediaStore に格納されたメディア情報も、ContentResolver 経由で取り出す事が可能です。

この ContentResolver を使用してメディア情報を取り出す際に、content:// スキームの URI で表記された Uri を使用します。

content:// スキームの URI を用いると、内部ストレージに限らず、Google ドライブなどのクラウド上の画像を取り出す事も可能です。

相互変換

FileUri

可能です。ただし生成した URI は外部への共有が制限される場合があります。
URI が共有可能かについては targetSdkVersion に依存します。

Android 6.0 まで

Uri クラスのメソッド fromFile(File) を使用します。

File file = new File(path);
Uri uri = Uri.fromFile(file);

uri には file:// スキームの URI が格納されます。

Android 7.0 以上

Android 7.0 以上の環境でも、fromFile(File) メソッドで file:// スキームの Uri を生成する事が可能です。

ただし、Android 7.0 以降向けにビルドしたアプリでは、上記の手法で生成した URI を他のアプリへと共有する事は出来ません。具体的には、アプリの targetSdkVersion24 以上の場合、 file:// スキームの URI を Intent で共有すると FileUriExposedException が発生します。

詳細は Android 7.0 の動作の変更点 | Android Developers を参照してください。

簡単に解説すると、

  • Android 7.0 以降向けのアプリでは、アプリデータの漏洩を防ぐために、プライベート ディレクトリのアクセス権限を厳しくした
  • file:// スキームの URI を使用すると、任意のパスを指定することが可能
  • すなわち file:// スキームの URI では、上記のプライベート ディレクトリのような、受け取った側がアクセスできないディレクトリを指定してしまう可能性がある
  • そのため、Android 7.0 以降向けのアプリでは file:// URI の共有を禁止した

という経緯です。

共有の制限を解決する方法として、FileProvider クラスのメソッド getUriForFile(Context, String, File) を使用し、content:// スキームの URI を発行する方法があります。この手法では

  • Context.getFilesDir()
  • Context.getCacheDir()
  • Environment.getExternalStorageDirectory()

のディレクトリに保存されているファイルであれば、File から Uri を生成し、外部へと共有する事が出来ます。

ここでは例として、Context.getFilesDir() 内の document/share_file を対象に、共有可能な Uri を生成します。

まず、共有するディレクトリの情報を paths.xml に指定します。

res/xml/paths.xml
<?xml version="1.0" encoding="utf-8"?>
<paths>
    <files-path name="document" path="document/" />
</paths>

ここで、共有するディレクトリと XML で使用するタグの間には、次の関係が存在します。

  • Context.getFilesDir() ... <files-path> タグで指定
  • Context.getCacheDir() ... <cache-path> タグで指定
  • Environment.getExternalStorageDirectory() ... <external-path> タグで指定

次に、AndroidManifest.xml<application> 内に、FileProvider に関する情報を記述します。

AndroidManifest.xml
<provider
    android:name="android.support.v4.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/paths"/>
</provider>

最後に、getUriForFile メソッドを使用して、File から Uri を生成します。

File documentDir = new File(context.getFilesDir() + "/document");
File shareFile = new File(documentDir + "/share_file");
Uri uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", shareFile);

上記ディレクトリ一覧以外に存在するファイルを共有するには、一度 Context.getCacheDir() で取得したキャッシュ ディレクトリに外部のファイルをコピーします。コピーしたファイルを getUriForFile メソッドに指定し、content:// スキームの URI を生成した後に外部のアプリへと共有します。

上記の実装は、以下の Web サイトを参考にさせて頂きました。

UriFile

不可能な場合があります。
File へ変換可能かは URI に依存します。

file:// スキーム

スキームが file:// の場合は、URI のパスがそのままファイルの位置を指しています。
そこで UrigetPath メソッドを使用してパスを取得し、得られたパスを元に File インスタンスを生成します。

File file = new File(uri.getPath())

content:// スキーム

スキームが content:// の場合は、URI のパスからファイルの位置を直接得る事はできません。

この時、URI の形式が

  • content://media/external/images/media/コンテンツID

の場合は、MediaStore に対し問い合わせを行い、ファイルパスを文字列として得ます。

String[] projection = {MediaStore.MediaColumns.DATA};
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
if (cursor != null) {
    String path = null;
    if (cursor.moveToFirst()) {
        path = cursor.getString(0);
    }
    cursor.close();
    if (path != null) {
        File file = new File(path);
    }
}

ただし、同じ content:// スキームを持つ URI であっても

  • content://com.android.providers.media.documents/document/コンテンツID …… システムの画像選択ウィンドウ
  • content://com.android.providers.downloads.documents/document/コンテンツID …… ダウンロード
  • content://com.google.android.apps.docs.storage/document/コンテンツID …… Google ドライブ
  • content://com.dropbox.android.FileCache/filecache/コンテンツID …… Dropbox

のような形式の URI である場合は、上記の手法でファイルパスを取得する事は出来ません。

上記の URI のうち、画像選択ウィンドウとダウンロードの 2 項目については、ファイルパスを取得する手法が StackOverflow に掲載されています。
一方、Google ドライブや Dropbox の URI からファイルパスを取得する方法はありません。

そのため、ファイルパスが取得出来る前提で Uri を取り扱うのは避けるべきです。

InputStreamBitmap の取得

FileInputStream

File を渡して FileInputStream を生成します。

InputStream stream = new FileInputStream(file);

UriInputStream

ContentResolver インスタンスのメソッド openInputStream(Uri) を使用します。

InputStream stream = context.getContentResolver().openInputStream(uri);

InputStreamBitmap

InputStream から Bitmap を生成するには、BitmapFactory クラスのメソッド decodeStream(InputStream) を使用します。BufferedInputStream を間に挟むことで、読み取り効率が向上します。

実際には、生成する Bitmap のサイズを制限する為に BitmapFactory.Options を渡すべきですが、ビットマップのデコードについては他に詳しい記事が存在するため詳細は割愛します。

Bitmap bitmap = BitmapFactory.decodeStream(new BufferedInputStream(stream));

ファイル名の取得

File

File インスタンスのメソッド getName を使用します。

String name = file.getName();

Uri

file:// スキーム

Uri のパスを渡して File インスタンスを生成し、getName メソッドを呼び出します。

String name = new File(uri.getPath()).getName();

content:// スキーム

MediaStore 形式の URI の場合は MediaStore に問い合わせを行います。

String[] projection = {MediaStore.MediaColumns.DISPLAY_NAME};
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
if (cursor != null) {
    String name = null;
    if (cursor.moveToFirst()) {
        name = cursor.getString(0);
    }
    cursor.close();
}

ファイルサイズの取得

File

File インスタンスのメソッド length を使用します。

long size = file.length();

Uri

file:// スキーム

Uri のパスを渡して File インスタンスを生成し、length メソッドを呼び出します。

long size = new File(uri.getPath()).length();

content:// スキーム

MediaStore 形式の URI の場合は MediaStore に問い合わせを行います。

String[] projection = {MediaStore.MediaColumns.SIZE};
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null);
if (cursor != null) {
    long size = 0;
    if (cursor.moveToFirst()) {
        size = cursor.getLong(0);
    }
    cursor.close();
}

写真の向きの取得

カメラアプリで撮影した画像ファイルをアプリから利用する際、写真撮影時の端末の向きによっては、そのまま Bitmap を生成すると誤った向きの画像を生成してしまう恐れがあります。そこで、 JPEG ファイルの Exif 領域から写真の向きを取得し、Bitmap を正しい向きへと回転します。

File

ファイルの絶対パスを ExifInterface に渡し、インスタンスを生成します。
ExifInterface から TAG_ORIENTATION 属性を取得し、属性の値を元に画像の向きを決定します。

ExifInterface exif = new ExifInterface(file.getAbsolutePath());
int exifOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
int orientation = 0;
switch (exifOrientation) {
    case ExifInterface.ORIENTATION_ROTATE_90:
        orientation = 90;
        break;

    case ExifInterface.ORIENTATION_ROTATE_180:
        orientation = 180;
        break;

    case ExifInterface.ORIENTATION_ROTATE_270:
        orientation = 270;
        break;
}

Uri

Uri から写真の向きを得る手段は、以下の 2 種類が存在します。

  • MediaStore に問い合わせを行なう
  • InputStream から画像ファイルの Exif 領域を読み取る

前者は MediaStore に対し MediaStore.Images.ImageColumns.ORIENTATION 列を問い合わせる事で画像の向きの取得が可能です。ただし、システムの画像選択ウィンドウを始めとする MediaStore 形式以外の URI では、写真の向きが全て「 0° 」扱いにされてしまうため、あまり実用的な方法ではありません。

そこで、後者の手法について解説します。これまで、InputStream から Exif を取得する為の ExifInterface(InputStream) コンストラクタは API Level 24 すなわち Android 7.0 以降でのみ利用可能 でしたが、2017年 1月に ExifInterface Support Library が登場した為、Android 2.3 〜 6.0 の端末でも安心して InputStream から Exif の情報を得られるようになりました。

ExifInterface Support Library を使用する為に、build.gradle に依存ライブラリを追加します。バージョンは適宜最新の物に合わせてください。

compile 'com.android.support:exifinterface:25.1.1'

これで、ExifInterface(InputStream) コンストラクタの使用が可能になります。

ExifInterface exif = new ExifInterface(new BufferedInputStream(stream));
int exifOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL);
int orientation = 0;
switch (exifOrientation) {
    case ExifInterface.ORIENTATION_ROTATE_90:
        orientation = 90;
        break;

    case ExifInterface.ORIENTATION_ROTATE_180:
        orientation = 180;
        break;

    case ExifInterface.ORIENTATION_ROTATE_270:
        orientation = 270;
        break;
}

まとめ

Android では、幅広い方法で提供されている画像ファイルの情報を Uri で束ねているため、最初はかなり扱いに戸惑いますが

  • 画像ファイルは徹底して Uri で扱い、ファイルパスを取り出そうとしない
  • 画像データが必要になる直前で InputStream を取得する

事を心がけると、ContentProvider に振り回されずに済むと思います。
必要に応じて、InputStream から各種情報を得るサードパーティ ライブラリを使用すると良いでしょう。

検証用アプリ

記事を執筆するにあたり Uri の各種検証の為に作成したテストアプリを GitHub に公開しました。

https://github.com/wakamesoba98/UriAnalyzer

参考