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

# 概要

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

# 要約

画像ファイルはなるべく画像データが必要になる瞬間まで `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 ドライブなどのクラウド上の画像を取り出す事も可能です。

# 相互変換

## `File` → `Uri`

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

### Android 6.0 まで

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

```java
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 を他のアプリへと共有する事は出来ません。具体的には、アプリの `targetSdkVersion` が `24` 以上の場合、 `file://` スキームの URI を `Intent` で共有すると `FileUriExposedException` が発生します。

詳細は [Android 7.0 の動作の変更点 | Android Developers](https://developer.android.com/about/versions/nougat/android-7.0-changes.html?hl=ja#sharing-files) を参照してください。

簡単に解説すると、

* 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` に指定します。

```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` に関する情報を記述します。

```xml: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` を生成します。

```java
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 サイトを参考にさせて頂きました。

* [FileProvider | Android Developers](https://developer.android.com/reference/android/support/v4/content/FileProvider.html)
* [Android 7.0 (API 24) への対応 ～(外部)ストレージ内ファイルの共有～ - きっとラボ](http://kit-lab.hatenablog.jp/entry/2017/01/08/011428)

## `Uri` → `File`

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

### `file://` スキーム

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

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

### `content://` スキーム

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

この時、URI の形式が

* `content://media/external/images/media/コンテンツID`

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

```java
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 に掲載されています。](http://stackoverflow.com/a/33930169) 
一方、Google ドライブや Dropbox の URI からファイルパスを取得する方法はありません。

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

# `InputStream` と `Bitmap` の取得

## `File` → `InputStream`

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

```java
InputStream stream = new FileInputStream(file);
```

## `Uri` → `InputStream`

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

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

## `InputStream` → `Bitmap`

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

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

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

# ファイル名の取得

## `File`

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

```java
String name = file.getName();
```

## `Uri`

### `file://` スキーム

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

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

### `content://` スキーム

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

```java
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` を使用します。

```java
long size = file.length();
```

## `Uri`


### `file://` スキーム

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

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

### `content://` スキーム

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

```java
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` 属性を取得し、属性の値を元に画像の向きを決定します。

```java
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 以降でのみ利用可能](https://developer.android.com/reference/android/media/ExifInterface.html#ExifInterface(java.io.InputStream)) でしたが、2017年 1月に [ExifInterface Support Library](https://developers-jp.googleblog.com/2017/01/introducing-the-exifinterface-support-library.html) が登場した為、Android 2.3 〜 6.0 の端末でも安心して `InputStream` から Exif の情報を得られるようになりました。

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

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

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

```java
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>

# 参考

* [The CommonsBlog - How to Consume Content From a Uri](https://commonsware.com/blog/2016/03/15/how-consume-content-uri.html)
* [FileProvider | Android Developers](https://developer.android.com/reference/android/support/v4/content/FileProvider.html)
* [Android 7.0 (API 24) への対応 ～(外部)ストレージ内ファイルの共有～ - きっとラボ](http://kit-lab.hatenablog.jp/entry/2017/01/08/011428)
* [Glide のメソッドをお借りして画像の向き（orientation）を取得する - 炊きたてのご飯が食べたい](http://blog.asobicocoro.com/entry/2016/12/16/094601)
* [Android: Bitmaps loaded from gallery are rotated in ImageView - Stack Overflow](http://stackoverflow.com/questions/3647993/android-bitmaps-loaded-from-gallery-are-rotated-in-imageview)
* [Android getting orientation from MediaStore Uri - Stack Overflow](http://stackoverflow.com/questions/33249025/android-getting-orientation-from-mediastore-uri)
* [android - cursor didn't have _data column not found - Stack Overflow](http://stackoverflow.com/questions/32661221/android-cursor-didnt-have-data-column-not-found)
* [Get real path from URI, Android KitKat new storage access framework - Stack Overflow](http://stackoverflow.com/questions/20067508/get-real-path-from-uri-android-kitkat-new-storage-access-framework/20559175#20559175)