LoginSignup
3
5

More than 3 years have passed since last update.

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(14)TargetAPIを29に上げる

Last updated at Posted at 2021-02-23

前回の記事からだいぶ空いてしまった間に、Android Q(10)はおろかPie(11)までがリリースされてしまいました。

今回は、2020/11/1以降、アップデートするアプリが対応を必須とされているTargetAPI=29とする対応を、既存のプロジェクトに対して行っていきます。

Androidアプリは、1年ごとにTargetAPIレベルを上げなさいとGoogleからお達しが出ています。
守らないと、アプリを新規リリースまたは更新版のリリースが出来ません。

ロードマップはこの辺りで確認できますが、アップデートに関してはだいたい毎年11月以降適用されると考えていればいいかと思います。
Google Play の対象 API レベルの要件を満たす

2021/02/09現在、Target30への対応期限(2021/11)も見えていますが、お尻に火が付いた状態ということでいったん29への対応をやっていきます。

環境

本記事作業開始前の環境です。
※作業終了時にバージョンが変わっていることがあります

ツールなど バージョンなど
MacbookPro macOS Catalina 10.15.7
Android Studio 3.6.3
Java(JDK) jdk1.8.0_131.jdk

今回の目標

Target APIレベルを上げる手順をざっと経験する。
Android Qでアプリが動くようにする。

前提

TargetAPIレベル?

まず、TargetAPIレベルって何?というところから。
簡単に言えば、app/build.gradleにある以下のバージョン番号のことです。

