炊きたてのご飯が食べたい

定時に帰れるっていいね。自宅勤務できるっていいね。子どもと炊きたてのご飯が食べられる。アクトインディでは積極的にエンジニアを募集中です。

Glide のメソッドをお借りして画像の向き(orientation)を取得する


アクトインディ Advent Calendar 2016 16日目の記事になります。子どもとおでかけ情報アプリ「いこーよ」は、マップ上で簡単におでかけ先を探せる検索アプリです。記事を見てくれたパパさん、ママさん、ぜひ一度使ってみてください^^

iko-yo.net

Kotlin 1.0.5
minSdkVersion 19
targetSdkVersion 25

本日の記事は Android アプリでギャラリーやカメラから取得した画像をサーバーにアップロードする際に必要となる、画像の向きの情報について書きたいと思います。Intent 経由で起動したカメラの Uri から bitmap を取得して Retrofit を使って画像のアップロードを行なったところ、デバイスを縦にして撮影した画像が横向きでアップロードされるといった問題に出くわしました。あー、画像の向きを考慮して bitmap を生成しないといけないのかぁ、と気づいて、まぁ Uri に画像の向きを取得するメソッドありそうだから探してみるかー、と簡単な気持ちで臨んだのが全ての誤りでした。最終的には Glide の public メソッドを利用させてもらうといった暴挙に出たのですが、それに至った経緯を記事に残しておこうと思います。

  1. ExifInterface(uri.path) で orientation の値が常に 0 が返却されてしまう
  2. contentResolver を利用して filePath を取得しようとすると READ_EXTERNAL_STORAGE パーミッションが必要となる
  3. contentResolver を利用して filePath を取得するが、ギャラリーとカメラで違う実装が必要になる
  4. contentResolver を利用して filePath を取得するが、ギャラリーのダウンロードフォルダから選択した画像はまた別の処理になる
  5. 独自に実装を諦めて Glide さんのお力を借りることを決意する

f:id:t-namikata:20161215231353p:plain

Glide のメソッドを借りるまでの経緯を紹介すると長くなってしまうので、最終的な画像の orientation を取得する実装を先に紹介しておきます。以下のように実装しました。

  • UriExtension.kt
fun Uri.getOrientationWithGradle(): Int {
    val byteArrayPool = ByteArrayPool.get()
    byteArrayPool.bytes
    val byteForStream = byteArrayPool.bytes
    val contentResolver = SampleApplication.instance.contentResolver
    val inputStream = contentResolver.openInputStream(this)
    val bufferedStream = RecyclableBufferedInputStream(inputStream, byteForStream)
    val exceptionStream = ExceptionCatchingInputStream.obtain(bufferedStream)
    val orientation = ImageHeaderParser(exceptionStream).orientation
    inputStream.close()
    return orientation
}
  • SampleApplication.kt
class SampleApplication : Application() {
    companion object {
        lateinit var instance: SampleApplication
            private set
    }

    init {
        instance = this
    }

    override fun onCreate() {
        super.onCreate()
    }
}

ギャラリーとカメラは以下のように Intent 経由で起動しています。

  • ギャラリーの起動
fun onGalleryClick() {
    val intent = Intent()
    intent.type = "image/*"
    intent.action = Intent.ACTION_OPEN_DOCUMENT
    intent.addCategory(Intent.CATEGORY_OPENABLE)
    this.startActivityForResult(intent, 0)
}
  • カメラの起動

AndroidManifest で <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> が必要

fun onCameraClick() {
    this.cameraUri = CameraUtility.cameraUri(activity = this)
    val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    intent.putExtra(MediaStore.EXTRA_OUTPUT, this.cameraUri)
    this.startActivityForResult(intent, 1)
}

