読者です 読者をやめる 読者になる 読者になる

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

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

Android での二度押し防止施策

Android Kotlin 連続タップ 二度押し

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

iko-yo.net

Kotlin 1.0.5
minSdkVersion 19
targetSdkVersion 25

本日の記事は Android アプリでの二度押し防止施策について書きたいと思います。 API と通信を行うアプリを作成していると、保存や送信ボタンの連続タップ時の例外的な処理を考慮に入れる必要が出てくると思うので、連打防止施策のちょっとした tips を紹介したいと思います。 ProgressDialog を表示して、処理が完了したら Dialog を消すといった方法も考えましたが Dialog が表示されるまでのちょっとした間でも、タップ処理が連続して反応してしまう為、ボタンをタップしたら 0.5 秒間 isEnabled = false にして、ボタンのタップ処理を無効にする方法を採用しました。 ImageButton や Button は View を継承しているので View の Extension としてタップを禁止する処理を入れています。

  • ViewExtension.kt
/**
 * 二度押し防止施策として 0.5 秒間タップを禁止する
 */
fun View.notPressTwice() {
    this.isEnabled = false
    this.postDelayed({
        this.isEnabled = true
    }, 500L)
}

連続タップを禁止したいボタンの setOnClickListener で以下のように呼び出します。

postButton.setOnClickListener {
    postButton.notPressTwice()
    // 後続の処理を書く
}

iOS エンジニアが Android を開発する時のコンポーネントやウィジェットの比較表

iOS 比較 Android

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

iko-yo.net

今までは iOS のアプリ開発をしていたのですが Android アプリも作成することになり iOS のコンポーネントや各ウィジェットが Android で何に対応するのか最初に開発する時に知りたかったので、簡単なところだけですがまとめてみました。

kotlin

アプリケーションのライフサイクル

iOS Android
AppDelegate Application

アプリケーションのライフサイクルを管理するクラスです。プロジェクト作成時に iOS の場合は AppDelegate が作成されますが Android の場合は Application クラスを継承したサブクラスを自分で作成します。

class SampleApplication : Application() {
    override fun onCreate() {
        super.onCreate()
    }
}

画面のライフサイクル

iOS Android
ViewController Activity

iOS の場合は画面の遷移は NavigationController に ViewController がスタックされていきます。Android も同様に Activity が Task にスタックされていきます。Android には Modal といった概念はありません(noHistory などを指定することでスタックしないといった設定を行えます)

View

iOS Android
Xib Layout(xml)

iOS の Xib に相当するものが Android では xml で定義する layout ファイルになります。StoryBoard の概念は Android にはありません。

iOS Android
UIView View, ViewGroup

iOS の場合は View は単一でもグループとしても振る舞うことができますが Android は View は常に単一で、複数のView を持つクラスは ViewGroup となっています。 ViewGroup には RelativeLayout, FrameLayout, LinearLayout があり、組みたいレイアウト構成により使い分けます。

iOS Android
UITableView ListView, RecyclerView

Android でリスト表示したい場合には ListView か RecyclerView を利用します。どちらもリスト表示ができ RecyclerView の方がセル毎にレイアウトファイルを分けたりと柔軟に対応できるイメージがあります。

iOS Android
UICollectionView RecyclerView, ViewPager

横スクロールするリストの表示には RecyclerView を利用します。ペラペラとページめくりしたい場合は iOS の場合は UICollectionView の pagingEnabled を true にすると実現できますが RecyclerView にはそのように機能がない為、ページめくりをしたい場合は ViewPager を利用します。

ウィジェット

iOS Android
UIButton Button, ImageButton

iOS では、テキストだけのボタンも画像だけのボタンも UIButton を利用しますが Android では、画像のみのボタンには ImageButton , テキストを表示したい場合には Button を利用します。

iOS Android
UITextField, UITextView EditText

iOS では、 1 行のテキストを扱う場合には UITextField , 複数行のテキストを扱う場合には UITextView を利用しますが、 Android は EditText のプロパティで 1 行か複数行かを制御します。Android で 1 行のテキストを扱う場合は EditText に inputType="text" を指定します。複数行のテキストを扱う際に layout_height="wrap_content" を指定した場合のレイアウトの高さは 1 行分なので、レイアウト調整では minLines を指定して何行分のレイアウトを確保するか指定してあげます。