app/build.gradle
android {
    compileSdkVersion 28
    defaultConfig {
        targetSdkVersion 28

APIレベル?

APIレベルって何?というお話ですが、Androidには、OSバージョン毎にAPIレベルが通番で振られています。

Android 1.6 = 4
Android 4.4 = 19
Android 5.0 = 21
Android 10 = 29

等といった感じです。
詳しくは
API レベルとは
などで確認できます。

こちらのサイトなどだと、シェアも確認できて便利です。
http://smatabinfo.jp/os/android/index.html

なぜ上げる必要があるの?

Androidは基本的に下位互換があるため、新しいOS向けにAPIレベルを上げても、古いOSでも動くことはほぼ補償されています(一部例外はありますが)。
一方、新しいAPIはパフォーマンスの向上や、セキュリティ対策がどんどん施されていっています。
古いAPIを使ったままだと、これらの恩恵を受けられないわけです。
APIレベルを上げたところで、下位互換があるから古いOSでも動作するので、上げないメリットないよね?

というところです。

詳しくはこちらをご参照下さい。
新しい SDK をターゲットにする理由

新OS対応って、どんな作業があるの?

基本的に、新OS発表のあと、その1年後くらいにそのOSのAPIレベルにTargetを更新してリリースすることが求められます。

大別して以下のような作業の流れとなります。

(1)まず、新OS上で自分のアプリが動作するか検証テストをする

新OSを搭載した実機で動作を確認します。
デベロッパー向けにベータ版が使える場合がありますので(ベータ版を利用できる端末である場合など)、それらを利用して検証していくことが望ましいです。

これはTargetAPIを上げる前に検証しておかなければなりません。
新OSのリリースの方が、TargetAPIの更新期限より前にあるからです。

この段階では、TargetAPIレベルに関係なく、新OSで動かす場合に必ず発生する動作の変更点を確認する必要があります。
その情報は、Android Developerサイトで必ず公開されますので、ベータ版が公開になったニュースなどを目にしたら、チェックしに行くようにしましょう。

例えば、Android Q(10)での動作の変更点は、以下で確認できます。
Android10 動作の変更点: すべてのアプリ

最近では、プライバシーに関する変更点は別記事になっていることが多いので、そちらもチェックが必要です。
Android 10 におけるプライバシー

例えば、今回対応する予定のAPI29では、Scoped Storageという外部ストレージへのプライバシー制限が大幅に変わる変更があり、このシリーズで作っているアプリは影響が有る可能性があります。(画像を保存しているため)。

(2)TargetAPIの指定を変更して、アプリをビルドする

次に行うのが、TargetAPIレベルを上げることです。
このとき、以下のような作業が発生する場合があります。

  • 非推奨(deprecated)となった関数などの置き換え
  • 各種ライブラリのバージョンアップ
  • 新たに適用される制限、権限(permission)などへの対応

非推奨項目やライブラリバージョンアップは必須では無いことが多いですが、最後の新たに適用される制限などはよく注意する必要があります。

(3)複数のOSで動作確認

今度は、実行するOSに関係なく、TargetAPIレベルを上げてビルドしたアプリが問題なく動作することを確認する必要があります。
この場合にも、TargetAPIを上げると動作に変更がある点についてはGoogleはきちんと公開してくれています。
(わかりやすいとは言いませんが・・・汗)

例えば、TargetAPIを29(Android 10)に上げた場合については、以下のページで確認が出来ます。
動作の変更点: API 29 以降をターゲットとするアプリ

テスト実装の重要性

特に前項の(1)と(3)において、もしテストコードを書いてなかったらと想像して下さい。
毎回、フルリグレッション(全機能全ステップ)の検証が必要になり、それを実際に人が動作してやることを考えると、うんざりしますよね。
でも!
テストが充実していると、この手動・人力での検証を最小限に抑えることが出来るのはお分かりですよね(^-^)

テストはなにも新規にリリースするときやアプリの機能をアップデートしたときだけに必要になるわけじゃ無いんですよね。
どんなに規模の小さなアプリでも、この新OS対応の運用を考えると、可能な限りUIテストも含めて書いておくことが望ましいと思います。

TargetAPI 29対応

1. プライバシーの変更への対応

Android 10上で動作する全てのアプリへの影響がある項目です。
Android 10では、Scoped Storageというのが一番、この記事で書いてきたアプリには影響が大きいと思われる変更かと思います。日本語にすると対象範囲別ストレージといっているようです。

詳しくみていきます。

1.1 変更点の概要

https://developer.android.com/about/versions/10/privacy?hl=ja
より引用

対象範囲別ストレージ
外部ストレージ用の限定ビューが用意されました。このビューから、アプリ固有ファイルやメディア コレクションにアクセスできます

これだけ読むとちょっと意味が分かりませんね。
自分のアプリが対象かどうかもこれだけでは分かりません。

影響を受けるアプリ
外部ストレージ内のファイルにアクセスして共有するアプリ

ここで一気に関係あるかもと気を引き締めます。
というのも、この記事で書いてきたアプリには、Instagramへのシェア機能として、画像を投稿する機能を作っています。
そのとき、作った画像をどこに保存していたかな???とコードを探してみると・・・

InstagramShareActivity.kt
    @NeedsPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
    fun createShareImage() {
        val dir = File(
            Environment.getExternalStoragePublicDirectory(
                Environment.DIRECTORY_PICTURES
            ), "qiita_pedometer"
        )
        launch {
            val bitmap = withContext(Dispatchers.Default) {
                getBitmapFromView(binding.root.layout_post_image)
            }
            withContext(Dispatchers.IO) {
                viewModel.createShareImage(bitmap, dir)
            }
        }
    }

Environment.getExternalStoragePublicDirectoryを使って、外部ストレージに保存しているので、どうやら対象になりそうです。

対応策
アプリ固有ディレクトリ内およびメディア コレクション ディレクトリ内で処理を実行します

Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)は、メディアコレクションディレクトリではないのか?

詳細をクリックして確認してみます。以下のページに飛ぶかと思います。

アプリのファイルとメディアを対象とする外部ストレージ アクセス

ここの記述を読んでもいまいち分かりづらいですが、取り敢えず以下のような内容は読み取れると思います。

  • アプリのスコープに用意された外部ストレージの領域へのアクセスは、権限のリクエスト(WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGE)が不要になる
  • Context#getExternalFilesDir()を使うか、メディアストアを使う

はて、では既存コードで使用しているEnvironment.getExternalStoragePublicDirectoryはどうなるのだ??

こういうときは、まずAPIリファレンスを見てみましょう。
https://developer.android.com/reference/android/os/Environment#getExternalStoragePublicDirectory(java.lang.String)

赤字で目立つように書いてありますね。
APIレベル29から非推奨(deprecated)だと・・・

ではいったいどうなってしまうのか?
実際にAPIレベルを上げてビルドして、動作を確認してみましょう。

1.2 TargetAPIレベルを上げてビルドし動作を確認する

TargetAPIを上げるとbuildToolsやプラグインのバージョン、果てはAndroidStudioのバージョンまで上げないといけないこともあるのですが、その場合の手順については以下の記事でも触れているのでいったん飛ばします。(少なくともTarget28から29への対応では特に何も必要無かったかと思います)
Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(7)リファクタリングとKoinでDI編#開発環境の最新版対応

ひとまず、compileSdkVersionと、targetSdkVersionを29に変更します。

app/build.gradle

android {
    compileSdkVersion 29
    defaultConfig {
        applicationId "jp.les.kasa.sample.mykotlinapp"
        minSdkVersion 19
        targetSdkVersion 29

Gradle syncしましょう。
特に問題ないはずです。

これでアプリを実行し、Instagramへのシェアを実行してみます。
シェアは暗黙的インテントを使っているので、実行端末やエミュレーターに実際にInstagramのアプリがインストールされている必要はありません。

Android 9のエミュレーターで実行してみました。

シェア画面
Chooser画面

問題なく実行できました。(Chooser画面が正常に出れば、画像は保存が成功しています)
Google Photoアプリでも、画像が保存されているのが確認できます。

GooglePhotoアプリ

続いて、Android 10のエミュレーターでも実行してみます。

投稿ボタンを押すと、Chooserの画面が表示されず、アプリが再起動したような動きを見せます。
何度もやっていると、次の画面が出ました。

アプリがクラッシュ(例外発生)を繰り返しているとこの表示になりますので、どこかでクラッシュしているのは間違いないです。

Logcatを見てみると・・・

    java.io.FileNotFoundException: /storage/emulated/0/Pictures/qiita_pedometer/20210211_003716.jpg: open failed: ENOENT (No such file or directory)
        at libcore.io.IoBridge.open(IoBridge.java:496)
        at java.io.FileOutputStream.<init>(FileOutputStream.java:235)
        at java.io.FileOutputStream.<init>(FileOutputStream.java:186)
        at jp.les.kasa.sample.mykotlinapp.activity.share.InstagramShareViewModel.saveBitmap(InstagramShareViewModel.kt:46)
        at jp.les.kasa.sample.mykotlinapp.activity.share.InstagramShareViewModel.createShareImage(InstagramShareViewModel.kt:37)
        at jp.les.kasa.sample.mykotlinapp.activity.share.InstagramShareActivity$createShareImage$1$1.invokeSuspend(InstagramShareActivity.kt:100)

フォルダかファイルが見つからないと言われます。
Target28まで、あるいはTarget29+Anroid 9では動いていたはずなので、Target29+Android 10で影響が出ていることが分かります。

このように、TargetAPIレベルを上げたときは、旧OSと新OSの両方で動作確認していく必要があります。

ひとまず、これでTargetAPI=29にするとアプリに影響があることが分かりました。
これを直していく必要があるのですが、その前にやることがあるのでそちらを先に解説します。

2. テストが動くようにする

最初の方で、「テストを書いてないと大変だ」というようなことを書きましたが、実はテスト結果だけを見ていても罠があります。

このシリーズの記事では、シェアで画像を保存する機能のテストは、これまでに以下のテストを書いてきました。

  • Robolectric版
    • Context等が必要なテストも実機無しでテスト可能なライブラリ
  • androidTest版(Instrumentationテスト)
    • エミュレーターや実機を繋いで、実際にアプリを端末上で動作させるテスト

実は、TargetAPIを上げてテストした際、両者の実行結果が異なっていました。
後で解説しますが、まず単体テストを通すためにも一苦労必要なので、それを先に解説します。

2.1 androidTest(Instrumentationテスト)

今回の機能をテストするテストコード自体はInstagramShareActivityTest(Robolectric版)あるいはInstagramShareActivityTestI(androidTest版)のpost()メソッドになります。

まず、androidTestを実行してみます。

InstagramShareActivityTestI.kt
    @Test
    fun post() {
        val intent = Intent().apply {
            putExtra(
                InstagramShareActivity.KEY_STEP_COUNT_DATA,
                StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT)
            )
        }
        val activity = activityRule.launchActivity(intent)


        // activityに反応させないため、いったんすべての監視者を削除
        activity.viewModel.savedBitmapFile.removeObservers(activity)

        // テスト用の監視
        val testObserver = TestObserver<File>(1)
        activity.viewModel.savedBitmapFile.observeForever(testObserver)

        onView(withText(R.string.label_post)).perform(click())

        testObserver.await()

        assertThat(activity.viewModel.savedBitmapFile.value).isFile()

        activity.viewModel.savedBitmapFile.removeObserver(testObserver)
    }

Android10未満と、Android10とで実行してみて下さい。

Android9では成功し、Android10では以下のようなエラーが出ているかと思います。

Test instrumentation process crashed. Check jp.les.kasa.sample.mykotlinapp.activity.share.InstagramShareActivityTestI#post.txt for details

ひとまずここまでは実際の検証結果と同じなので問題ないですね。

2.2 Robolectric版を実行してみる

問題が起きたのはこちらです。
まず、以下のテストを実行してみます。こちらはRobolectricなので、エミュレーター/実機端末は不要です。

InstagramShareActivityTest.kt
   fun post() {
        val intent = Intent().apply {
            putExtra(
                InstagramShareActivity.KEY_STEP_COUNT_DATA,
                StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT)
            )
        }
        activity = activityRule.launchActivity(intent)


        // activityに反応させないため、いったんすべての監視者を削除
        activity.viewModel.savedBitmapFile.removeObservers(activity)

        // テスト用の監視
        val testObserver = TestObserver<File>(1)
        activity.viewModel.savedBitmapFile.observeForever(testObserver)

        onView(withText(R.string.label_post)).perform(click())

        testObserver.await(10)

        assertThat(activity.viewModel.savedBitmapFile.value).isFile()

        activity.viewModel.savedBitmapFile.removeObserver(testObserver)
    }

実行してみて下さい・・・

実行できないはずです!

java.lang.IllegalArgumentException: API level 29 is not available

こんなエラーが出ませんか?
なんと、RobolectricがTargetAPI29に対応していない!

しかしググってみると、バージョン4.3.1で対応されたとの情報があります。
https://stackoverflow.com/a/58694744

他には、「Robolectricの実行APIレベルを28以下に指定すれば大丈夫だよ」なんて情報もたくさんありますが、「APIレベル29で実行した結果のテストを行いたい」目的を果たせないため、却下です。

早速Robolectricのバージョンを上げてみます。

app/build.gradle
testImplementation 'org.robolectric:robolectric:4.3.1'

またテストを実行してみましょう。
テストが通り・・・

ません!
エラーログを見ると、メッセージが変わっています。

UnsupportedOperationException: Failed to create a Robolectric sandbox: Android SDK 29 requires Java 9 (have Java 8)

どうやら、Java9以上が必要と言われているようですT_T

どうしてもTarget29上で実行させたいので、Java9以上に上げるしか選択肢はありません。
ただ、全体を上げてしまってやるより、まずはこのテストをJava9以上で動かしてみようと思います。

2.3 Java9以上でテストを動かす

2.3.1 Java9以上をインストールする

お好きなディストリビューションのJDKをインストールして下さい。
私はAdoptOpenJDKにしました。また、インストールするならLTS(サポート期限が長いやつ)がいいので、11を入れました。
https://adoptopenjdk.net/

Mac版はDLしたpkgファイルをダブルクリックしてインストールするだけです。
環境変数JAVA_HOMEはまだ変えないでおきます。

/Library/Java/JavaVirtualMachines/adoptopenjdk-11.jdk下に入ったようです。

2.3.2 テストの実行環境をJava9以上にする

まずは該当テストのみの実行環境を変えてみます。
Android Studio3.6.3の場合、以下の手順で変更できます。

(1) 以下のEdit Configurations...メニューを選ぶ

Test_EditConfig.png

(2) Android Junit下の該当テストを選ぶ

(3) JREの項目のフォルダアイコンを選んでインストールしたJava9以上のフォルダを選択する

Test_EditJRE.png

(4) OKをクリック

設定は以上です。

2.9.3 実行してみる

テストは動くはずです。
結果はpostメソッドは失敗するはず・・・

Test_JRE11.png

成功してしまいましたT^T

2.9.4 なぜ成功してしまったか?

以下の行にブレークポイントを貼って、savedBitmapFileの中身を覗いてみましょう。

Test_breakpoint.png

単独のテストのデバッグ実行は、テストメソッドの左側にある実行アイコンをクリックして、Debug 'post()'を選ぶと出来ます。

Test_RunDebugMethod.png

あ、これも実行環境のJREを変更しておいて下さいね。
一度実行して失敗させておいてから、Edit Configrations...すると楽です。

デバッグ実行して、ブレークポイントで止まったら、savedBitmapFile.valueにカーソルを宛てて値を表示させてみます。

Test_DebugValue.png

パスが何やら複雑なものになっていますね。本来なら/storage/emulated/0/Pictures/qiita_pedometerのようなパスになっているはずですが。
これはRobolectricが実際の外部ストレージに書き込むことなくテストできるように外部ストレージ自体をエミュレートして、仮想的なパスを使っているということが想像できます。
なので、本来のEnvironment.getExternalStoragePublicDirectoryを通っていないための弊害と言えそうです。本家はdeprecatedになり値を返さなくなっているはずが、その変更が反映されていないとも言えますが、いずれにせよファイルI/OをエミュレートしているRobolecricならではというか、宿命的な現象でしょう。

これはRobolectricの対応を待つしか無いので、今は対処が出来ません。
ひとまず、AndroidTest(InstrumentationTest)では再現するので、修正後のテストはそちらを通すことで確認していくしかありません。

Robolectricは実機やエミュレーターを使うよりテスト実行が早いので便利ですが、このような弊害もあるので、やはりUIテストは可能な限りAndroidTest(InstrumentationTest)でもまだまだ書いておく必要がありそうです。

3. 修正する

ということで、Environment.getExternalStoragePublicDirectoryを使わないように修正していきます。

この画像は、アルバムアプリ(代表的なものとして、Googleフォトですね)にも表示して欲しいので、メディアストア(MediaStore)を使って作成し、公開するように実装していこうと思います。
もし、アプリ内からだけ見えて外部(アルバムアプリ等)から見えなくて良いのであれば、Context#getExternalFilesDirや、いずれ勝手に消えても良ければContext#getExternalCacheDirを使えば良いでしょう。

この場合、WRITE_EXTERNAL_STORAGEREAD_EXTERNAL_STORAGEのpermissionが不要になります。ヤッタネ!(Mでランタイムパーミッションが導入されたときの阿鼻叫喚はいったいなんだったんだ・・・)

MediaStoreAPIを使う場合は、Android 9以下のOSで実行される場合向けに宣言が必要となりますが、Andoid10では逆に「宣言しないように」しなければならないため、特殊な記述方法になります。詳しくは3.2節をご覧下さい。

では、対応方法を見ていきますが・・・
実は、今回(Target29対応)だけに有効な手段が存在するので、まずはそちらから紹介します。

3.1 臨時回避策を行う

実は、簡単な回避策があります。それは、「対象範囲別ストレージをオプトアウトする」という手段です。
方法については以下に書いてあります。
https://developer.android.com/training/data-storage/use-cases?hl=ja#opt-out-scoped-storage

ご覧頂ければ分かりますが、これはTargetAPI=29での限定的なオプションです。
TargetAPI=30に上げると、このオプションが無効になりますので、恒久対応をしなければなりません。

対応方法は簡単で、マニフェストファイルへrequestLegacyExternalStorage="true"という記述を<application>タグに追加するだけです。

AndroidManifest.xml
<manifest ... >
<!-- This attribute is "false" by default on apps targeting
     Android 10 or higher. -->
  <application android:requestLegacyExternalStorage="true" ... >
    ...
  </application>
</manifest>

Android 11でまたこの辺りはいろいろと変更がありそうなので、例えば今はアプリの更新を急いでいるので、こっちの対応は控える、という判断も有りだと思います。TargetAPI=30にする際に、もう逃げられないので恒久対応をしましょう。

上記設定を追記して、Android10の端末やエミュレーターで実行してみます。

optout_1.png
optout_2.png

無事保存が出来たようです。Googleフォトアプリからも見えています。

テストも実行してみます。AndroidTest(InstrumentationTest)だけでなく、念のためRobolectric版もやってみましょう。

  • AndroidTest(InstrumentationTest)版(InstagramShareActivityTestI)
    optout_test.png

  • Robolectric版(InstagramShareActivityTest)

    optout_robolectir_1.png

  • Robolectric版(InstagramShareViewModelTest)

    optout_robolectric2.png

どれもパスしました。

時間が無い、とにかく急いでTarget29対応したものをリリースしなければならない、というときには、いったんこちらを使っておくのも良いでしょう。
ただし、Target30に上げる際には絶対に対応しなければならないため、対応方針は早めに検討しておいた方が良いと思います。

3.2 MediaStore APIを使って対応する

こちらは恒久対応を行う場合になります。
前述の通り、このアプリでは、「他のアプリが参照できる画像ファイルをどこかに保存したい」というのが要件になります。

Android ストレージのユースケースとおすすめの方法 のページにある、メディア ファイルを他のアプリと共有するパターンとなります。

早速実装していきます。

あ、マニフェストファイルへの変更は元に戻しておいて下さいね。

3.2.1 権限の宣言を変更する

Android 10以上では、WRITE_EXTERNAL_STORAGEの権限が不要になりますが、Android 9以下では引き続き必要です。そのため、APIレベル28まではリクエストするという設定が必要になります。
それには、以下のようにマニフェストファイルへ追記します。

AndroidManifest.xml
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
            android:maxSdkVersion="28" />

3.2.2 権限リクエスト処理を分岐する

APIレベル29未満のOSでは、権限リクエストを表示し、29以上では表示しないようにしなければなりません。
本記事では、permissionsdispatcherというライブラリを使っていますが、これは@NeedsPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)アノテーションを付けた関数InstagramShareActivity#createShareImageを直接呼ばずに~~WithPermissionCheckというsuffixを付けたライブラリが自動生成した関数を呼んで実現しています。
なので、実行しているOSがAPIレベル29以上なら~~WithPermissionCheckを呼ばずに、直接createShareImageを呼ぶように分岐させてやれば良いですね。

