Upgrade to Pro — share decks privately, control downloads, hide ads and more …

From Mobile to Backend with Kotlin and Ktor | droidcon Webinar

From Mobile to Backend with Kotlin and Ktor | droidcon Webinar

As mobile developers, we often see the backend world as something magical that “does things”. But what if I tell you that you can easily build a backend even if mobile is your thing?

With this talk, I want to show how it is possible to bring your mobile knowledge (and shift it a little bit) to build a backend with Kotlin and Ktor. I will show how to structure the project, set up Dependency Injection, connect to a database and test everything to have a working backend ready to be deployed.

9da5d5cc4b6a9f28058152e28364b02a?s=128

Marco Gomiero

June 16, 2022
Tweet

More Decks by Marco Gomiero

Other Decks in Programming

Transcript

  1. Marco Gomiero droidcon webinar From Mobile to Backend with Kotlin

    and Ktor 👨💻 Senior Android Engineer @ TIER 
 Google Developer Expert for Kotlin > Twitter: @marcoGomier 
 > Github: prof18 
 > Website: marcogomiero.com
  2. droidcon webinar - @marcoGomier 🤔 Why?

  3. droidcon webinar - @marcoGomier • A career change 🤷 •

    Side project • Understand how things works “on the other side” • Help you teammates 🤔 Why?
  4. droidcon webinar - @marcoGomier * based on a true story

  5. droidcon webinar - @marcoGomier Ktor https://ktor.io/

  6. droidcon webinar - @marcoGomier Ktor • Kotlin • Lightweight and

    flexible • Asynchronous with Coroutines • Unopinionated
  7. droidcon webinar - @marcoGomier Unopinionated • Whatever architecture • Whatever

    pattern Knowledge Transfer
  8. droidcon webinar @marcoGomier Architecture

  9. droidcon webinar - @marcoGomier Android Activity/Fragment ViewModel Repository Local Data

    Source Remote Data Source Application
  10. droidcon webinar - @marcoGomier Resource* Repository Local Data Source Remote

    Data Source Application * or Controller Ktor
  11. droidcon webinar - @marcoGomier Resource Repository Local Data Source Remote

    Data Source Application Activity/Fragment ViewModel Repository Local Data Source Remote Data Source Application
  12. droidcon webinar - @marcoGomier Domain Data Resource Repository Local Data

    Source Remote Data Source Application Activity/Fragment ViewModel Repository Local Data Source Remote Data Source Application Presentation Application
  13. droidcon webinar - @marcoGomier Application class MyApp : Application() {

    override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } initAnalytics() initCrashReporting() initRandomLib() } }
  14. droidcon webinar - @marcoGomier Application fun Application.module() { install(Koin) {

    slf4jLogger() modules(koinModules) } setupConfig() setupDatabase() install(ContentNegotiation) { json() } install(CallLogging) { level = Level.INFO } install(Locations) routing { setupEndpoint() } }
  15. droidcon webinar - @marcoGomier Application slf4jLogger() modules(koinModules) } setupConfig() setupDatabase()

    install(ContentNegotiation) { json() } install(CallLogging) { level = Level.INFO } install(Locations)
  16. droidcon webinar - @marcoGomier Plugin https://ktor.io/docs/plugins.html • Add a specific

    feature to your backend • Highly customisable • No plugin activated by default
  17. droidcon webinar - @marcoGomier Plugin https://ktor.io/docs/

  18. droidcon webinar - @marcoGomier Application setupConfig() setupDatabase() install(ContentNegotiation) { json()

    } install(CallLogging) { level = Level.INFO } install(Locations) routing { setupEndpoint() } }
  19. droidcon webinar - @marcoGomier Application install(ContentNegotiation) { json() } install(CallLogging)

    { level = Level.INFO } install(Locations) routing { setupEndpoint() } }
  20. droidcon webinar - @marcoGomier Domain Data Presentation Application

  21. droidcon webinar - @marcoGomier Presentation class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) ... } }
  22. droidcon webinar - @marcoGomier Presentation fun Route.jokeEndpoint() { val jokeRepository

    by inject<JokeRepository>() get<JokeEndpoint.Random> { call.respond(jokeRepository.getRandomJoke()) } post<JokeEndpoint.Watch> { apiCallParams -> val name = apiCallParams.name jokeRepository.watch(name) call.respond("Ok") } }
  23. droidcon webinar - @marcoGomier Domain Data Presentation Application

  24. droidcon webinar - @marcoGomier Domain class JokeRepositoryImpl( private val jokeLocalDataSource:

    JokeLocalDataSource ) : JokeRepository { override suspend fun getRandomJoke(): JokeDTO { // ... } }
  25. droidcon webinar - @marcoGomier Domain Data Presentation Application

  26. droidcon webinar - @marcoGomier @Entity(tableName = "allowed_app") data class AllowedApp(

    @PrimaryKey val packageName: String, @ColumnInfo val appName: String ) Room https://developer.android.com/training/data-storage/room Data
  27. droidcon webinar - @marcoGomier @Dao interface AllowedAppDAO { @Query("SELECT *

    FROM allowed_app") suspend fun getAllAllowedApps(): List<AllowedApp> } @Entity(tableName = "allowed_app") data class AllowedApp( @PrimaryKey val packageName: String, @ColumnInfo val appName: String )
  28. droidcon webinar - @marcoGomier class LocalDatasource( private val db: RoomDatabase

    ) { suspend fun getAllowedApps(): List<AllowedApp> { return db.allowedAppDAO().getAllAllowedApps() } } @Entity(tableName = "allowed_app") data class AllowedApp( @PrimaryKey val packageName: String, @ColumnInfo val appName: String ) @Dao interface AllowedAppDAO { @Query("SELECT * FROM allowed_app") suspend fun getAllAllowedApps(): List<AllowedApp> }
  29. droidcon webinar - @marcoGomier Exposed https://github.com/JetBrains/Exposed

  30. droidcon webinar - @marcoGomier object JokeTable: IdTable<String>(name = "joke") {

    val createdAt = datetime("created_at") val updatedAt = datetime("updated_at") val value = text("value") override val id: Column<EntityID<String >> = varchar("joke_id", 255).entityId() override val primaryKey: PrimaryKey = PrimaryKey(id) }
  31. droidcon webinar - @marcoGomier class Joke(id: EntityID<String>): Entity<String>(id) { companion

    object: EntityClass<String, Joke>(JokeTable) var createdAt by JokeTable.createdAt var updatedAt by JokeTable.updatedAt var value by JokeTable.value } object JokeTable: IdTable<String>(name = "joke") { val createdAt = datetime("created_at") val updatedAt = datetime("updated_at") val value = text("value") override val id: Column<EntityID<String >> = varchar("joke_id", 255).entityId() override val primaryKey: PrimaryKey = PrimaryKey(id) }
  32. droidcon webinar - @marcoGomier class JokeLocalDataSourceImpl : JokeLocalDataSource { override

    suspend fun getAllJokes(): List<Joke> { val joke = newSuspendedTransaction { val query = JokeTable.selectAll() Joke.wrapRows(query).toList() } } } object JokeTable: IdTable<String>(name = "joke") { val createdAt = datetime("created_at") val updatedAt = datetime("updated_at") val value = text("value") override val id: Column<EntityID<String >> = varchar("joke_id", 255).entityId() override val primaryKey: PrimaryKey = PrimaryKey(id) } class Joke(id: EntityID<String>): Entity<String>(id) { companion object: EntityClass<String, Joke>(JokeTable) var createdAt by JokeTable.createdAt var updatedAt by JokeTable.updatedAt var value by JokeTable.value }
  33. droidcon webinar - @marcoGomier class JokeLocalDataSourceImpl : JokeLocalDataSource { override

    suspend fun getAllJokes(): List<Joke> { val joke = newSuspendedTransaction { val query = JokeTable.selectAll() Joke.wrapRows(query).toList() } } } object JokeTable: IdTable<String>(name = "joke") { val createdAt = datetime("created_at") val updatedAt = datetime("updated_at") val value = text("value") override val id: Column<EntityID<String >> = varchar("joke_id", 255).entityId() override val primaryKey: PrimaryKey = PrimaryKey(id) } class Joke(id: EntityID<String>): Entity<String>(id) { companion object: EntityClass<String, Joke>(JokeTable) var createdAt by JokeTable.createdAt var updatedAt by JokeTable.updatedAt var value by JokeTable.value }
  34. droidcon webinar - @marcoGomier object JokeTable: IdTable<String>(name = "joke") {

    val createdAt = datetime("created_at") val updatedAt = datetime("updated_at") val value = text("value") override val id: Column<EntityID<String >> = varchar("joke_id", 255).entityId() override val primaryKey: PrimaryKey = PrimaryKey(id) } class Joke(id: EntityID<String>): Entity<String>(id) { companion object: EntityClass<String, Joke>(JokeTable) var createdAt by JokeTable.createdAt var updatedAt by JokeTable.updatedAt var value by JokeTable.value } class JokeLocalDataSourceImpl : JokeLocalDataSource { override suspend fun getAllJokes(): List<Joke> { val joke = newSuspendedTransaction { val query = JokeTable.selectAll() Joke.wrapRows(query).toList() } } }
  35. droidcon webinar - @marcoGomier object JokeTable: IdTable<String>(name = "joke") {

    val createdAt = datetime("created_at") val updatedAt = datetime("updated_at") val value = text("value") override val id: Column<EntityID<String >> = varchar("joke_id", 255).entityId() override val primaryKey: PrimaryKey = PrimaryKey(id) } class Joke(id: EntityID<String>): Entity<String>(id) { companion object: EntityClass<String, Joke>(JokeTable) var createdAt by JokeTable.createdAt var updatedAt by JokeTable.updatedAt var value by JokeTable.value } class JokeLocalDataSourceImpl : JokeLocalDataSource { override suspend fun getAllJokes(): List<Joke> { val joke = newSuspendedTransaction { val query = JokeTable.selectAll() Joke.wrapRows(query).toList() } } }
  36. droidcon webinar - @marcoGomier Joke.new { this.createdAt = LocalDateTime.now() this.updatedAt

    = LocalDateTime.now() value = "A Joke" }
  37. droidcon webinar - @marcoGomier EmailTable.deleteWhere { EmailTable.account.eq(account.id.value) and EmailTable.id.eq(emailId) }

  38. droidcon webinar - @marcoGomier EmailTable.deleteWhere { EmailTable.account.eq(account.id.value) and EmailTable.id.eq(emailId) }

    EmailThreadTable.join(EmailFolderTable, JoinType.INNER, additionalConstraint = { (EmailThreadTable.id eq EmailFolderTable.threadId).and( EmailThreadTable.account eq EmailFolderTable.accountId ) }) .select { EmailThreadTable.account eq account.id.value and (EmailFolderTable.folderId eq folderId) } .orderBy(EmailThreadTable.lastEmailDate, SortOrder.DESC) .limit(n = pageSize)
  39. droidcon webinar @marcoGomier Dependency Injection

  40. droidcon webinar - @marcoGomier https://insert-koin.io/docs/quickstart/ktor Koin

  41. droidcon webinar - @marcoGomier val appModule = module { single<HelloRepository>

    { HelloRepositoryImpl() } factory { MySimplePresenter(get()) } }
  42. droidcon webinar - @marcoGomier class MyApplication : Application() { override

    fun onCreate() { super.onCreate() startKoin { androidLogger() androidContext(this@MyApplication) modules(appModule) } } }
  43. droidcon webinar - @marcoGomier class MainActivity : AppCompatActivity() { val

    firstPresenter: MySimplePresenter by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) ... } }
  44. droidcon webinar - @marcoGomier val appModule = module { single<JokeLocalDataSource>

    { JokeLocalDataSourceImpl() } single<JokeRepository> { JokeRepositoryImpl(get()) } }
  45. droidcon webinar - @marcoGomier fun Application.module() { install(Koin) { slf4jLogger()

    modules(koinModules) } ... }
  46. droidcon webinar - @marcoGomier fun Route.jokeEndpoint() { val jokeRepository by

    inject<JokeRepository>() get<JokeEndpoint.Random> { call.respond(jokeRepository.getRandomJoke()) } post<JokeEndpoint.Watch> { apiCallParams -> val name = apiCallParams.name jokeRepository.watch(name) call.respond("Ok") } }
  47. droidcon webinar @marcoGomier Logging

  48. droidcon webinar - @marcoGomier Timber class MyApplication : Application() {

    override fun onCreate() { super.onCreate() if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } else { // Log to somewhere else } } } https://github.com/JakeWharton/timber
  49. droidcon webinar - @marcoGomier Timber https://github.com/JakeWharton/timber Timber.d(“Message") Timber.v("Message") Timber.i("Message") Timber.w("Message")

    Timber.wtf("Message") Timber.e(exception)
  50. droidcon webinar - @marcoGomier SLF4J http://www.slf4j.org/index.html fun Application.module(testing: Boolean =

    false) { log.info("Hello from module!") }
  51. droidcon webinar - @marcoGomier SLF4J http://www.slf4j.org/index.html routing { get("/api/v1") {

    call.application.environment.log.info("Hello from /api/v1!") } } fun Application.module(testing: Boolean = false) { log.info("Hello from module!") }
  52. droidcon webinar - @marcoGomier SLF4J http://www.slf4j.org/index.html routing { get("/api/v1") {

    call.application.environment.log.info("Hello from /api/v1!") } } fun Application.module(testing: Boolean = false) { log.info("Hello from module!") } val logger = LoggerFactory.getLogger(MyClass :: class.java)
  53. droidcon webinar - @marcoGomier val logger = LoggerFactory.getLogger(MyClass :: class.java)

    inline fun <reified T> T.getLogger(): Logger { return LoggerFactory.getLogger(T :: class.java) } class MyClass { private val logger = getLogger() fun main() { logger.info("Hello World") } }
  54. droidcon webinar - @marcoGomier logger.trace("Message") logger.debug("Message") logger.info("Message") logger.warn("Message") logger.error("Message") SLF4J

  55. droidcon webinar - @marcoGomier logback.xml

  56. droidcon webinar - @marcoGomier <configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{YYYY-MM-dd

    HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n </ pattern> </ encoder> </ appender> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_DEST}/ktor-chuck-norris-sample.log </ file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- daily rollover --> <fileNamePattern>${LOG_DEST}/ktor-chuck-norris-sample.%d{yyyy-MM-dd}.log </ fileNamePattern> <!-- keep 90 days' worth of history capped at 3GB total size --> <maxHistory>${LOG_MAX_HISTORY} </ maxHistory> <totalSizeCap>3GB </ totalSizeCap> </ rollingPolicy> <encoder> <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n </ pattern> </ encoder> </ appender> <root level="info"> <appender-ref ref="STDOUT" /> <appender-ref ref="FILE" /> </ root> <logger name="org.eclipse.jetty" level="INFO" /> <logger name="io.netty" level="INFO" /> <logger name="org.quartz" level="INFO" /> </ configuration> logback.xml
  57. droidcon webinar - @marcoGomier <configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{YYYY-MM-dd

    HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n </ pattern> </ encoder> </ appender> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_DEST}/ktor-chuck-norris-sample.log </ file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- daily rollover --> <fileNamePattern>${LOG_DEST}/ktor-chuck-norris-sample.%d{yyyy-MM-dd}.log </ fileNamePattern> <!-- keep 90 days' worth of history capped at 3GB total size --> <maxHistory>${LOG_MAX_HISTORY} </ maxHistory> <totalSizeCap>3GB </ totalSizeCap> </ rollingPolicy> logback.xml
  58. <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n </ pattern> </

    encoder> </ appender> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_DEST}/ktor-chuck-norris-sample.log </ file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- daily rollover --> <fileNamePattern>${LOG_DEST}/ktor-chuck-norris-sample.%d{yyyy-MM-dd}.log </ fileNamePattern> <!-- keep 90 days' worth of history capped at 3GB total size --> <maxHistory>${LOG_MAX_HISTORY} </ maxHistory> <totalSizeCap>3GB </ totalSizeCap> </ rollingPolicy> <encoder> <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n </ pattern> </ encoder> </ appender> <root level="info"> <appender-ref ref="STDOUT" /> />
  59. droidcon webinar - @marcoGomier <encoder> <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36}

    - % </ pattern> </ encoder> </ appender> <root level="info"> <appender-ref ref="STDOUT" /> <appender-ref ref="FILE" /> </ root> <logger name="org.eclipse.jetty" level="INFO" /> <logger name="io.netty" level="INFO" /> <logger name="org.quartz" level="INFO" /> </ configuration>
  60. droidcon webinar @marcoGomier Secrets

  61. droidcon webinar - @marcoGomier local.properties tmdbKey="private_key"

  62. droidcon webinar - @marcoGomier build.gradle.kts val String.byProperty: String? get() {

    val local = java.util.Properties() val localProperties: File = rootProject.file("local.properties") if (localProperties.exists()) { localProperties.inputStream().use { local.load(it) } return local.getProperty(this) } return null } tmdbKey="private_key" buildConfigField("String", "TMDB_KEY", "\"${"tmdbKey".byProperty ?: "")}\"")
  63. droidcon webinar - @marcoGomier NetworkApiInterceptor.kt tmdbKey="private_key" buildConfigField("String", "TMDB_KEY", "\"${"tmdbKey".byProperty ?:

    "")}\"") val myKey: String = BuildConfig.TMDB_KEY
  64. droidcon webinar - @marcoGomier application.conf

  65. droidcon webinar - @marcoGomier ktor { deployment { port =

    8080 port = ${?PORT} } application { modules = [com.prof18.ktor.chucknorris.sample.ApplicationKt.module] } server { isProd = false } database { driverClass = "com.mysql.cj.jdbc.Driver" url = "jdbc:mysql: / / localhost:3308/chucknorris?useUnicode=true&characterEncoding=UTF-8" user = "root" password = "password" maxPoolSize = 3 } } application.conf
  66. ktor { deployment { port = 8080 port = ${?PORT}

    } application { modules = [com.prof18.ktor.chucknorris.sample.ApplicationKt.module] } server { isProd = false } database { driverClass = "com.mysql.cj.jdbc.Driver" // application.conf
  67. droidcon webinar - @marcoGomier ktor { deployment { port =

    8080 port = ${?PORT} } application { modules = [com.prof18.ktor.chucknorris.sample.ApplicationKt.module] } server { isProd = false } database { driverClass = "com.mysql.cj.jdbc.Driver" url = "jdbc:mysql: // localhost:3308/chucknorris?useUnicode=true&characterEncoding=UT user = "root" password = "password" maxPoolSize = 3 } }
  68. droidcon webinar - @marcoGomier } application { modules = [com.prof18.ktor.chucknorris.sample.ApplicationKt.module]

    } server { isProd = false } database { driverClass = "com.mysql.cj.jdbc.Driver" url = "jdbc:mysql: // localhost:3308/chucknorris?useUnicode=true&characterEncoding=UT user = "root" password = "password" maxPoolSize = 3 } }
  69. droidcon webinar - @marcoGomier data class ServerConfig( val isProd: Boolean

    )
  70. droidcon webinar - @marcoGomier data class ServerConfig( val isProd: Boolean

    ) class AppConfig { lateinit var serverConfig: ServerConfig // Place here other configurations }
  71. droidcon webinar - @marcoGomier data class ServerConfig( val isProd: Boolean

    ) class AppConfig { lateinit var serverConfig: ServerConfig // Place here other configurations } fun Application.setupConfig() { val appConfig by inject<AppConfig>() // Server val serverObject = environment.config.config("ktor.server") val isProd = serverObject.property("isProd").getString().toBoolean() appConfig.serverConfig = ServerConfig(isProd) }
  72. droidcon webinar - @marcoGomier fun Application.module() { ... setupConfig() val

    appConfig by inject<AppConfig>() ... }
  73. droidcon webinar - @marcoGomier java -jar ktor-backend.jar -config=/config-folder/application.conf

  74. droidcon webinar @marcoGomier Testing

  75. droidcon webinar - @marcoGomier • Unit Test -> Just regular

    Kotlin Unit tests • androidTest -> TestEngine Testing https://ktor.io/docs/testing.html
  76. droidcon webinar - @marcoGomier @Test fun testRequests() = withTestApplication(module(testing =

    true)) { with(handleRequest(HttpMethod.Get, "/")) { assertEquals(HttpStatusCode.OK, response.status()) assertEquals("Hello from Ktor Testable sample application", response.content) } https://ktor.io/docs/testing.html
  77. droidcon webinar @marcoGomier Deploy

  78. droidcon webinar - @marcoGomier • aar • apk • aab

    Deploy
  79. droidcon webinar - @marcoGomier https://github.com/johnrengelman/shadow ./gradlew assembleRelease ./gradlew bundleRelease

  80. droidcon webinar - @marcoGomier • Fat JAR • Executable JVM

    application • WAR • GraalVM Deploy https://ktor.io/docs/deploy.html
  81. droidcon webinar - @marcoGomier • Fat JAR • Executable JVM

    application • WAR • GraalVM Deploy https://ktor.io/docs/deploy.html
  82. droidcon webinar - @marcoGomier https://github.com/johnrengelman/shadow build.gradle.kts plugins { ... id("com.github.johnrengelman.shadow")

    version "7.0.0" }
  83. droidcon webinar - @marcoGomier https://github.com/johnrengelman/shadow build.gradle.kts tasks { shadowJar {

    manifest { attributes(Pair("Main-Class", "io.ktor.server.netty.EngineMain")) } } } application { mainClass.set("io.ktor.server.netty.EngineMain") }
  84. droidcon webinar - @marcoGomier https://github.com/johnrengelman/shadow ./gradlew shadowJar

  85. droidcon webinar - @marcoGomier

  86. droidcon webinar @marcoGomier Conclusions

  87. droidcon webinar - @marcoGomier • Ktor is flexible and unopinionated

    -> Knowledge Transfer • Mobile knowledge can be adapted • Effective scaling and deploying can be hard • “Going to the other side” enrich your dev vision Conclusions
  88. droidcon webinar - @marcoGomier https://github.com/prof18/ktor-chuck-norris-sample

  89. Bibliography / Useful Links • https: // ktor.io/ • https:

    // ktor.io/learn/ • https: // ktor.io/docs/welcome.html • https: // github.com/JetBrains/Exposed/wiki • https: // www.marcogomiero.com/posts/2021/ktor-project-structure/ • https: // www.marcogomiero.com/posts/2021/ktor-logging-on-disk/ • https: // www.marcogomiero.com/posts/2021/ktor-in-memory-db-testing/ • https: // www.marcogomiero.com/posts/2022/ktor-migration-liquibase/ • https: // www.marcogomiero.com/posts/2022/ktor-setup-documentation/ • https: // www.marcogomiero.com/posts/2022/ktor-setup-documentation/ • https: // www.marcogomiero.com/posts/2022/backend-from-mobile-ktor/
  90. Marco Gomiero droidcon webinar Thank you! > Twitter: @marcoGomier 


    > Github: prof18 
 > Website: marcogomiero.com 👨💻 Senior Android Engineer @ TIER 
 Google Developer Expert for Kotlin