iOS Android
UILabel TextView

iOS では UILabel の numberOfLines に 0 にすることで複数行の可変として扱いますが Android は特にそのような指定は不要です。行数を制御したい場合は maxLines を指定してやります。

iOS Android
UIImageView ImageView

iOS では、画像を非同期で取得する有名なライブラリは SDWebImage だと思いますが Android の場合は Glide と Picasso が有名です。 Glide はメソッド数が多いですが、画像の向き情報( rotate ) を考慮して表示してくれるので便利です。 Picasso も次期バージョンで対応するといった話を聞きました。

iOS Android
DatePicker DatePicker

iOS の DatePicker はドラム型の表示ですが Android の DatePicker はカレンダーで表示されます。 spinner を指定すればドラム型で表示されるようになった気がしますが試していません。

iOS Android
Picker NumberPicker

年月日を扱うときは DatePicker を利用しますが、年月だけを扱いたい場合などは NumberPicker を利用します。

アラート

iOS Android
UIAlertController AlertDialog

Android でアラートを表示する場合は AlertDialog を利用します。AlertDialog を Activity から呼び出してアラートを表示することができますが、 Activity は、画面の回転や OS のメモリ不足などで強制的に Kill されることがある為 DialogFragment を継承した Fragment で実装することが推奨されています。 iOS にはないトースト表示として Activity などの context を必要としない Toast クラスもあります。

Kotlin + RecyclerView で OnItemClick を実装する

Android Kotlin RecyclerView OnItemClick

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

iko-yo.net

本日の記事では Kotlin での RecyclerView の実装方法について紹介したいと思います。ListView では、リストの要素をタップした時の処理は OnItemClickListener に処理を書くと思いますが RecyclerView には OnItemClickListener をセットすることができない為、独自にタップした時の処理を実装する必要があります。

今回は RecyclerView の Adapter にメソッドを引数として渡す方法で実装してみたいと思います。サンプルのソースは Github に公開しています。KotlinRecyclerViewSample

やりたい事

f:id:t-namikata:20161220095848g:plain

  • MainActivity で RecyclerView のアイテムをクリックすると DetailActivity に遷移する
  • タップしたアイテムのタイトルが DetailActivity に表示される

実装方法

  1. RecyclerView の Adapter を作成する
  2. MainActivity で RecyclerView をセットアップする

1. RecyclerView の Adapter を作成する

RecyclerView の Adapter を作成します。ポイントはコンストラクタの引数に itemClick を宣言しているところです。今回の例では item をタップした時の処理は MainActivity でメソッドを定義し Adapter の引数として渡すことで、Activity の遷移の実装を行なっています。itemClick には List の要素の itemName を渡すようにしているので、 MainActivity から DetailActivity に遷移する際に必要となる itemName をメソッドを定義する MainActivity で取得できるようになっています。

class ItemListAdapter(val items: List<String>, val itemClick: (String) -> Unit) :
        RecyclerView.Adapter<ItemListAdapter.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
        return ViewHolder(view = view, itemClick = itemClick)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.setUp(items[position])
    }

    override fun getItemCount(): Int {
        return this.items.count()
    }

    class ViewHolder(view: View, val itemClick: (String) -> Unit) : RecyclerView.ViewHolder(view) {
        private val textView: TextView by bindView(R.id.item_text_view)

        fun setUp(itemName: String) {
            this.textView.text = itemName
            this.itemView.setOnClickListener { itemClick(itemName) }
        }
    }
}

item.xml は以下のようになっています。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/item_text_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp" />

    <View
        android:layout_alignParentBottom="true"
        android:background="@android:color/black"
        android:alpha="0.1"
        android:layout_width="match_parent"
        android:layout_height="1dp" />
</RelativeLayout>
  1. MainActivity で RecyclerView をセットアップする

あとは Adapter をセットする時にリストをタップした時の処理を引数に渡してあげることで完成です。