InstagramShareActivity.kt
   override fun onCreate(savedInstanceState: Bundle?) {
        ...(省略)

        binding.root.button_share_instagram.setOnClickListener {
            if (Build.VERSION.SDK_INT < 29) {
                createShareImageWithPermissionCheck()
            } else {
                createShareImage()
            }
        }
        ....(省略)

これで実行すると、Android 10以上の搭載の端末/エミュレーターでは権限リクエストダイアログが表示されず、Android 9以下では表示されるようになります。

3.2.3 メディアストアAPIを使って画像を保存する

サンプルコードがメディアストレージガイドページのアイテムを追加するにあるので、それらを参考に実装していきます。

なお、上記ページは音楽ファイルが対象なので、画像については、以下を参考にしました。(javaコードですが)
https://akira-watson.com/android/mediastore-save.html

また、Android28以下での実装は以下を参考にしました。実はここもAPIレベル毎に処理が微妙に異なりますので、注意してください。
https://codechacha.com/ja/android-mediastore-insert-media-files/

では、実際のコードを見ていきます。
まず、InstagramShareViewModelですが、ApplicationContextが必要になるため基底クラスをAnddoidViewModelに変更します。

InstagramShareViewModel.kt
class InstagramShareViewModel(application: Application) : AndroidViewModel(application) {

こうするとgetApplication<Application>()Applicationクラスのインスタンスが取得出来るようになります。

処理分岐が必要になるので、saveBitmap関数を以下のように書き変えます。

InstagramShareViewModel.kt

    private fun isExternalStorageMounted() =
        Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()

    private fun saveBitmap(bitmap: Bitmap, displayName: String): Uri? {
        // 外部ストレージが使えるかチェック
        if (!isExternalStorageMounted()) return null

        // Q以上とP以下で処理を分ける
        return if (Build.VERSION.SDK_INT > 28) {
            // Pより大きいAPIレベル
            saveBitmapOver28(bitmap, displayName)
        } else {
            // Q未満のAPIレベル
            saveBitmapUnder29(bitmap, displayName)
        }
    }

ついでに外部ストレージがマウント済みかをちゃんとチェックするように対応しました。
前回の実装では面倒で飛ばしていました(汗)

3.2.3.1 APIレベル29以上の実装

最初にAPIレベル>28の場合の実装をしていきます。

InstagramShareViewModel.kt
    @RequiresApi(Build.VERSION_CODES.Q)
    private fun saveBitmapOver28(bitmap: Bitmap, displayName: String): Uri? {
        // ContentResolverを取得
        val resolver = getApplication<Application>().contentResolver

        // Imagesコレクションを取得
        val collection = MediaStore.Images.Media
            .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

        // 挿入するContentValueをセット
        val contentValues = ContentValues().apply {
            put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/qiita_pedometer")
            put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
            put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
            put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000) // should be in unit of seconds
            // 排他制御
            put(MediaStore.Audio.Media.IS_PENDING, 1)
        }

        // ContentValueを挿入し、アイテムのUriを得る
        val item = resolver.insert(collection, contentValues) ?: return null

        // Uriを得てから、そこに実際のデータを書き込む
        val fos = resolver.openOutputStream(item)
        try {
            // 書込実施
            val result = bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos)

            if (result) {
                return item
            }
        } catch (e: IOException) {
        } finally {
            fos?.close()

            // 排他制御を解除
            contentValues.clear()
            contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
            resolver.update(item, contentValues, null, null)
        }
        return null
    }

