fresh digitable

めんどくさかったなってことを振り返ったり振り返らなかったりするための記録

自作のViewGroupをRobolectricでテストしつつ開発する その2

つづきです: akihito104.hatenablog.com

fragment-testingを使おう

前回の記事では空のActivityにカスタムビューをアタッチしてからテストしているが、androidxのfragment-testingというライブラリを使うのが楽そうというか正しそうなのでこちらを使いましょう。

developer.android.com

やることは、前回の記事で空のActivityを作ってやっていることを空のFragmentに置き換えてやるだけ。

おまけ: Robolectric.buildAttributeSet()

レイアウトリソースでカスタムアトリビュートを指定したときの挙動をテストしたいということがある。ただ、テストでテスト用のレイアウトリソースファイルを読み込ませるのも一苦労なので、Viewに渡すAttributeSetのモックオブジェクトを作るのがいいらしい。Robolectricにはそのためのビルダーがあり、属性のIDと値のテキストをペアで渡す。

Robolectric.buildAttributeSet()
  .addAttribute(R.attr.foo, "bar")
  .addAttribute(R.attr.foo_color, "@color/foo_color")     // レイアウトリソースに書く時のように指定する
  .build()

投稿する写真をカメラアプリで撮って送るついでにギャラリーアプリから見えるようにする(API Level 22+)

今回やりたいこと

前提

  • target SDK version: 29
  • Android 10で動作を確認した
  • androidx.activity:activity-ktx:1.2.0-beta01
  • androidx.fragment:fragment-ktx:1.3.0-beta01

カメラアプリを起動するインテント

カメラアプリのインテントActivityResultContracts.TakePictureを使えばよい。

インテントを作るときに渡すUriFileProvider.getUriForFile()を使って作る。content://からはじまるUriが取得できる。カメラアプリは撮影に成功してもファイルパスを返しては来ないのでこのUriは保存しておく。FileProviderの設定はリファレンスが詳しい。

developer.android.com

今回はexternal-media-pathと、古いバージョン(API Level 21未満)のためにexternal-file-pathも指定した。また、authorityの名前にアプリケーションIDを使いたかったのでアプリモジュールで実装してDIで注入する形にした。

カメラアプリにファイルの書き込み権限を渡したい

リファレンスによると二つの方法があるらしい。

FileProvider  |  Android Developers

結論から言うと二つ目のInclude the Permission in an Intentの方法は、Intent Chooserを出したい場合にはうまく動かない様子。

stackoverflow.com

したがって一つ目のGrant Permission to a Specific Packageの方法で実現することになる*1。ただし、権限を渡したいアプリのパッケージ名が必要なので何とかして入手しなければならない。API Level 22以上であればIntent.createChooser(Intent, CharSequence, IntentSender)を使うことで、Intent Chooserで選択されたアプリがBroadcastReceiverで取得できる。 API Level21以下で同じことをしなければならない時は、選択される可能性のあるカメラアプリをPackageManager.queryIntentActivities()で取得し、それらすべてに対して許可するしかないと思われる。いずれの場合においても権限をrevokeしそびれることが無いよう注意する。

画像をギャラリーから見えるようにする

この記事を書いている現在、公式の Take photos  |  Android Developers に書いてあるIntent.ACTION_MEDIA_SCANNER_SCAN_FILEを投げる方法は非推奨になっている。なので、今回はMediaScannerConnection.scanFile()を使った。これだとContextが必要なのでActivityResultContract.parseResult()とかの中でちょろっとやってしまうのは難しいかもしれない。また、これに渡すファイルパスはcontent://の形式のものではなく、FileProvider.getUriForFile()に渡したFileのパスを渡す。

このほかにも、カスタムのContractを作ったらワケあってDI(Dagger)でFragmentにContractを注入することになり、registerForActivityResult()onAttach()のなか(というかAndroidSupportInjection.inject()のあと)で呼ぶ羽目になったとかいろいろ細かいことはあるが、細かいのでこの辺にしておく。

*1:StackOverflowの回答ではqueryIntentActivities()をやって明示的なIntentにして解決しているがこっちの方法では試していない。こっちでうまくいくならその方がいいかも。

fluxとかMVIみたいな構造のアプリを作ってみたかった その4

前の記事: akihito104.hatenablog.com

このツイートに関連して困ったことが起きている。

以前載せたクラス図の左下にNavigationDelegateというクラスがある。

https://user-images.githubusercontent.com/9658489/91858210-d8d6c680-eca3-11ea-802f-bb4a9e01ce2a.png

このNavigationDelegateはandroidxのNavControllerを持っており、ViewStatesで行った処理の結果を受けて画面遷移を行う。また、NavControllerのOnDestinationChangedListenerを介して現在のNavHostFragmentの変更通知を受け取り、これを監視可能な形でViewStatesに提供している。

NavigationDelegateはDIからActivityをコンストラクタインジェクションされ、必要になった時にActivityからNavControllerを取り出している。クラス図を見てわかる通り、NavigationDelegateは間接的にViewModelの持ち物になっている。これが問題で、Activityの再生成が起きるとき、NavigationDelegateが弱参照で持っているActivityは無くなって参照できなくなるのでNavigationDelegateごと作り変える必要があるのだが、ViewModelはActivityのライフサイクルを超えて再利用されるのでNavigationDelegateの再生成は行われない。

思いつく解決策としては次の三つ。

  • ViewModelをやめる: 根本的な解決策だがすべてのデータ管理を確認する必要がありちょっと大変。
  • ApplicationLivecycleCallbacks経由で新しいActivityを受け取れるようにする: Activityを注入する代わりにApplicationLivecycleCallbacksを実装しているクラスを注入して常に最新のActivityインスタンスを参照できるようにする。既存のコードはあんまり変えなくてよさそうだけどうまくいくのかよくわからん。
  • NavigationDelegateはActivityに持たせて、ViewStatesから発行されたナビゲーションに関するイベントを待ち受ける。NavHostの状態はRepositoryとかに適当に突っ込む。よう知らんけどAndroidアプリらしいような気がする。

2020/11/01 解決策についていくらか追記した。

次の記事: akihito104.hatenablog.com