言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

Scoped Storageでパス指定してファイルにアクセスできなくなった

はじめに

見て見ぬふりをしてきたScoped Storageにハマったのでメモ書きを残す。

Android Q (10) から導入されたScoped Storage。「端末内部のfileやらのアクセス権限周りや保存領域が変わったんだろうなー」くらいの雑な理解で見て見ぬふりを続けてきたが思わぬところでハマってしまった。

Glideで画像を表示できなくなった

以前は動いていたコードが動かなくなった。具体的にはContentResolver経由で MediaStore.Video.Media.DATA のStringを取ってきて、Glideのinto()に渡してもpermissionがないというerrorが出て画像が表示されない。

val PROJECTION = listOf(
    MediaStore.Video.Media.DATA,
)

val cursor = context.contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION.toTypedArray(),
        null, null, MediaStore.Video.Media.DATE_MODIFIED)

if (cursor.moveToLast()) {
    do {
        val data = cursor.getString(cursor.getColumnIndex(PROJECTION[0]))
    } while (cursor.moveToPrevious())
}
cursor.close()
Glide.with(context.applicationContext)
    .load(data)
    .into(imageView) // 表示されない

AndroidManifestに android:requestLegacyExternalStorage="true" をつけてやると上記のコードでも動くので、Scoped Storage周りで問題が起きているのは間違いない。ちなみに、android:requestLegacyExternalStorage="true"でとりあえずこの問題は回避できるが、

警告: 来年度のメジャー プラットフォーム リリースでは、ターゲット SDK レベルに関係なく、すべてのアプリで対象範囲別ストレージが必須となります。

とあるので早めに対処することをお勧めする。

developer.android.com

^ 上記ページはなぜか言語設定を英語にすると別のページに転送される。同じ内容の英語リソースが見つけられなかった。

対処法

Scoped Storageが導入に伴い、MediaStore.Video.Media.DATAがdeprecatedになった。代わりにBaseColumns._IDを取得し、ContentUris.withAppendedId()を使ってUriを取得すれば行ける。

参考

medium.com

以下コード例

val PROJECTION = listOf(
        BaseColumns._ID,
)

val cursor = context.contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION.toTypedArray(),
        null, null, MediaStore.Video.Media.DATE_MODIFIED)

if (cursor.moveToLast()) {
    do {
        val idColumnIndex = cursor.getColumnIndex(PROJECTION[0])
        val contentUri = ContentUris.withAppendedId(
                MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
                cursor.getLong(idColumnIndex)
        )
    } while (cursor.moveToPrevious())
}
cursor.close()
Glide.with(context.applicationContext)
    .load(contentUri.toString) // 同じString型を渡しても今後は表示される
    .into(imageView)

何が起きているのか

せっかくなのでGlideの内部実装を追ってみた。(version: 4.11.0)

into()がコールされた後に画像のロードが走るが、その際にload()で渡されたstringのパターンを見ている。stringが '/'で始まるかどうかで処理が分岐する。

public class StringLoader<Data> implements ModelLoader<String, Data> {

  @Nullable
  private static Uri parseUri(String model) {
    Uri uri;
    if (TextUtils.isEmpty(model)) {
      return null;
      // See https://pmd.github.io/pmd-6.0.0/pmd_rules_java_performance.html#simplifystartswith
    } else if (model.charAt(0) == '/') {
      uri = toFileUri(model);
    } else {
      uri = Uri.parse(model);
      String scheme = uri.getScheme();
      if (scheme == null) {
        uri = toFileUri(model);
      }
    }
    return uri;
  }