@RequiresApi(Build.VERSION_CODES.Q)は、この関数を実行するのにAPIレベルQ以上が必要であることを示しています。これを指定しておくと、

Field requires API level 29 (current min is 19):

というAndroidStudioの警告を消すことが出来ます。

put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/qiita_pedometer")は、Picturesフォルダの下に"qiita_pedometer"というフォルダを作ってその下にファイルを保存したいため、指定しています。
Picturesフォルダの直下でも問題ない場合は、この行自体、無くて大丈夫です。

それ以外の処理内容はコメントを書いたのでだいたいわかると思いますが、以下の流れになっていることを理解してください。

  1. ContentResolverにまずアイテムを挿入し、Uriを得る
  2. そのUriに対し、ファイル内容を書き込む

3.2.3.2 APIレベル29未満の実装

続いて、APIレベル<29の場合の実装です。

InstagramShareViewModel.kt
    @Suppress("DEPRECATION")
    private fun saveBitmapUnder29(bitmap: Bitmap, displayName: String): Uri? {
        // 先にファイルを保存する
        val dir = File(
            Environment.getExternalStoragePublicDirectory(
                Environment.DIRECTORY_PICTURES
            ), "qiita_pedometer"
        )
        val filepath = File(dir, displayName)
        // 親ディレクトリまで作成
        filepath.parentFile?.mkdirs()

        val fos = FileOutputStream(filepath)
        try {
            // 書込実施
            val result = bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos)

            if (result) {
                // ContentResolverを取得
                val resolver = getApplication<Application>().contentResolver

                val values= ContentValues().apply {
                    put(MediaStore.Images.Media.DISPLAY_NAME, "my_image6.jpg")
                    put(MediaStore.Images.Media.MIME_TYPE, "image/jpg")
                    put(MediaStore.Images.Media.DATA, filepath.absolutePath)
                    put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000) // should be in unit of seconds
                }
                // ContentResolverに挿入
                return resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
            }

        } catch (e: IOException) {
        } finally {
            fos.close()
        }
        return null
    }