class CameraUtility {
    companion object {
        fun cameraUri(activity: Context): Uri {
            val imageName = "${System.currentTimeMillis()}.jpg"
            val contentValues = ContentValues()
            contentValues.put(MediaStore.Images.Media.TITLE, imageName)
            contentValues.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
            return activity.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
        }
    }
}
  • ギャラリーとカメラの Uri の取得
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    when (requestCode) {
        0 -> {
            data ?: return
            if (resultCode != Activity.RESULT_OK) return
            val uri = data.data
            val orientation = uri.getOrientationWithGradle() // 画像の向きによって Int が返却される
        }
        1 -> {
            if (resultCode != Activity.RESULT_OK) return
            val uri = this.cameraUri ?: return
            val orientation = uri.getOrientationWithGradle() // 画像の向きによって Int が返却される
        }
    }
}

事の経緯

問題1

ExifInterface(uri.path) で orientation の値が常に 0 が返却されてしまう、というのが始まりでした。画像の向きの取得方法を調べてみると、uri.path で filePath が取得できるから ExifInterface 使って Exif 情報をゲットできるといった情報があったので、ギャラリーから選択した画像の uri を取得して以下のように実装してみました。

val uri = data.data // data.data はギャラリー経由で取得した画像の Uri
val exif = ExifInterface(uri.path)
val orientation = Integer.parseInt(exifInterface.getAttribute(ExifInterface.TAG_ORIENTATION))

ところが、画像の向きが 90 度であっても常に orientation の値が 0 になってしまう問題が起きました。色々と調べてみると端末によりそのような事象になるようで、別の方法が StackOverFlow などで紹介されていたので試してみることにしました。

問題2

contentResolver を利用して filePath を取得しようとすると READ_EXTERNAL_STORAGE パーミッションが必要となる

別の方法というのが contentResolver を利用して画像の情報を DB に問い合わせ、uri.path では取得できない実際のストレージパスを取得して Exif 情報を取ってくるといった方法でした。

val uri = data.data // data.data はギャラリー経由で取得した画像の Uri

val contentResolver = SampleApplication.instance.contentResolver
val wholeId = DocumentsContract.getDocumentId(uri)
val id = if (wholeId.contains(":")) {
    wholeId.split(":")[1]
} else {
    wholeId
}
val sel = MediaStore.Images.Media._ID + "=?"
val columns = arrayOf(MediaStore.Images.Media.DATA)

// EXTERNAL_CONTENT_URI にアクセスする為には READ_EXTERNAL_STRAGE の許可が必要
val cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, columns, sel, arrayOf(id), null)
var filePath: String? = null
if (cursor.moveToFirst()) {
    val columnIndex = cursor.getColumnIndex(columns[0])
    if (columnIndex != -1) {
        filePath = cursor.getString(columnIndex)
    }
}
cursor.close()
filePath?.let {
    val exif = ExifInterface(it)
    val orientation = Integer.parseInt(exifInterface.getAttribute(ExifInterface.TAG_ORIENTATION))
}

この方法で、確かにギャラリー経由で取得した画像の Uri を元に、画像の向き情報である orientation の値が取得できました(画像の向きが 90 度であれば 6 の数字を得られる)。しかし、この実装の問題点として EXTERNAL_CONTENT_URI にアクセスする為には READ_EXTERNAL_STORAGE パーミッションが必要になることが分かりました。画像の表示には Glide を利用していたのですが Glide にギャラリーから取得した uri を渡してあげると READ_EXTERNAL_STORAGE のパーミッションなしに、画像の向き情報を考慮して表示ができています。カメラで撮影した画像の uri を取得するのに WRITE_EXTERNAL_STORAGE のパーミッションを取得していたので、気持ち悪いけど、まぁ良しとしようかなぁと思いながら、カメラのテストをしていた時に、さらなる問題が発生しました。

問題3

contentResolver を利用して filePath を取得するが、ギャラリーとカメラで違う実装が必要になる

カメラで撮影した画像の Uri を this.cameraUri = CameraUtility.cameraUri(activity = this) として実装しているのですが cameraUri を元に getDocumentId で id を取得しようとすると Exception が発生するといった問題でした。

this.cameraUri = CameraUtility.cameraUri(activity = this)
val contentResolver = SampleApplication.instance.contentResolver
val wholeId = DocumentsContract.getDocumentId(uri) // Exception が発生