class MainActivity : AppCompatActivity() {
    private val recyclerView: RecyclerView by bindView(R.id.recycler_view)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val adapter = ItemListAdapter(items = listOf("Apple", "Pineapple", "Pen")) { itemName ->
            val intent = Intent(this, DetailActivity::class.java)
            intent.putExtra("itemName", itemName)
            this.startActivity(intent)
        }
        this.recyclerView.adapter = adapter
    }
}
class DetailActivity : AppCompatActivity() {
    private val textView: TextView by bindView(R.id.text_view)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_detail)

        this.title = "DetailActivity"
        this.textView.text = intent.getStringExtra("itemName")
    }
}

Kotlin + Retrofit2 + Gson で API 通信を実装する

Kotlin Android Retrofit Gson Interceptor OkHttp

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

iko-yo.net

Kotlin 1.0.5
minSdkVersion 19
targetSdkVersion 25

この記事では Retrofit を使って API 通信をする際の、 http ヘッダーの追加方法や json を独自のモデルへ変更する方法などの基本的なところを書きたいと思います。iOS の開発を担当していた頃は Swift + Alamofire + ObjectMapper で API 通信を実装していたのですが、 Retrofit もとても分かりやすく、導入が容易で素晴らしいライブラリだと思いました。

  1. Retrofit のリクエストを生成する generater クラスを作成する
  2. Debug ビルド時には request と response のログを出力する
  3. api の json の形式に合わせて Gson の NamingPolicy を設定する
  4. http ヘッダー を追加する
  5. 401 エラー時の refreshToken を利用した token の更新方法

1. Retrofit のリクエストを生成する generater クラスを作成する

Retrofit では API のリクエスト先のエンドポイントを Interface で定義します。

interface SampleService {
    @GET("users/{id}")
    fun getUser(@Path("id") id: Int): Call<User>
}

定義した Interface から API のリクエストを生成するクラスを作成しておくと、何かと便利なので、まずは generator クラスを作成します。

class RetrofitServiceGenerator {
    companion object {
        fun createService(): SampleService {
            val apiUrl = "https://api.sample.com/"
            val retrofit = Retrofit.Builder()
                    .baseUrl(apiUrl)
                    .build()
            return retrofit.create(SampleService::class.java)
        }
    }
}

利用する時は val service = RetrofitServiceGenerator.createService() で service を作成し service.listRepos(user = "user") とします。あとは非同期でリクエストを投げたい場合は enqueue 、同期の時は execute で API をコールすることができます。

2. Debug ビルド時には request と response のログを出力する

開発中は request と response のログが見れた方が便利なので、Debug ビルドの時はログを出力するように設定します。Retrofit はデフォルトで OkHttp Client を利用しているので OkHttp の interceptor を追加してあげる形になります。 build.gradle に compile 'com.squareup.okhttp3:logging-interceptor:3.3.1' の追加が必要です。

class RetrofitServiceGenerator {
    companion object {
        fun createService(): SampleService {
            val apiUrl = "https://api.github.com/"
            val client = builderHttpClient() // OkHttpClient に logging の設定を追加
            val retrofit = Retrofit.Builder()
                    .baseUrl(apiUrl)
                    .client(client) // Retrofit に client を設定
                    .build()
            return retrofit.create(SampleService::class.java)
        }
    }

    private fun builderHttpClient(): OkHttpClient {
        val client = OkHttpClient.Builder()
        if (BuildConfig.DEBUG) {
            val logging = HttpLoggingInterceptor()
            logging.level = HttpLoggingInterceptor.Level.BODY
            client.addInterceptor(logging)
        }

        return client.build()
    }
}

3. api の json の形式に合わせて Gson の NamingPolicy を設定する

Gson を利用して json で返却されるレスポンスを独自の型に変換したいと思います。Retrofit はデフォルトの設定が Gson になっており、何も指定指定なくても json レスポンスを SampleService Interface で定義した型に変換を行います。 SampleService の Call の User が 変換されるデータクラス (Entity) になります。json と User は以下だとします。

{
    id: 1,
    first_name: "tabeo",
    last_name: "gohan"
}
data class User(
    val id: Int,
    val firstName: String,
    val lastName: String
)

この時 id は json と User で一致するので問題ありませんが、 first_name, firstName は snakecase と lowerUpperCase の違いがある為、このようなケースの場合は Gson の NamingPoricy を設定してあげる必要があります。このようなパターンは良くある為、予め Gson が snakecase と lowerUpperCase を適合させる LOWER_CASE_WITH_UNDERSCORES といった指定が準備されています。