@Suppress("DEPRECATION")は、Environment.getExternalStoragePublicDirectoryを使っていて「非推奨になったよ」とAndroidStudioが警告を出すので、この関数が呼ばれる状況では使えるはずな為、警告自体を消すための指定です。このSuppressアノテーションは、クラス宣言の上に書けばクラス全体で警告が抑制されます。今回は、関数に指定しているので、この関数の中の警告だけ抑制されます。なお、他に警告が出ていないかはよく確認してから設定するようにしましょうね。

処理の流れは、29以上の場合と違って、次のようになっています。

  1. ファイルの絶対パスをこちらで指定
  2. ファイルをそこに書き込む
  3. ContentResolverに挿入しアイテムのUriを得る

なぜこのようになっているかというと、API29未満では以下の定数が使えないからです。

  • MediaStore.VOLUME_EXTERNAL_PRIMARY
  • MediaStore.Audio.Media.RELATIVE_PATH

VOLUME_EXTERNAL_PRIMARYの方は、val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URIとすることで一応同じものが取れそうなのですが、RELATIVE_PATHの方が指定できないので、フォルダ名を指定することが出来ない。ということで、既存コードがフォルダを作ってその下に保存する実装だったのでそれを踏襲しなければならない(アプリアップデートして保存するフォルダ変わっていると嫌ですよね)ため、このような形になりました。もしPictures直下で良ければ、適宜APIレベルに応じてif文を入れるくらいで処理はほぼ同じに出来るでしょう。実は、その場合にはAPIレベル29未満でもWRITE_EXTERNAL_STORAGEのパーミッションが不要になります。

3.2.3.3 DIを修正

InstagramShareViewModelの基底クラスを変更したことで、コンストラクタに引数が増えています。Koinを使ってDIしている箇所も、修正が必要です。