色々と試したところ、カメラで撮影した画像の orientation は以下で取得できることが分かりました。

val uri = this.cameraUri // カメラで撮影した画像の Uri

val contentResolver = SampleApplication.instance.contentResolver
var filePath: String? = null
val cursor = contentResolver.query(uri, null, null, null, null)
if (cursor.moveToFirst()) {
    if (cursor.moveToFirst()) {
        val index = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
        if (index != -1) {
            filePath = cursor.getString(index)
        }
    }
}
cursor.close()
filePath?.let {
    val exif = ExifInterface(it)
    val orientation = Integer.parseInt(exifInterface.getAttribute(ExifInterface.TAG_ORIENTATION))
}

動作はするものの、この時点で Android の filePath はどのような構成になっているかが分からなくなってきました。ギャラリーとカメラで orientation の取得方法が異なる時点で、大分やられていましたが、トドメは、ギャラリーのダウンロードフォルダから画像を選択した場合に orientation が取得できないといった問題でした。

問題4

ギャラリー経由で取得した画像の Uri を元に orientation の値を取得する以下の実装ですが、ギャラリーのダウンロードフォルダから画像を選択した場合 cursor.moveToFirst() が false になるといった問題が起きました。 DocumentsContract.getDocumentId(uri) で画像の ID はきちんと取得できているのですが上手く行きません。

val uri = data.data // data.data はギャラリーのダウンロードフォルダにある画像の Uri

val contentResolver = SampleApplication.instance.contentResolver
val wholeId = DocumentsContract.getDocumentId(uri)
val id = if (wholeId.contains(":")) {
    wholeId.split(":")[1]
} else {
    wholeId
}
val sel = MediaStore.Images.Media._ID + "=?"
val columns = arrayOf(MediaStore.Images.Media.DATA)

val cursor = contentResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, columns, sel, arrayOf(id), null)
var filePath: String? = null
if (cursor.moveToFirst()) { // false になる

色々と試して見た結果、ダウンロードフォルダから選択した画像は以下の実装で orientation を取得できることが分かりました。

val uri = data.data // data.data はギャラリーのダウンロードフォルダにある画像の Uri
val id = DocumentsContract.getDocumentId(uri)

val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), id.toLong())
val contentResolver = SampleApplication.instance.contentResolver
val columns = arrayOf(MediaStore.Images.Media.DATA)
var filePath: String? = null
val cursor = contentResolver.query(contentUri, columns, null, null, null)
if (cursor.moveToFirst()) {
    val index = cursor.getColumnIndex(columns[0])
    filepath = cursor.getString(index)
}
cursor.close()
filePath?.let {
    val exif = ExifInterface(it)
    val orientation = Integer.parseInt(exifInterface.getAttribute(ExifInterface.TAG_ORIENTATION))
}

もう何がなんだか分からなくなりました。画像の向き情報が欲しいだけなのに、考慮することが多すぎて、ライブラリに依存する形になってしまいますが Glide さんのお力を借りようと決意しました。

Glide を利用した orientation を取得する実装

Glide にギャラリーから取得できる Uri を渡したところ、画像の向き情報を取得するメソッドは ImageHeaderParser(exceptionStream).orientation だということが分かりました。実行されるメソッドを順に辿り uri の inputStream を渡してあげることで orientation が無事に取得できるようになりました。

fun Uri.getOrientationWithGradle(): Int {
    val byteArrayPool = ByteArrayPool.get()
    byteArrayPool.bytes
    val byteForStream = byteArrayPool.bytes
    val contentResolver = SampleApplication.instance.contentResolver
    val inputStream = contentResolver.openInputStream(this)
    val bufferedStream = RecyclableBufferedInputStream(inputStream, byteForStream)
    val exceptionStream = ExceptionCatchingInputStream.obtain(bufferedStream)
    val orientation = ImageHeaderParser(exceptionStream).orientation
    inputStream.close()
    return orientation
}

Glide の orientation を取得する実装を見たのですが、理解力が足りなく良く分かりませんでした。今後の課題として実装の内容は少しずつ紐解いて行きたいと思います。