  private static Uri toFileUri(String path) {
    return Uri.fromFile(new File(path));
  }

MediaStore.Video.Media.DATA でStringを取ってくる場合は/で始まるパスになっている。
例:

/storage/emulated/0/DCIM/Camera/VID_20200821_00000001.mp4

一方、BaseColumns._IDからUriを取得する場合は(当たり前だが)パスではなくUriになっている。
例:

content://media/external/video/media/12345

ここでの処理の分岐で画像が出たり出なかったりする。

前者はパスを指定してFileオブジェクトを生成しようとするが、ここでpermissionがないとしてerrorになる。下記リンクは先ほどと同じページだが、パス経由でファイルにアクセスできないと明記してある。繰り返しになるが、この情報は英語リソースで見つけることができない。誰か見つけたらリンク教えてください…

注: 対象範囲別ストレージするアプリは、「/sdcard/DCIM/IMG1024.JPG」のようなパスに対する直接カーネル アクセス権限を有していません。アプリがこのようなファイルにアクセスするには、MediaStore を使用して、openFile() などのメソッドを呼び出す必要があります。

developer.android.com

複数のsubscribersに対応したFlowとテストの書き方

MVVMアーキテクチャにおいて、一つのModelを複数のView Modelで共有している場合、あるイベントを複数のView Modelで同時に受け取りたいことがある。これをKotlin Coroutine Flowを使って書くとどうなるのかなぁと思ったのでやってみたメモ。テストも書いた。

前回のエントリでPaging Library 3の内部実装を読んだ際に同じようなことをやっているようなコード があったので参考にした。

muumuutech.hatenablog.com

実装例

例として「requestがあったらremote source (api serverとか)から何か値を取ってきてflowにemitする」ようなservice classを実装する。コード例はこちらのgistにまとめてある。

Example of Kotlin Coroutine Flow with multiple subscribers · GitHub

まずはクラス定義。コンストラクタ引数にCoroutineDispatcherを渡せるようにしておく。これはテストの時にTestCoroutineDispatcherでcoroutineを動かすために必要。デフォルト引数でDispatchers.IOを指定し、テスト以外では引数を指定しなくて良いようにしておいた。

class FooService(private val dispatcher: CoroutineDispatcher = Dispatchers.IO) {

privateなchannelを定義する。複数のsubscribersに対応できるようにConflatedBroadcastChannelを使う。

    @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
    private val fooChannel = ConflatedBroadcastChannel<Unit>()

上記のchannelをasFlow()を使ってflowに変換し、class外にexposeする。

    @OptIn(
        kotlinx.coroutines.FlowPreview::class,
        kotlinx.coroutines.ExperimentalCoroutinesApi::class
    )
    val fooFlow = fooChannel.asFlow().map { getFooFromRemoteSource() }.flowOn(dispatcher)

    private fun getFooFromRemoteSource(): String {
        val result = "result" // ここでremote sourceから値をfetchすることを想定
        return result
    }

これでchannelにeventが流れてきた時にmap節の中で変換した値をflowに流すことができる。今回はuserのclick動作など、引数がない場合を想定したのでUnitを流しているが、引数が必要であればUnitの代わりにそのtypeを定義する。mapで変換した後にflowOn()でどのdispatcherを使うか指定している。(この理由は上記で書いた通りtestの時に必要になるから。)

ここでmap節のなかでwithContext()などで動作するthreadを指定していると、testが動いているthreadと切り替えが発生し、処理が並列で動いた結果期待した挙動にならないことがある。(私はこれに気がつかずかなり時間を消費してしまった😩)

最後に、channelがprivateなので外からtriggerできるような関数を定義する。

    @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
    fun requestFoo() {
        fooChannel.offer(Unit)
    }

テスト例

上記のservice classのunit test codeを書いていく。

Coroutineのtestなので、test scopeとtest dispatcherを用意する。

    @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
    private val testScope = TestCoroutineScope()

    @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
    private val dispatcher = TestCoroutineDispatcher()

@Beforeなfunctionでテスト対象を初期化。

    private lateinit var fooService: FooService

    @Before
    fun setup() {
        fooService = FooService(dispatcher)
    }

requestFoo()がtriggerとなり、fooFlowに値が流れてくることを確認するテスト。

   @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
    @Test
    fun `requestFoo() should trigger getting foo and fooFlow emit foo`() {
        testScope.runBlockingTest {
            val foo = mutableListOf<String>()
            val job = launch {
                fooService.fooFlow.collect { foo.add(it) }
            }

            // ここではまだfooは空
            assertThat(foo).isEmpty()

            fooService.requestFoo()

            assertThat(foo).isEqualTo(listOf("result"))

            job.cancel()
        }
    }

TestCoroutineScope.runBlockingTestはまだまだ不安定という記事をどこかで見かけた気がしたが、自分の環境(1.3.7)では上記のコードは問題なく動いていた。ただ、この例だと特にdelayが発生しないのでrunBlockingTestを使う理由も特にない。気になる人はrunBlockingを使えば良いと思う。
また、fooをlistにするべきかは議論があるかもしれないが、個人的には「一度のtriggerで一回しか値が流れてこないこと」を確認できるのでlistで良いと思っている。

Paging Library 3の内部実装読んだメモ書き

はじめに

Paging Library 3が発表された際、Full Kotlinで書かれてCoroutineもたくさん使われているということで話題になった。せっかくなので内部実装を読んでみた時のメモを雑に残しておく。

今回みたバージョンは 3.0.0-alpha01

PagingLibraryをどう使うかは下記のCodelabを参考にした。

codelabs.developers.google.com

学びメモ