modules.kt
val viewModelModule = module {
    ... (省略)
    viewModel { InstagramShareViewModel(androidApplication()) }
}

これで、実行できるようになりました。Android10と9の実機端末やエミュレーターでそれぞれ実行し、動作することを確認して下さい。

なお、Googleフォトアルバムで確認する際、サブディレクトリに画像を作成しているためか、トップページでは表示されず、ライブラリというところに入り、作成したサブフォルダ名を選ぶと見えるようになります。

device-2021-02-19-000612.png

3.3 単体テストの変更

3.3.1 実行環境を複数指定する

createShareImageのテストを変更しないとなりません。
ここでちょっと考えないといけないのが、Target29未満向けと、Target29以上向けの関数があることです。
実行環境も変えてテストを行いたいところですね。
Robolectricでは、デフォルトではTargetAPIレベルでテストが実行されますが、これを変えることが出来ます。
次のように指定します。

InstagramShareViewModelTest.kt
@RunWith(AndroidJUnit4::class)
@Config(sdk = [Build.VERSION_CODES.P, Build.VERSION_CODES.Q]) // これを追加
class InstagramShareViewModelTest : AutoCloseKoinTest() {

@Config(sdk = [バージョンの配列])で指定されたバージョン分、APIレベルを変えてテストをすることが出来るようになります。

3.3.2 テストコードの変更

つづいて、テストを書き変えてみます。

InstagramShareViewModelTest.kt
    @Test
    fun createShareImage() {
        val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.RGB_565)

        // ViewModelインスタンスをモック化する
        val mocked = Mockito.spy(viewModel)

        // 特定の関数の戻り値をダミーにする
        Mockito.doReturn(true).`when`(mocked).isExternalStorageMounted()

        runBlocking {
            mocked.createShareImage(bitmap)
        }

        // resultの確認
        assertThat(mocked.savedBitmapUri.value).isNotNull()

        // ContentResolverでファイルを読んで確認
        val resolver = ApplicationProvider.getApplicationContext<Application>().contentResolver
        resolver.openInputStream(mocked.savedBitmapUri.value!!).use { stream ->
            // ファイルが出来ているかの確認
            val savedBitmap = BitmapFactory.decodeStream(stream)
            assertThat(savedBitmap).isNotNull()
        }
        // 画像が一致するかなどは、Robolectricがダミー画像を作って返すためチェック出来ない。
    }

監視したいLiveDataの型がFileからUriに変わったので、それに伴う修正です。
ファイルが出来ているかの確認も、ContentResolverを介して行っています。

resolver.openInputStream(mocked.savedBitmapUri.value!!).use { stream ->の部分ですが、いわゆるLoanパターンに対応するための関数となります。
私も正式な名称を初めて知ったのですが、Loanパターンというのは、いわゆるcloseメソッドを必要とするオブジェクト(InputStreamCursorなど)のcloseメソッドを自動的で呼ぶ出すもの、です。JavaなどではClosableとかAutoClosableなどを使うと思いますが、それのKotlin版というわけです。

Activityのテストの方も直しておきます。コンパイルが通らなくなっているので、直しておかないと単体テストが実行できないためです。

InstagramShareActivityTest.kt
    @Test
    fun post() {
        val intent = Intent().apply {
            putExtra(
                InstagramShareActivity.KEY_STEP_COUNT_DATA,
                StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT)
            )
        }
        activity = activityRule.launchActivity(intent)


        // activityに反応させないため、いったんすべての監視者を削除
        activity.viewModel.savedBitmapUri.removeObservers(activity)

        // テスト用の監視
        val testObserver = TestObserver<Uri>(1)
        activity.viewModel.savedBitmapUri.observeForever(testObserver)

        onView(withText(R.string.label_post)).perform(click())

        testObserver.await(10)

        assertThat(activity.viewModel.savedBitmapUri.value).isNotNull()

        activity.viewModel.savedBitmapUri.removeObserver(testObserver)
    }

上記はRobolectric版のコードですが、AndroidTest(InstrumentationTest)版もほぼ同じです。
ただ、パーミッションルールを実行APIレベルに応じて変える必要があります。マニフェストファイルにない権限があると失敗するからです。APIレベル29以上ではWRITE_EXTERNAL_STORAGEは不要になったので削除します。

InstagramShareActivityTestI.kt
    @get:Rule
    var grantPermissionRule = if (Build.VERSION.SDK_INT > 28) {
        GrantPermissionRule.grant(
            Manifest.permission.READ_EXTERNAL_STORAGE
        )
    } else {
        GrantPermissionRule.grant(
            Manifest.permission.WRITE_EXTERNAL_STORAGE,
            Manifest.permission.READ_EXTERNAL_STORAGE
        )
    }

ViewModelのテストを実行してみましょう・・・と言いたいところですが、これだけでは実はテストが失敗してしまいます。

RobolectricのEnvironment.getExternalStorageState()MOUNTEDを返してくれないようで、InstagramShareViewModel#isExternalStorageMountedが常にfalseになってしまうからです。

一体どうしたものか・・・?

Koinを使ってDIしましょう!
Koinってなんだっけ?DIって何だったっけ?という方は、以下などで思い出して下さい^^;
Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(7)リファクタリングとKoinでDI編

3.3.3 EnvironmentProviderを作りKoinでDIする

まず、元となるインターフェースと、アプリの方で使うクラスを定義します。
場所はどこでも良いですが、私はmodules.ktにしました。

modules.kt
// Environmentチェックを提供するプロバイダ
interface EnvironmentProviderI {
    fun isExternalStorageMounted() : Boolean
}

class EnvironmentProvider : EnvironmentProviderI{
    override fun isExternalStorageMounted(): Boolean {
        return Environment.getExternalStorageState()== Environment.MEDIA_MOUNTED
    }
}

InstagramShareViewModelのコンストラクタに引数を追加します。