class RetrofitServiceGenerator {
    companion object {
        fun createService(): SampleService {
            val apiUrl = "https://api.github.com/"
            val client = builderHttpClient()
            val gson = GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create() // NamingPoricy そ指定する
            val retrofit = Retrofit.Builder()
                    .baseUrl(apiUrl)
                    .client(client)
                    .addConverterFactory(GsonConverterFactory.create(gson)) // Retrofit に gson を設定
                    .build()
            return retrofit.create(SampleService::class.java)
        }
    }

    ... 省略 ...

}

4. http ヘッダー を追加する

http ヘッダーに token 情報を追加して API を call する場合など、 http ヘッダーに情報を追加するケースがあると思います。 http ヘッダーを追加する場合も logging と同様に OkHttp の Interceptor を作成して追加を行います。

class RetrofitServiceGenerator {
    companion object {
        fun createService(): SampleService {

          ... 省略 ...

        }

        private fun builderHttpClient(): OkHttpClient {
            val client = OkHttpClient.Builder()
                    .addInterceptor(BearerAuthenticationInterceptor()) // token を追加する interceptor
            if (BuildConfig.DEBUG) {
                val logging = HttpLoggingInterceptor()
                logging.level = HttpLoggingInterceptor.Level.BODY
                client.addInterceptor(logging)
            }

            return client.build()
        }
    }
}
class BearerAuthenticationInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain?): Response? {
        chain ?: return null

        val accessToken = "token" // アプリ内で保持している token をセットする
        val request = chain.request().newBuilder()
                .header("Authorization", "Bearer ${accessToken}")
                .build()
        return chain.proceed(request)
    }
}

5. 401 エラー時の refreshToken を利用した token の更新方法

OkHttp はレスポンスのコードが 401 の場合に http ヘッダーを更新して再リクエストできる Authenticator という便利なクラスが用意されています。 Authenticator クラスを継承したサブクラスを作ることで 401 エラーが返却された際の処理を実装することができます。

class TokenAuthenticator : Authenticator {
    private var count = 1

    override fun authenticate(route: Route, response: Response): Request? {
        // 3 回リトライを行う。 return null で authenticate メソッドの loop から抜ける
        if (this.retryCount(response = response) > 3) {
            return null
        }

        // アプリ内で保持している refreshToken
        val refreshToken = "refreshToken"
        // refreshToken を利用して token を更新する
        val newToken = this.updateToken(refreshToken = refreshToken) ?: return null

        return response.request().newBuilder().header("www-Authorization", "Bearer ${newToken}")?.build()
    }

    private fun updateToken(refreshToken: String): String? {
        // Retrofit の execute (同期)メソッドで token 更新のリクエストを行う
        return newToken
    }

    private fun retryCount(response: Response): Int {
        response.priorResponse()?.let {
            count += 1
        }
        return count
    }
}

作成した TokenAuthenticator を RetrofitServiceGenerator に反映した完成版がこちらです。

class RetrofitServiceGenerator {
    companion object {
        fun createService(): SampleService {
            val client = builderHttpClient()
            val gson = GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create()
            val apiUrl = "https://api.github.com/"
            val retrofit = Retrofit.Builder()
                    .baseUrl(apiUrl)
                    .addConverterFactory(GsonConverterFactory.create(gson))
                    .client(client)
                    .build()
            return retrofit.create(SampleService::class.java)
        }

        private fun builderHttpClient(): OkHttpClient {
            val client = OkHttpClient.Builder()
                    .addInterceptor(BearerAuthenticationInterceptor())
                    .authenticator(TokenAuthenticator())
            if (BuildConfig.DEBUG) {
                val logging = HttpLoggingInterceptor()
                logging.level = HttpLoggingInterceptor.Level.BODY
                client.addInterceptor(logging)
            }

            return client.build()
        }
    }
}

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

Android orientation 画像の向き rotate Exif ExifInterface ContentResolver Kotlin

アクトインディ 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 を取得する実装を見たのですが、理解力が足りなく良く分かりませんでした。今後の課題として実装の内容は少しずつ紐解いて行きたいと思います。