  • 今までRxJavaで「Signalをemitしたい(emitされるvalueに意味はない場合 e.g. ユーザのclickを検出したい時とか)場合」にboolean trueとか適当な値を流していたけど、Kotlin CoroutineのflowでUnit型をemitしているコードがあってなるほどなぁと思った。
  • FlowonStart()onCompletion(), onEmpty() などの拡張関数が生えている。onEmpty()ってどういう時に使いたくなるんだろう
  • class内部では ConflatedBroadcastChannel を使っていて、拡張関数ConflatedBroadcastChannel.asFlow()を使って外部に提供している部分がたくさんある
  • 上記で提供されるflowは拡張関数 Flow.asLiveData() を使ってLiveDataに変換することができる
  • LiveData.switchMap() を使ってMutableLaveData A にvalue a がpostされたタイミングでそのvalueを使って何か処理を行い Mutable Live Data B にtransformしたvalue b をpostすることができる
  • Flow.scan() 便利。initial valueにnullを入れる場合は null as MulticastedPagingData<T>? のようにnullをcastして渡していた
  • MergedAdapterは次のversionくらいでConcatAdapterに変更されるっぽい。PagingDataAdapter.withLoadStateHeaderAndFooter()MergedAdapterを返却しているので注意。(rename前の今も実際の挙動はmergeではなくconcat)

実装の詳細メモ

1. PagingDataAdapter

  • RecyclerView.Adapter をextendしている
  • submitData() がトリガーとなってdetaがセットされる
    • 引数は PagingData 型になっている。
      • PagingDataFlow<PageEvent<T>> 型であるflowとUiReceiverをもつ
      • PagingDataの生成はPager.flow から取ってくることができる
        • Pager.flowPageFetcher.flow から作られている
          • PageFetcher.flow はKotlin Coroutineがchainになっていて読んでて楽しい。
    • 実際の処理は AsyncPagingDataDiffer に移譲している
  • Flow<Unit> 型の dataRefreshFlow が生えていてアプリケーションはこれをcollectしてdataの更新を検知することができる

2. AsyncPagingDataDiffer

  • PagingDataDiffer を持っていてdiffを計算してくれる
    • diffの計算は Dispatchers.Main のcontextで走る
  • Kotlin coroutineのjobを持っていて複数のdiff計算が走らないように制御してくれる

3. PagingDataDiffer

  • abstract classなので、実際はこれを実装したobjectが AsyncPagingDataDiffer クラスに定義されている
  • suspend funである collectFrom() を持っている。PagingDataのflowからデータを取得してDispatchers.Main のcontextでdiffを計算しPageEventの種類によってPagePresenterにinsert/dropのeventを処理させる。

4. PagePresenter

  • page情報を保持するクラスで NullPaddedListを実装している
  • PresenterCallback というinterfaceを持っていて、dataが更新されるとこのcallbackが発火する。 イベント発火後、AsyncPagingDataDifferPresenterCallback はrecycler viewの ListUpdateCallback に変換されてリストが更新される。

MotionLayoutの覚書き

はじめに

MotionLayoutが発表されて随分経った。「Android Studio 4系が必須でMotion Editorが来るのを待っていた」という言い訳で長い間スルーしていたが、そろそろ4系が安定して来たという話をちらほら聞くのでちょっとさわってみた。結果、Motion Editorなくても全然xml tag手書きでいけるということがわかったのでメモを残す。

ちなみにさわったのはこちらのcodelab。

codelabs.developers.google.com

MotionLayoutの基本

  • ConstraintLayoutを継承したMotionLayoutをparent viewとする
  • MotionLayoutのtag内にapp:layoutDescription="@xml/foo"といったようにMotionSceneを定義したxmlファイル名を指定する
  • MotionSceneにはTransitionConstraintSetを定義

Transition

  • Transitionのtag内にapp:constraintSetStart="@+id/start"みたいな感じでanimationのstart/endのConstraintSetを指定
  • Animationの契機となるOnClickOnSwipeなどはTransitionの中で定義する
  • animationのstart/endの間のPathを定義したい場合はTransitionの中でKeyFrameSetとその中にKeyPositionを定義する
  • KeyPosition
    • keyPositionTypeでどのようにMotionが変化するか指定する
    • 各typeについてはここを参照
      • parentRelativeはscreenと同じく左上が(0, 0)
  • animationのstart/endの間のview propertyを変えたい場合は KeyAttributeを定義する
    • KeyAttributeの中でCustomAttributeを設定することができる
    • CustomAttributeapp:attributeNameには対象クラスに定義されたsetter nameを指定する。(setXxxYyy()を使いたい場合はapp:attributeName="XxxYyy"になる。)

ConstraintSet