InstagramShareViewModel.kt
class InstagramShareViewModel(application: Application,
                              private val environmentProvider: EnvironmentProviderI)
    : AndroidViewModel(application) {

また、外部ストレージのマウントチェックを変更します。

InstagramShareViewModel.kt
   // これは削除
//   private fun isExternalStorageMounted() =
//        Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()

   private fun saveBitmap(bitmap: Bitmap, displayName: String): Uri? {
        // 外部ストレージが使えるかチェック
        if (!environmentProvider.isExternalStorageMounted()) return null
        ...

最後に、Koinモジュールの定義を変更します。

modules.kt
// ViewModel
val viewModelModule = module {
    ....
    viewModel { InstagramShareViewModel(androidApplication(), get()) }
}

...

val providerModule = module {
    factory { CalendarProvider() as CalendarProviderI }
    factory { EnvironmentProvider() as EnvironmentProviderI }
}

これでDIでEnvironmentProviderIを受け取れるようになりました。

3.3.4 テスト用のEnvironmentProviderを作って置き換える

MockModules.ktに以下のように追加しました。

MockModules.kt
// テスト用にモックするモジュール
val mockModule = module {
    ...

    single(override = true){
        MockEnvironmentProvider() as EnvironmentProviderI
    }
}

...

// EnvironmentProviderのモッククラス
class MockEnvironmentProvider : EnvironmentProviderI {
    override fun isExternalStorageMounted(): Boolean = true
}

テストクラスに@Before関数を作って、モックモジュールを読み込むようにします。

InstagramShareViewModelTest.kt
    @Before
    fun setUp() {
        loadKoinModules(mockModule)
    }

InstagramShareActivityのテストにも同様に追加しておきましょう。

これでテストを実行すると、2回、テストが実行されたと思います。
結果表示はこんな感じかと。

config_test.png

3.3.5 Mockitoライブラリの追加

さて、APIレベル28と29でテストを実行し、通過しました。でも、本当にそれぞれAPIレベルに応じた処理を呼んでくれているんでしょうか?

このチェックをするために、モック化ライブラリとして有名な、Mockitoというのを使ってみます。
読み方は「モヒート」です。サイトの画像を見て分かるとおり、お酒(カクテル)のモヒートですね^^

まずライブラリを追加します。

app/build.gradle
dependencies {
    ...
    // Mockito
    testImplementation 'org.mockito:mockito-inline:3.7.7'
}

Kotlinを使っている場合、coreライブラリではなく、inlineの方を使うのが良いようです。

3.3.6 関数が呼ばれた回数をチェックする

条件に応じて、該当の関数がちゃんと呼ばれたかをチェックするのに、Mockitoにある、「関数が呼ばれた回数をチェックする」というものを利用します。

手順としては、まず対象の以下の関数は中の処理はいらないので、モック化してしまいます。

  • saveBitmapOver28
  • saveBitmapUnder29

また、これらの関数はprivate宣言を削除し、@VisibleForTestingを付けておきます。
そしてcreateShareImageを呼び、上記の関数が期待回数ちゃんと呼ばれたかをチェックします。

テスト関数は次のようになります。

InstagramShareViewModelTest.kt
@RunWith(AndroidJUnit4::class)
@Config(sdk = [Build.VERSION_CODES.P, Build.VERSION_CODES.Q])
class InstagramShareViewModelTest : AutoCloseKoinTest() {
    ....

    @Test
    fun createShareImage_VersionDispatch() {
        val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.RGB_565)

        // ViewModelインスタンスをモック化する
        val mocked = Mockito.spy(viewModel)

        val resultUri = "contet://foo/bar".toUri()

        // 特定の関数の戻り値をダミーにする
        Mockito.doReturn(resultUri).`when`(mocked)
            .saveBitmapOver28(any(Bitmap::class.java), Mockito.anyString())
        Mockito.doReturn(resultUri).`when`(mocked)
            .saveBitmapUnder29(any(Bitmap::class.java), Mockito.anyString())

        runBlocking {
            mocked.createShareImage(bitmap)
        }

        // resultの確認と、バージョン別に正しい関数が呼ばれているかのチェック
        assertThat(mocked.savedBitmapUri.value).isEqualTo(resultUri)

        if (Build.VERSION.SDK_INT > 28) {
            Mockito.verify(mocked, Mockito.times(1))
                .saveBitmapOver28(any(Bitmap::class.java), Mockito.anyString())
            Mockito.verify(mocked, Mockito.times(0))
                .saveBitmapUnder29(any(Bitmap::class.java), Mockito.anyString())
        } else {
            Mockito.verify(mocked, Mockito.times(0))
                .saveBitmapOver28(any(Bitmap::class.java), Mockito.anyString())
            Mockito.verify(mocked, Mockito.times(1))
                .saveBitmapUnder29(any(Bitmap::class.java), Mockito.anyString())
        }
    }
}

fun <T> any(clazz: Class<T>): T {
    return Mockito.any(clazz)
}

val mocked = Mockito.spy(viewModel)は、もとのオブジェクトを複製して特定の関数だけモック化することが出来ます。モック化しなかった関数については、本来のコードが実行されます。
クラス内の関数を全部まるまるモック化するには、Mockito.mock(SomeClass::class.java)を使います。この場合には、全ての関数が空でかつnullを返すようになります。

その後のInstagramShareViewModelの関数へのアクセスはモック化したオブジェクトmockedから行います。

Mockito.doReturn(resultUri).`when`(mocked).saveBitmapOver28は、spyした関数の戻り値をダミーに置き換えるコードです。
saveBitmapOver28が呼ばれたら、固定のresultUriを返すように上書きしています。実際の関数の処理は一切実行されません!

Kotlinではwhenが予約語なので、`when`と書く必要があります。
これがイケてないということで、kotlin向けに作成されたMockito-Kotlinというライブラリもありますので、気になる方はそちらを使ってみても良いでしょう。

先ほどの2つの関数の戻り値を書き変え、createShareImageを呼んだ後で、戻り値がそれと一致していること、また、それぞれのAPIレベル毎に、呼ばれた関数が正しいかを、それぞれ呼ばれた回数をチェックすることで判断しています。

なお、anyは、Mockito.anyではなく、ラップした関数を使います。これをしないと、java.lang.IllegalStateException: Mockito.any() must not be nullというエラーが発生してしまいます。
こちらを参考に、NonNullを返すようにラップすることでそれを回避しています。
https://www.yo1000.com/kotlin-mockito-any

なお、この現象もMockito-Kotlinを使うと回避出来るようですので、興味ある方は是非書き変えにチャレンジしてみて下さい。

最後に、Activityのテストも、Robolectric版またはAndroidTest版でAPIレベル28と29の端末(またはエミュレーター)で実行して通過するのを確認すれば、この対応は完了です。

まとめ

この記事で書いてきたアプリでは、Target29対応で必要だったのはScoped Storageにのみ対応が必要でしたが、場合によっては他の対応も必要になってくることがあります。
Googleの変更点の記事を読んだだけではピンときていなくても、実際にビルドして動かしてみると影響があった、ということは良くあります。思いのほか対応に工数が取られることもありますので、TargetAPIレベルを上げる対応は計画的に行う必要があります。APIレベルを上げるのは、アプリのアップデートをリリースしない限りはしなくても良いものではありますが、例えば不具合を直して緊急リリースしなければならないようなときに、TargetAPI対応を急いでやらなければならなくなる羽目になります。なので、前もって十分に検証の時間を取って準備をして対応しておくようにしましょう。

  • TargetAPIを上げる手順、必要になる作業について学びました。
  • MediaStoreAPIの使い方を学びました。
  • Mockitoライブラリを使って一部の処理をモック化したり、関数が呼ばれた回数をチェックする方法を学びました。

オマケ

  • Koinの復習をしました。

なお、今回は、その他のライブラリのバージョンアップはしていませんが(もしかしたらうっかりショートカットキー叩いて上がっちゃってるのはあるかも:sweat_smile:)、可能であればこういった機会に最新版に揃えていく方が良いかなと思います。ただ、無闇に上げるのではなく、各ライブラリのRelease noteくらいはざっと目を通して、バージョンアップして大丈夫そうかはチェックしましょう。

ここまでの状態のプロジェクトをGithubにpushしてあります。

※CIの都合上、以下のAppendixの項目でやったAndroid Gradle Plugin/Gradleバージョンのアップまでした状態のものが上がっていますが、据え置いたままでもビルド・テストは通ります。

予告

今度こそ・・・RoomからFirestoreへの保存に変換を・・・

でも実はRoomもバージョンが上がって少し変わっているんですよね・・・
もしかしたらそちらをやるかも知れません。

Appendix

1. ビルド環境をJava9以上にする場合

本記事では、特定の単体テストの実行環境のみJavaバージョンを変更していましたが、全体を変えて問題ない場合は、Edit Configurations...Templateを変えてしまっておくのがよいでしょうね。

ただ、Project StructureJDK locationは変更してはいけません!

JDKlocation.png

ここをJava9以上にしてしまうと、こんなエラーが出て、なんとビルドが通らなくなります。

android java.lang.NoClassDefFoundError: javax/xml/bind/JAXBException

どうやらLintがJava9以上に対応していないようです。
(JAXBはJava9から同梱されなくなっています)
とりあえず、Android Studioからのテストだけする場合は、テストの実行環境だけを変えるようにしましょう。

でも、CI/CDなどを自動で回している場合はそれでは困ってしまいますね。
なんとかJava9以上で実行できる方法は無いでしょうか?とおもって検索したら、以下のサイトが見つかりました。
必要なライブラリを自力でdependenciesに追加していく手法ですが、これでなんとか出来そうですね。

それから、Android4.2からBundleされるJavaが11になっているそうで、いっそのことそれらも全部最新にすれば解消されるのでしょうね。それでもやはり、CIで回すときはAndroid Studio関係ないし、どうすれば?と思って更に検索していると、以下の記事に辿り着きました。

どうやら、Android Gradle Plugin(AGP)を4.0以上に上げられれば、Java9でビルドが出来るようです。
Android Studio4.1.2(Stableの最新版)だと、AGPの最低バージョンが4.1.2となるようですから、これを機にAndroid Studioもバージョンアップしても良いでしょうね。

ということでAndroid Studio,AGPなどのバージョンアップもして、ビルド・テストしてみましたが、1点だけ対応した以外は問題無さそうでしたので、リポジトリには上げた状態のものが入っています。

対応したのは、gradle.propertiesにあった以下の記述を削除したことです。

gradle.properties
android.enableUnitTestBinaryResources=true

そういえばdeprecateになってるよって以前から警告は出ていた気がします:sweat:

最終的に、環境は以下のようになりました。

ツールなど バージョンなど
MacbookPro macOS Catalina 10.15.7
Android Studio 4.1.2
Java(JDK) openjdk version "11.0.10"

2. CI環境をJava9以上にする

私の環境では、UnitTestとRobolectricTestをCircleCIで、AndroidTestも含めた全テストをGithub Actionsで動くように設定しています。
記事は、以下の辺りをご参照ください。

CircleCIはエミュレーターテストに対応していないので(簡単にできない)外しています。

上記でビルド環境もJava9以上に対応したら、CIの方も設定を変えなければなりません。

2.1 Github Actionsの場合

actions/setup-javaを使っている箇所でのバージョン指定を任意のものに変えればよいです。

android.yml
    steps:
    - uses: actions/checkout@v2
    - name: set up JDK 11
      uses: actions/setup-java@v1
      with:
        java-version: 11

キャッシュなど使っている場合は一度削除など?が必要になるかも知れませんので、ご注意下さい(使ったことがないので想像ですが)。

2.2 CircleCIの場合

先ほども紹介した以下のページにあるとおり、既にCircleCIのAndroid Docker ImageはJava11になっています。
https://nashcft.hatenablog.com/entry/2020/08/22/185518

なので、むしろJava8に据え置いておきたい場合が大変ですが、上記ページにその情報もありますので、参考にしてください。

2.3 Jenkinsの場合

こちらは通常通りJenkinsマシンにJava9以上をインストールして、JAVA_HOME等を書き変えてやれば良いでしょう。
ただし、他のテストなどに影響でないように確認はしてください。

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5