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

From Mobile to Backend with Kotlin and Ktor - plDroid

From Mobile to Backend with Kotlin and Ktor - plDroid

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.

Marco Gomiero

May 31, 2023
Tweet

More Decks by Marco Gomiero

Other Decks in Programming

Transcript

  1. @marcoGomier
    From Mobile to
    Backend with
    Kotlin and Ktor
    👨💻 Senior Android Engineer @ TIER

    Google Developer Expert for Kotlin
    Marco Gomiero

    View Slide

  2. @marcoGomier
    🤔 Why?

    View Slide

  3. @marcoGomier
    • A career change 🤷


    • Side project


    • Understand how things works “on the other side”


    • Help you teammates
    🤔 Why?

    View Slide

  4. @marcoGomier
    * based on a true story

    View Slide

  5. @marcoGomier
    Ktor
    https://ktor.io/

    View Slide

  6. @marcoGomier
    Ktor
    • Kotlin


    • Lightweight and flexible


    • Asynchronous with Coroutines


    • Unopinionated

    View Slide

  7. @marcoGomier
    Unopinionated
    • Whatever architecture


    • Whatever pattern
    Knowledge Transfer

    View Slide

  8. @marcoGomier
    Architecture

    View Slide

  9. @marcoGomier
    Android
    Activity/Fragment
    ViewModel
    Repository
    Local Data Source Remote Data Source
    Application

    View Slide

  10. @marcoGomier
    Resource*
    Repository
    Local Data Source Remote Data Source
    Application
    * or Controller
    Ktor

    View Slide

  11. @marcoGomier
    Resource
    Repository
    Local Data Source Remote Data Source
    Application
    Activity/Fragment
    ViewModel
    Repository
    Local Data Source Remote Data Source
    Application

    View Slide

  12. @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

    View Slide

  13. @marcoGomier
    Application
    class MyApp : Application() {


    override fun onCreate() {


    super.onCreate()


    if (BuildConfig.DEBUG) {


    Timber.plant(Timber.DebugTree())


    }


    initAnalytics()


    initCrashReporting()


    initRandomLib()


    }


    }

    View Slide

  14. @marcoGomier
    Application
    fun Application.module() {


    install(Koin) {


    slf4jLogger()


    modules(koinModules)


    }


    setupConfig()


    setupDatabase()


    install(ContentNegotiation) {


    json()


    }


    install(CallLogging) {


    level = Level.INFO


    }


    install(Locations)


    routing {


    setupEndpoint()


    }


    }

    View Slide

  15. @marcoGomier
    Application


    slf4jLogger()


    modules(koinModules)


    }


    setupConfig()


    setupDatabase()


    install(ContentNegotiation) {


    json()


    }


    install(CallLogging) {


    level = Level.INFO


    }


    install(Locations)




    View Slide

  16. @marcoGomier
    Plugin
    https://ktor.io/docs/plugins.html
    • Add a specific feature to your backend


    • Highly customisable


    • No plugin activated by default

    View Slide

  17. @marcoGomier
    Plugin
    https://ktor.io/docs/

    View Slide

  18. @marcoGomier
    Application
    setupConfig()


    setupDatabase()


    install(ContentNegotiation) {


    json()


    }


    install(CallLogging) {


    level = Level.INFO


    }ㅤ

    install(Locations)


    routing {


    setupEndpoint()


    }


    }

    View Slide

  19. @marcoGomier
    Application
    install(ContentNegotiation) {


    json()


    }


    install(CallLogging) {


    level = Level.INFO


    }ㅤ

    install(Locations)


    routing {


    setupEndpoint()


    }


    }

    View Slide

  20. @marcoGomier
    Domain
    Data
    Presentation
    Application

    View Slide

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


    override fun onCreate(savedInstanceState: Bundle?) {


    super.onCreate(savedInstanceState)


    setContentView(R.layout.activity_main)


    ...

    }


    }

    View Slide

  22. @marcoGomier
    Presentation
    fun Route.jokeEndpoint() {


    val jokeRepository by inject()


    get {


    call.respond(jokeRepository.getRandomJoke())


    }


    post { apiCallParams
    ->

    val name = apiCallParams.name


    jokeRepository.watch(name)


    call.respond("Ok")


    }


    }

    View Slide

  23. @marcoGomier
    Domain
    Data
    Presentation
    Application

    View Slide

  24. @marcoGomier
    Domain
    class JokeRepositoryImpl(


    private val jokeLocalDataSource: JokeLocalDataSource


    ) : JokeRepository {


    override suspend fun getRandomJoke(): JokeDTO {


    // ...

    }


    }

    View Slide

  25. @marcoGomier
    Domain
    Data
    Presentation
    Application

    View Slide

  26. @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

    View Slide

  27. @marcoGomier
    @Dao


    interface AllowedAppDAO {


    @Query("SELECT * FROM allowed_app")


    suspend fun getAllAllowedApps(): List


    }
    @Entity(tableName = "allowed_app")


    data class AllowedApp(


    @PrimaryKey val packageName: String,


    @ColumnInfo val appName: String


    )

    View Slide

  28. @marcoGomier
    class LocalDatasource(


    private val db: RoomDatabase


    ) {


    suspend fun getAllowedApps(): List {


    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


    }

    View Slide

  29. @marcoGomier
    Exposed
    https://github.com/JetBrains/Exposed

    View Slide

  30. @marcoGomier
    object JokeTable: IdTable(name = "joke") {


    val createdAt = datetime("created_at")


    val updatedAt = datetime("updated_at")


    val value = text("value")


    override val id: Column>>
    = varchar("joke_id", 255).entityId()


    override val primaryKey: PrimaryKey = PrimaryKey(id)


    }

    View Slide

  31. @marcoGomier
    class Joke(id: EntityID): Entity(id) {


    companion object: EntityClass(JokeTable)


    var createdAt by JokeTable.createdAt


    var updatedAt by JokeTable.updatedAt


    var value by JokeTable.value


    }
    object JokeTable: IdTable(name = "joke") {


    val createdAt = datetime("created_at")


    val updatedAt = datetime("updated_at")


    val value = text("value")


    override val id: Column>>
    = varchar("joke_id", 255).entityId()


    override val primaryKey: PrimaryKey = PrimaryKey(id)


    }

    View Slide

  32. @marcoGomier
    class JokeLocalDataSourceImpl : JokeLocalDataSource {


    override suspend fun getAllJokes(): List {


    val joke = newSuspendedTransaction {


    val query = JokeTable.selectAll()


    Joke.wrapRows(query).toList()


    }


    }


    }
    object JokeTable: IdTable(name = "joke") {


    val createdAt = datetime("created_at")


    val updatedAt = datetime("updated_at")


    val value = text("value")


    override val id: Column>>
    = varchar("joke_id", 255).entityId()


    override val primaryKey: PrimaryKey = PrimaryKey(id)


    }
    class Joke(id: EntityID): Entity(id) {


    companion object: EntityClass(JokeTable)


    var createdAt by JokeTable.createdAt


    var updatedAt by JokeTable.updatedAt


    var value by JokeTable.value


    }

    View Slide

  33. @marcoGomier
    class JokeLocalDataSourceImpl : JokeLocalDataSource {


    override suspend fun getAllJokes(): List {


    val joke = newSuspendedTransaction {


    val query = JokeTable.selectAll()


    Joke.wrapRows(query).toList()


    }


    }


    }
    object JokeTable: IdTable(name = "joke") {


    val createdAt = datetime("created_at")


    val updatedAt = datetime("updated_at")


    val value = text("value")


    override val id: Column>>
    = varchar("joke_id", 255).entityId()


    override val primaryKey: PrimaryKey = PrimaryKey(id)


    }
    class Joke(id: EntityID): Entity(id) {


    companion object: EntityClass(JokeTable)


    var createdAt by JokeTable.createdAt


    var updatedAt by JokeTable.updatedAt


    var value by JokeTable.value


    }

    View Slide

  34. @marcoGomier
    object JokeTable: IdTable(name = "joke") {


    val createdAt = datetime("created_at")


    val updatedAt = datetime("updated_at")


    val value = text("value")


    override val id: Column>>
    = varchar("joke_id", 255).entityId()


    override val primaryKey: PrimaryKey = PrimaryKey(id)


    }
    class Joke(id: EntityID): Entity(id) {


    companion object: EntityClass(JokeTable)


    var createdAt by JokeTable.createdAt


    var updatedAt by JokeTable.updatedAt


    var value by JokeTable.value


    }
    class JokeLocalDataSourceImpl : JokeLocalDataSource {


    override suspend fun getAllJokes(): List {


    val joke = newSuspendedTransaction {


    val query = JokeTable.selectAll()


    Joke.wrapRows(query).toList()


    }


    }


    }

    View Slide

  35. @marcoGomier
    object JokeTable: IdTable(name = "joke") {


    val createdAt = datetime("created_at")


    val updatedAt = datetime("updated_at")


    val value = text("value")


    override val id: Column>>
    = varchar("joke_id", 255).entityId()


    override val primaryKey: PrimaryKey = PrimaryKey(id)


    }
    class Joke(id: EntityID): Entity(id) {


    companion object: EntityClass(JokeTable)


    var createdAt by JokeTable.createdAt


    var updatedAt by JokeTable.updatedAt


    var value by JokeTable.value


    }
    class JokeLocalDataSourceImpl : JokeLocalDataSource {


    override suspend fun getAllJokes(): List {


    val joke = newSuspendedTransaction {


    val query = JokeTable.selectAll()


    Joke.wrapRows(query).toList()


    }


    }


    }

    View Slide

  36. @marcoGomier
    Joke.new {


    this.createdAt = LocalDateTime.now()


    this.updatedAt = LocalDateTime.now()


    value = "A Joke"


    }

    View Slide

  37. @marcoGomier
    EmailTable.deleteWhere {


    EmailTable.account.eq(account.id.value) and EmailTable.id.eq(emailId)


    }

    View Slide

  38. @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)


    View Slide

  39. @marcoGomier
    Dependency Injection

    View Slide

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

    View Slide

  41. @marcoGomier
    val appModule = module {


    single { HelloRepositoryImpl() }


    factory { MySimplePresenter(get()) }


    }

    View Slide

  42. @marcoGomier
    class MyApplication : Application() {


    override fun onCreate() {


    super.onCreate()




    startKoin {


    androidLogger()


    androidContext(this@MyApplication)


    modules(appModule)


    }


    }


    }

    View Slide

  43. @marcoGomier
    class MainActivity : AppCompatActivity() {


    val firstPresenter: MySimplePresenter by inject()


    override fun onCreate(savedInstanceState: Bundle?) {


    super.onCreate(savedInstanceState)


    setContentView(R.layout.activity_main)


    ...

    }


    }

    View Slide

  44. @marcoGomier
    val appModule = module {


    single { JokeLocalDataSourceImpl() }


    single { JokeRepositoryImpl(get()) }


    }

    View Slide

  45. @marcoGomier
    fun Application.module() {


    install(Koin) {


    slf4jLogger()


    modules(koinModules)


    }


    ...

    }

    View Slide

  46. @marcoGomier
    fun Route.jokeEndpoint() {


    val jokeRepository by inject()


    get {


    call.respond(jokeRepository.getRandomJoke())


    }


    post { apiCallParams
    ->

    val name = apiCallParams.name


    jokeRepository.watch(name)


    call.respond("Ok")


    }


    }

    View Slide

  47. @marcoGomier
    Logging

    View Slide

  48. @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

    View Slide

  49. @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)

    View Slide

  50. @marcoGomier
    SLF4J
    http://www.slf4j.org/index.html
    fun Application.module(testing: Boolean = false) {


    log.info("Hello from module!")


    }

    View Slide

  51. @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!")


    }

    View Slide

  52. @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)

    View Slide

  53. @marcoGomier
    val logger = LoggerFactory.getLogger(MyClass
    ::
    class.java)
    inline fun T.getLogger(): Logger {


    return LoggerFactory.getLogger(T
    ::
    class.java)


    }
    class MyClass {


    private val logger = getLogger()


    fun main() {


    logger.info("Hello World")


    }


    }

    View Slide

  54. @marcoGomier
    logger.trace("Message")


    logger.debug("Message")


    logger.info("Message")


    logger.warn("Message")


    logger.error("Message")
    SLF4J

    View Slide

  55. @marcoGomier
    logback.xml

    View Slide

  56. @marcoGomier









    %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n

    pattern>



    encoder>



    appender>





    ${LOG_DEST}/ktor-chuck-norris-sample.log

    file>







    ${LOG_DEST}/ktor-chuck-norris-sample.%d{yyyy-MM-dd}.log

    fileNamePattern>




    ${LOG_MAX_HISTORY}

    maxHistory>


    3GB

    totalSizeCap>



    rollingPolicy>





    %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n

    pattern>



    encoder>



    appender>





    />

    />


    root>


    />

    />

    />


    configuration>


    logback.xml

    View Slide

  57. @marcoGomier









    %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n

    pattern>



    encoder>



    appender>





    ${LOG_DEST}/ktor-chuck-norris-sample.log

    file>







    ${LOG_DEST}/ktor-chuck-norris-sample.%d{yyyy-MM-dd}.log

    fileNamePattern>




    ${LOG_MAX_HISTORY}

    maxHistory>


    3GB

    totalSizeCap>



    rollingPolicy>




    logback.xml

    View Slide



  58. %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n

    pattern>



    encoder>



    appender>





    ${LOG_DEST}/ktor-chuck-norris-sample.log

    file>







    ${LOG_DEST}/ktor-chuck-norris-sample.%d{yyyy-MM-dd}.log

    fileNamePattern>




    ${LOG_MAX_HISTORY}

    maxHistory>


    3GB

    totalSizeCap>



    rollingPolicy>





    %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n

    pattern>



    encoder>



    appender>





    />

    />

    View Slide

  59. @marcoGomier



    %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %

    pattern>



    encoder>



    appender>





    />

    />


    root>


    />

    />

    />


    configuration>


    View Slide

  60. @marcoGomier
    Secrets

    View Slide

  61. @marcoGomier
    local.properties
    tmdbKey="private_key"

    View Slide

  62. @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
    ?:
    "")}\"")

    View Slide

  63. @marcoGomier
    NetworkApiInterceptor.kt
    tmdbKey="private_key"
    buildConfigField("String", "TMDB_KEY", "\"${"tmdbKey".byProperty
    ?:
    "")}\"")
    val myKey: String = BuildConfig.TMDB_KEY

    View Slide

  64. @marcoGomier
    application.conf

    View Slide

  65. @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

    View Slide

  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

    View Slide

  67. @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


    }


    }

    View Slide

  68. @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


    }


    }

    View Slide

  69. @marcoGomier
    data class ServerConfig(


    val isProd: Boolean


    )

    View Slide

  70. @marcoGomier
    data class ServerConfig(


    val isProd: Boolean


    )
    class AppConfig {


    lateinit var serverConfig: ServerConfig


    //
    Place here other configurations


    }

    View Slide

  71. @marcoGomier
    data class ServerConfig(


    val isProd: Boolean


    )
    class AppConfig {


    lateinit var serverConfig: ServerConfig


    //
    Place here other configurations


    }
    fun Application.setupConfig() {


    val appConfig by inject()


    //
    Server


    val serverObject = environment.config.config("ktor.server")


    val isProd = serverObject.property("isProd").getString().toBoolean()


    appConfig.serverConfig = ServerConfig(isProd)


    }

    View Slide

  72. @marcoGomier
    fun Application.module() {


    ...



    setupConfig()


    val appConfig by inject()


    ...

    }


    View Slide

  73. @marcoGomier
    java -jar ktor-backend.jar -config=/config-folder/application.conf

    View Slide

  74. @marcoGomier
    Testing

    View Slide

  75. @marcoGomier
    • Unit Test
    ->
    Just regular Kotlin Unit tests


    • androidTest
    ->
    TestEngine
    Testing
    https://ktor.io/docs/testing.html

    View Slide

  76. @marcoGomier
    @Test


    fun testRoot() = testApplication {


    val response = client.get("/")


    assertEquals(HttpStatusCode.OK, response.status)


    assertEquals("Hello, world!", response.bodyAsText())


    }
    https://ktor.io/docs/testing.html

    View Slide

  77. @marcoGomier
    Deploy

    View Slide

  78. @marcoGomier
    • aar


    • apk


    • aab
    Deploy

    View Slide

  79. @marcoGomier
    ./gradlew assembleRelease
    ./gradlew bundleRelease

    View Slide

  80. @marcoGomier
    • Fat JAR


    • Executable JVM application


    • WAR


    • GraalVM
    Deploy
    https://ktor.io/docs/deploy.html

    View Slide

  81. @marcoGomier
    • Fat JAR


    • Executable JVM application


    • WAR


    • GraalVM
    Deploy
    https://ktor.io/docs/deploy.html

    View Slide

  82. @marcoGomier https://github.com/ktorio/ktor-build-plugins
    build.gradle.kts
    plugins {


    ...

    id("io.ktor.plugin") version "2.3.0"


    }

    View Slide

  83. @marcoGomier https://github.com/ktorio/ktor-build-plugins
    build.gradle.kts
    application {


    mainClass.set("io.ktor.server.netty.EngineMain")


    }

    View Slide

  84. @marcoGomier https://github.com/ktorio/ktor-build-plugins
    ./gradlew buildFatJar

    View Slide

  85. @marcoGomier

    View Slide

  86. @marcoGomier
    Conclusions

    View Slide

  87. @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

    View Slide

  88. @marcoGomier https://github.com/prof18/ktor-chuck-norris-sample

    View Slide

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


    • https:
    / /
    ktor.io/learn/


    • https:
    / /
    ktor.io/docs/welcome.html


    • https:
    / /
    ktor.io/docs/migrating-2.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-jobs-quartz/


    • https:
    / /
    www.marcogomiero.com/posts/2022/backend-from-mobile-ktor/

    View Slide

  90. @marcoGomier
    Thank you!
    > Twitter: @marcoGomier

    > Github: prof18

    > Website: marcogomiero.com

    > Mastodon: androiddev.social/@marcogom
    Marco Gomiero
    👨💻 Senior Android Engineer @ TIER

    Google Developer Expert for Kotlin

    View Slide