  • ConstraintSetの中ではanimationしたいviewごとにConstraintを定義
  • Constraintandroid:idには対象のviewを指定
  • layout fileに書いた各attributeはConstraintSetのxmlに定義したものに上書きされてしまう。 1そのため、android:layout_widthandroid:layout_heightを各Constraintで指定する必要がある。

覚えておきたい便利な機能・Tips

  • app:motionDebug="SHOW_PATH"MotionLayout のtagに入れることでmotionのpathをアプリ上で表示することができる
  • touchAnchorSideは基本的に一直線に進む方向を指定する必要がある(そうでないとanimationがおかしくなる場合がある)が、複雑なpathを書きたい場合はどの方向にも一直線に進まないケースがある。その場合はinvisibleなviewを一つ作ってそいつをanchorにするとうまくいく。
    • あるいはapp:dragDirectionを追加で指定することで解決するかもしれない
  • コードからmotionを動かしたいときはMotionLayout#setProgress(float pos)を使う
  • 既存のConstraintLayoutをMotionLayoutにconvertしたい場合は、下記手順で自動でconvertしてくれる
    1. layout xmlファイルを開く
    2. design serfaceを選択
    3. preview画面上で右クリック
    4. Convert to Motion Layout`を選択
  • ConstraintSetを使う時は全てのchildにidつけてやらないとDesign tabのpreviewもうまくrenderされない。またapp実行時に下記のRuntimeExceptionがthrowされる

    java.lang.RuntimeException: All children of ConstraintLayout must have ids to use ConstraintSet

KoinのDIの仕組み

はじめに

みなさん、Koinはお好きだろうか?

KoinとはKotlin用の軽量DI Frameworkである。

insert-koin.io

基本的には下記の3 stepでお手軽にDIの仕組みを提供してくれる。

  1. moduleの宣言
  2. Koinのstart
  3. 任意の場所でinject

Injectする際にinject()というcustomのproperty delegateを使うことができる。おしゃれだ。

val service : BusinessService by inject()

このinject()の中身で何をしているか気になってコードをのぞいて見た

inline fun <reified T : Any> ComponentCallbacks.inject(
        qualifier: Qualifier? = null,
        noinline parameters: ParametersDefinition? = null
) = lazy(LazyThreadSafetyMode.NONE) { get<T>(qualifier, parameters) }

lazy()でinstance生成するのもまた良い。といういのは置いておいて、このget()の先で何が起こっているのか気になってコードを読んでみた。

注意事項

今回は2020/5/4時点での最新バージョンである2.1.5のコードを読んでいく。 KoinはOSS projectであり、ソースコードGitHubホスティングされている。

github.com

DIの仕組みを読んでいく

1. moduleの宣言

moduleの宣言は下記のように行う。(公式のsample codeより抜粋)

// Given some classes 
class Controller(val service : BusinessService) 
class BusinessService() 

// just declare it 
val myModule = module { 
  single { Controller(get()) } 
  single { BusinessService() } 
} 

module() というDSLの中身はこんな感じ。

fun module(createdAtStart: Boolean = false, override: Boolean = false, moduleDeclaration: ModuleDeclaration): Module {
    val module = Module(createdAtStart, override)
    moduleDeclaration(module)
    return module
}

ModuleDeclarationはtypealiasである。実体はlambdaである。

typealias ModuleDeclaration = Module.() -> Unit

というわけでmodule定義時にlambdaで渡している2行のsingle()というDSLが実行される。
single()の定義はこんな感じ。

    inline fun <reified T> single(
            qualifier: Qualifier? = null,
            createdAtStart: Boolean = false,
            override: Boolean = false,
            noinline definition: Definition<T>
    ): BeanDefinition<T> {
        return Definitions.saveSingle(
                qualifier,
                definition,
                rootScope,
                makeOptions(override, createdAtStart)
        )
    }

Definitions.saveSingle()の実装を追っていくとBeanDefinitionというclassのinstanceを生成してscopeのdefinitionに登録したのちBeanDefinitionを返却している。(singleは定義の実装から分かるようにroot scopeに登録される。)

    inline fun <reified T> saveSingle(
        qualifier: Qualifier? = null,
        noinline definition: Definition<T>,
        scopeDefinition: ScopeDefinition,
        options: Options
    ): BeanDefinition<T> {
        val beanDefinition = createSingle(qualifier, definition, scopeDefinition, options)
        scopeDefinition.save(beanDefinition)
        return beanDefinition
    }

BeanDefinitionが何かと言うとdata classである。

data class BeanDefinition<T>(
        val scopeDefinition: ScopeDefinition,
        val primaryType: KClass<*>,
        val qualifier: Qualifier? = null,
        val definition: Definition<T>,
        val kind: Kind,
        val secondaryTypes: List<KClass<*>> = listOf(),
        val options: Options = Options(),
        val properties: Properties = Properties(),
        val callbacks: Callbacks<T> = Callbacks()
)

上記のようにDI対象のKClassやらqualifierやらを保持している。このBeanDefinitionScopeDefinitiondefinitionsにSetとして保持される。

class ScopeDefinition(val qualifier: Qualifier, val isRoot: Boolean = false, private val _definitions: HashSet<BeanDefinition<*>> = hashSetOf()) {

    val definitions: Set<BeanDefinition<*>>
        get() = _definitions

    fun save(beanDefinition: BeanDefinition<*>, forceOverride: Boolean = false) {
        if (definitions.contains(beanDefinition)) {
            if (beanDefinition.options.override || forceOverride) {
                _definitions.remove(beanDefinition)
            } else {
                val current = definitions.firstOrNull { it == beanDefinition }
                throw DefinitionOverrideException("Definition '$beanDefinition' try to override existing definition. Please use override option or check for definition '$current'")
            }
        }
        _definitions.add(beanDefinition)
    }

2. Koinのstart

AndroidであればApplicationクラスのonCreate()、Kotlinであればmain()の中でstartKoin()DSLを読んでKoinをstartさせる。

fun main(vararg args : String) { 
  startKoin {
    modules(myModule)
  }
} 

modules()DSLModuleのvarargもしくはList<Module>をparameterにとるが、どちらにしろ最終的にmodules(modules: List<Module>)がコールされる。

    fun modules(modules: List<Module>): KoinApplication {
        if (koin._logger.isAt(Level.INFO)) {
            val duration = measureDuration {
                loadModules(modules)
            }
            val count = koin._scopeRegistry.size()
            koin._logger.info("loaded $count definitions - $duration ms")
        } else {
            loadModules(modules)
        }
        if (koin._logger.isAt(Level.INFO)) {
            val duration = measureDuration {
                koin.createRootScope()
            }
            koin._logger.info("create context - $duration ms")
        } else {
            koin.createRootScope()
        }
        return this
    }

loggerの設定で条件分岐をやっているけれど、基本的にはやることはloadModules()createRootScope()の2点。

まずはloadModules()をみていく。このfunctionを辿っていくとKoin#loadModules()にたどり着く。

    fun loadModules(modules: List<Module>) = synchronized(this) {
        _modules.addAll(modules)
        _scopeRegistry.loadModules(modules)
    }

_modulesModuleのHashSetなのであまりここでは気にしない。
ScopeRegistry#loadModules()はmodules listの要素である各moduleをparameterにしてloadModule()をコールしている。

    private fun loadModule(module: Module) {
        declareScope(module.rootScope)
        declareScopes(module.otherScopes)
    }

    private fun declareScope(scopeDefinition: ScopeDefinition) {
        declareDefinitions(scopeDefinition)
        declareInstances(scopeDefinition)
    }

declareDefinitions()ScopeRegistryのメンバ変数のHashMapにScopeDefinitionを追加する。

次にdeclareInstances()。まずはScopeRegistryが保持しているscopeのHashMapからparameterとScopeDefinitionが一致するscopeを見つけ出す。ScopeクラスはInstanceRegistryを保持しており、ここにscopeDefinitionが保持しているBeanDefinition (1の最後で追加したやつ)を登録する。

    fun saveDefinition(definition: BeanDefinition<*>, override: Boolean) {
        val defOverride = definition.options.override || override
        val instanceFactory = createInstanceFactory(_koin, definition)
        saveInstance(
                indexKey(definition.primaryType, definition.qualifier),
                instanceFactory,
                defOverride
        )
        definition.secondaryTypes.forEach { clazz ->
            if (defOverride) {
                saveInstance(
                        indexKey(clazz, definition.qualifier),
                        instanceFactory,
                        defOverride
                )
            } else {
                saveInstanceIfPossible(
                        indexKey(clazz, definition.qualifier),
                        instanceFactory
                )
            }
        }
    }

    private fun saveInstance(key: IndexKey, factory: InstanceFactory<*>, override: Boolean) {
        if (_instances.contains(key) && !override) {
            error("InstanceRegistry already contains index '$key'")
        } else {
            _instances[key] = factory
        }
    }

ちなみにsaveInstance()の1st parameterのためにコールしているindexKey()はclassのfull nameとqualifier(もしあれば)の組み合わせのStringになっている。

fun indexKey(clazz: KClass<*>, qualifier: Qualifier?): String {
    return if (qualifier?.value != null) {
        "${clazz.getFullName()}::${qualifier.value}"
    } else clazz.getFullName()
}

3. 任意の場所でinject

「はじめに」で記載した通り、inject()のproperty delegateはlazyでget()をコールしている。この先を読んでいく。

inline fun <reified T : Any> ComponentCallbacks.inject(
        qualifier: Qualifier? = null,
        noinline parameters: ParametersDefinition? = null
) = lazy(LazyThreadSafetyMode.NONE) { get<T>(qualifier, parameters) }
inline fun <reified T : Any> ComponentCallbacks.get(
        qualifier: Qualifier? = null,
        noinline parameters: ParametersDefinition? = null
): T = getKoin().get(qualifier, parameters)

Koin##get()をみてみると、

    @JvmOverloads
    inline fun <reified T> get(
        qualifier: Qualifier? = null,
        noinline parameters: ParametersDefinition? = null
    ): T = _scopeRegistry.rootScope.get(qualifier, parameters)

Scope#get()はこんな感じ。

    inline fun <reified T> get(
        qualifier: Qualifier? = null,
        noinline parameters: ParametersDefinition? = null
    ): T {
        return get(T::class, qualifier, parameters)
    }
    fun <T> get(
        clazz: KClass<*>,
        qualifier: Qualifier? = null,
        parameters: ParametersDefinition? = null
    ): T {
        return if (_koin._logger.isAt(Level.DEBUG)) {
            val qualifierString = qualifier?.let { " with qualifier '$qualifier'" } ?: ""
            _koin._logger.debug("+- '${clazz.getFullName()}'$qualifierString")
            val (instance: T, duration: Double) = measureDurationForResult {
                resolveInstance<T>(qualifier, clazz, parameters)
            }
            _koin._logger.debug("|- '${clazz.getFullName()}' in $duration ms")
            return instance
        } else {
            resolveInstance(qualifier, clazz, parameters)
        }
    }
    @Suppress("UNCHECKED_CAST")
    private fun <T> resolveInstance(
        qualifier: Qualifier?,
        clazz: KClass<*>,
        parameters: ParametersDefinition?
    ): T {
        if (_closed) {
            throw ClosedScopeException("Scope '$id' is closed")
        }
        //TODO Resolve in Root or link
        val indexKey = indexKey(clazz, qualifier)
        return _instanceRegistry.resolveInstance(indexKey, parameters)
            ?: findInOtherScope<T>(clazz, qualifier, parameters) ?: getFromSource(clazz)
            ?: throwDefinitionNotFound(qualifier, clazz)
    }

Index keyを生成し、Scopeが保持しているInstanceRegistryからinstanceを取ってくる。

    @Suppress("UNCHECKED_CAST")
    internal fun <T> resolveInstance(indexKey: IndexKey, parameters: ParametersDefinition?): T? {
        return _instances[indexKey]?.get(defaultInstanceContext(parameters)) as? T
    }

これで2の最後でInstanceRegistryに登録されたinstanceを取得してinject完了。

雑感

scopeを実装する必要があるせいか思ったより複雑だった。まぁ複雑と言ってもDaggerみたいにcode generationするようなやつと比べるとシンプルで好き。エラーもわかりやすいし。

言いたいことは以上です。

AndroidからGoogle Sign In経由、AWS Cognitoでuser管理してDynamo DBを操作する ~AWS Java SDKを添えて~

ふと、「そういえばNo SQLのDBって業務で関わったことないな」と思いついたのでAWS DynamoDBをさわってみることにした。
Androidから直接さわるためAWS Java SDKを利用したが、ググって引っかかる情報は結構古くてもう存在しないAPIを使っていたり、公式documentのsampleコードも使えなかったりと苦労したのでメモを残しておく。
とはいえSDKコードgithubで公開されているし、Java docも用意されているので、これからさわる人はこっちからコードを辿ったりした方が早いかもしれない。

1.やったこと概要

f:id:muumuumuumuu:20200416180342p:plain

  1. Android appからGoogle Sign In
  2. 1で取得したtokenを用いてAWS CognitoにOpen ID Connectでlog in
  3. 2で取得したcredentialを用いてAWS DynamoDBのtableをread/write

2. 手順詳細

Google Sign In を実装する

まずは画面ぽちぽちしてFirebase側の設定を終わらす。このあたりを参考にしたらできるので手順は省略。

firebase.google.cn

developers.google.cn

設定が終わったらコードを書いていく。まずapp moduleのbuild.gradleにDependenciesを追加する。

implementation 'com.google.firebase:firebase-auth:19.3.0'
implementation 'com.google.android.gms:play-services-auth:17.0.0'

認証画面のonCreate()GoogleSignInClientを初期化。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
            .requestIdToken(getString(R.string.default_web_client_id))
            .requestEmail()
           .build()
   googleSignInClient = GoogleSignIn.getClient(this, gso)
}

onStart()ですでにGoogle Log in 済み状態かどうか取得。前回のLog in情報が残っていればGoogle Sign Inはスキップ。

 override fun onStart() {
    super.onStart()

    val account = GoogleSignIn.getLastSignedInAccount(this)
    if (account == null) {
        authorizeWithGoogle()
    } else {
        logInToCognito(account)
    }
}

未Log in状態であればGoogleが用意しているLog inボタンを表示してclick listenerを設定する。

private fun authorizeWithGoogle() {
    setContentView(R.layout.activity_launch)

    findViewById<SignInButton>(R.id.sign_in_button).apply {
        setSize(SignInButton.SIZE_WIDE)
        setOnClickListener {
            // 第二引数のrequest code(RC_SIGN_IN)は適宜定義してください
            startActivityForResult(googleSignInClient.signInIntent, RC_SIGN_IN)
        }
    }
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".LaunchActivity">

    <com.google.android.gms.common.SignInButton
        android:id="@+id/sign_in_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

上記のボタンが押下されるとGoogleSignIn側でいい感じにLog in処理が走ってくれるので結果を受け取るコードを書く。

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == RC_SIGN_IN) {
        handleSignInResult(GoogleSignIn.getSignedInAccountFromIntent(data))
    }
}

private fun handleSignInResult(completedTask: Task<GoogleSignInAccount>) {
    try {
        val account = completedTask.getResult(ApiException::class.java)
        if (account != null) {
            logInToCognito(account)
        } else {
            // handle error case
        }
    } catch (ex: ApiException) {
        // handle error case
    }
}

Open ID Connectを使用してGoogleのuser account情報を元にAWS Cognitoにsing in する

この辺りを参考にしてAWSのmanegement画面をぽちぽちしてIdentity Poolを作成する。

dev.classmethod.jp

Cognitoの設定が終わったらコードを書く。

まずはbuild.gradleにaws sdkのdependenciesを追加

def aws_android_sdk_version = '2.15.0'
implementation "com.amazonaws:aws-android-sdk-core:$aws_android_sdk_version"
implementation "com.amazonaws:aws-android-sdk-ddb:$aws_android_sdk_version"

このサンプルコードでは画面間でCognitoCachingCredentialsProviderを共有したいので、雑にApplicationクラスに持たせるようなコードを書いている。

private fun logInToCognito(account: GoogleSignInAccount) {
    val application = application as MyApplication
    application.initCredentialProvider(account.idToken)
}

Applicationクラスはこんな感じ。

class MyApplication : Application() {

    var credentialsProvider: CognitoCachingCredentialsProvider? = null

    fun initCredentialProvider(token: String?) {
        credentialsProvider = CognitoCachingCredentialsProvider(
            applicationContext,
            YOUR_IDENTITY_POOL_ID,
            YOUR_REGION
        ).apply {
            logins = mapOf("accounts.google.com" to token)
        }
    }
}

これでDynamo DBにアクセスするためのcredential providerの準備は完了。

Dynamo DBにアクセスする

再びAWSのmanagement画面側をぽちぽちしてDynamo DBにTableを定義する。Tableが定義されるとAmazon Resource Name (ARN)が発行されるので、CogniteでIdentity Poolを作った時に自動で作成されたIAM RoleにこのARNへのpermissionをつける。Resourceへのpermissionだけでなく、Read/WriteといったActionsへのpermissionも一緒につける。

ぽちぽちが終わったらAndroid側のコードに戻る。
Credential providerからAmazonDynamoDBClientを生成してDynamo DBにアクセスする。ここでsetRegion()を設定する必要があることに気がつかずしばらくはまってしまった…

val dynamoDBClient = AmazonDynamoDBClient(credentialsProvider).apply {
    setRegion(Region.getRegion(Regions.US_EAST_1)) // Regionは適宜置き換えてください
}

ここから先実際にtableにアクセスするにはnetwork通信が走るので別threadで処理を実行する必要がある。サンプルコードでは雑にKotlin coroutine(しかも一部)で書いているので適宜使いたい非同期処理に置き換えてほしい。

まずはread系。table全体のitemsを取得したい時はこんな感じ。

withContext(Dispatchers.IO) {
    try {
        val result = dynamoDBClient.scan(ScanRequest(YOUR_TABLE_NAME))
        // Do whatever you want
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

このresultScanResult型になっていて、getItems()経由で各itemのListを取得することができる。さらにこのListの型が<String, AttributeValue>のMapとなっている。このAttributeValueが若干曲者で、例えばNumber型が欲しい場合はgetN()を呼ぶ必要があるが、戻り値自体はStringなのでtoInt()を呼んだりしてキャストしてやる必要がある。詳細は下記のdoc参照。

docs.aws.amazon.com

また、これはscanに限らずtableにアクセスする場合全般に言える話だが、tokenが切れていたらNotAuthorizedExceptionがthrowされるので、この場合は手動でtokenをrefreshする必要がある。

条件を指定するquery()の使い方はこんな感じ。

withContext(Dispatchers.IO) {
    try {
        val result = dynamoDBClient.query(
            QueryRequest(YOUR_TABLE_NAME).apply {
                keyConditionExpression = "id = :id"
                expressionAttributeValues = mapOf(
                    ":id" to AttributeValue(credentialsProvider.identityId)
                )
            }
        )
        // Do whatever you want
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

上記の例ではidというpartition keyが存在するtableに対し、ログイン中のユーザのIDに一致するitemsを取得している。
key condition expressionの記述方法はJava doc を参照。また、このdocに書いてある通り、

Use the ExpressionAttributeValues parameter to replace tokens such as :partitionval and :sortval with actual values at runtime.

とのことなので、AttributeValueでwrapしたvaluesetExpressionAttributeValues()で設定する必要がある。

次にWrite系。

まずはtableにitemを追加するputItem()のメモ。

withContext(Dispatchers.IO) {
    try {
        dynamoDBClient.putItem(
            PutItemRequest(
                YOUR_TABLE_NAME,
                mapOf(
                    "id" to AttributeValue(credentialsProvider.identityId),
                    "name" to AttributeValue(account.displayName),
                    "image_url" to AttributeValue(account.photoUrl.toString())
                )
            )
        )
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

追加したいItemのattributeをmapで渡すだけ。ここでもvalueAttributeValueでwrapする必要がある。

最後にAtomic Counterを使ってItemを更新する場合の例。

withContext(Dispatchers.IO) {
    try {
        dynamoDBClient.updateItem(
            UpdateItemRequest()
                .withTableName(YOUR_TABLE_NAME)
                .withKey(
                    mapOf(
                        "partition_key" to AttributeValue(partitionKey),
                        "sort_key" to AttributeValue(sortKey)
                    )
                )
                .apply {
                    updateExpression = "add #count :i"
                    expressionAttributeNames = mapOf(
                        "#count" to "count"
                    )
                    expressionAttributeValues = mapOf(
                        ":i" to AttributeValue().apply {
                            n = "1"
                        }
                    )
                }
        )
    } catch (e: NotAuthorizedException) {
        // We need to refresh token
    } catch (e: Exception) {
        // error handling
    }
}

上記の例では、withKey() でpartition keyとsort keyを渡して更新したいitemを指定し、更新の条件はapply{}節の中に記述している。サンプルではcountというNumber型のattributeを現在のものから1インクリメントしている。ちなみにAttributeValueのpublic constructorはStringを取れるのだが、ここにStringである"1"を渡してしまうとString型のvalueとして処理されてしまうので、この場合はparameterなしconstructorを使ってinstanceを生成してからsetN()でString "1"を渡す必要がある。

言いたいことは以上です。

"Hacking with iOS Online" を完走した

産休中で暇だったので、オンラインの無料iOSハンズオンコース的なやつである "Hacking with iOS Online" をコツコツ進めており、無事本日完走した✌️

www.hackingwithswift.com

よかった点

このコースには39個のプロジェクトがあり、平日一日一つずつ終わらせていった。毎日新しいプロジェクトで作るアプリも変わるので飽きずに続けられてよかった。

本来英語ネイティブの人であれば1プロジェクト1時間くらいでサクッと終わるのだろうが、自分は英語を読むのが遅いのでもっと時間がかかってしまった。でも英語の勉強も兼ねていると思えば悪くないかも。ボキャブラリーも増えた気がする。

全体として、「iOS(UIKit)でこれやろうとしたらどれくらい大変なのかな?」がちょっとだけ体感できたのでよかった。ただし、ほとんどのプロジェクトはロジックがとても少なく、大体の処理がViewControllerに入っているので設計周りの知見は全く得られなかった。(iOS devでどういった設計が流行っているのか、なんのlibraryがよく使われているのかなど。)

以下、普段Androidを書いていた自分が「iOSだとこんな簡単にできるのか!!」とびっくりしたポイントのメモを残しておく。

f:id:muumuumuumuu:20200406180350p:plain

f:id:muumuumuumuu:20200406180452p:plain

f:id:muumuumuumuu:20200406180523p:plain