Sign in

A mailbox app (Kotlin)

Kotlin on Android: sign in, read, stay in step, send.

An Android mail app in Kotlin: sign in with ZiloOne, show folders and messages, stay in step as mail arrives, send with attachments. The same steps work in Swift, Flutter or anything else.

What you'll use#

  • AppAuth for Android for signing in (PKCE, the system browser, refresh).
  • OkHttp (or Ktor) and kotlinx.serialization for the API.
  • Permissions: openid email profile mail.read mail.write mail.send.
  1. Register the appAt Your apps › New app: kind Mobile or desktop, callback com.acme.mail:/callback, the mail permissions, anyone with ZiloOne.
  2. Sign in
    KOTLIN
    // build.gradle: implementation("net.openid:appauth:0.11.1")
    // manifest: <data android:scheme="com.acme.mail"/> on RedirectUriReceiverActivity (appAuthRedirectScheme)
    val issuer = Uri.parse("https://accounts.zilomail.in/oidc")
    AuthorizationServiceConfiguration.fetchFromIssuer(issuer) { config, ex ->
        val request = AuthorizationRequest.Builder(config!!, CLIENT_ID, ResponseTypeValues.CODE, Uri.parse("com.acme.mail:/callback"))
            .setScopes("openid", "email", "profile", "mail.read", "mail.write", "mail.send")
            .build()
        signIn.launch(authService.getAuthorizationRequestIntent(request))
    }
    
    // In the result callback:
    val response = AuthorizationResponse.fromIntent(data!!)
    authService.performTokenRequest(response!!.createTokenExchangeRequest()) { tokens, ex ->
        authState.update(tokens, ex)          // AuthState keeps and refreshes tokens
        secureStore.save(authState.jsonSerializeString())  // EncryptedSharedPreferences
    }
  3. Call the API with a fresh token
    KOTLIN
    val api = "https://api.zilomail.in/v1"
    
    suspend fun <T> withToken(block: suspend (String) -> T): T = suspendCoroutine { cont ->
        authState.performActionWithFreshTokens(authService) { token, _, ex ->
            if (ex != null) cont.resumeWithException(ex) else CoroutineScope(Dispatchers.IO).launch { cont.resume(block(token!!)) }
        }
    }
    
    suspend fun get(path: String): JsonObject = withToken { token ->
        val req = Request.Builder().url(api + path).header("Authorization", "Bearer $token").build()
        client.newCall(req).execute().use { res ->
            val body = res.body!!.string()
            if (!res.isSuccessful) throw ApiException(Json.parseToJsonElement(body).jsonObject["error"]!!.jsonObject)
            Json.parseToJsonElement(body).jsonObject
        }
    }
  4. Folders and the inbox
    KOTLIN
    val folders = get("/mail/folders")["data"]!!.jsonArray      // name, role, total, unread
    val inbox = get("/mail/messages?folder=inbox&limit=50")
    val messages = inbox["data"]!!.jsonArray                    // id, from, subject, snippet, read…
    val next = inbox["next"]?.jsonPrimitive?.contentOrNull       // more: ?cursor=$next
    Open one with GET /mail/messages/{id} (text, cleaned HTML, attachments), then mark it read with PATCH { "read": true }.
  5. Stay in stepGet the feed's position once, then hold a request open: it answers as soon as anything changes (new mail, read on the phone's other app, moved in webmail, deleted).
    KOTLIN
    var cursor = get("/mail/changes")["cursor"]!!.jsonPrimitive.content   // "now"
    
    while (isActive) {
        val page = get("/mail/changes?since=$cursor&wait=25")
        for (c in page["data"]!!.jsonArray) {
            val change = c.jsonObject
            when (change["kind"]!!.jsonPrimitive.content) {
                "created" -> fetchAndInsert(change["messageId"]!!.jsonPrimitive.content)
                "updated" -> refreshFlagsAndFolder(change["messageId"]!!.jsonPrimitive.content)
                "deleted" -> removeLocally(change["messageId"]!!.jsonPrimitive.content)
            }
        }
        cursor = page["cursor"]!!.jsonPrimitive.content
    }
    // 410 cursor_expired (away more than 30 days): list the folders again and start afresh.
    Run it in a foreground service while the app is open, and a WorkManager job every 15 minutes when it isn't.
  6. Send, with an attachment
    KOTLIN
    val body = buildJsonObject {
        putJsonArray("to") { add("priya@example.com") }
        put("subject", "Quote for October")
        put("text", "Hi Priya, the quote is attached.")
        put("inReplyTo", original.messageId)                 // a reply: threads it
        putJsonArray("attachments") {
            addJsonObject {
                put("filename", "quote.pdf")
                put("contentType", "application/pdf")
                put("content", Base64.encodeToString(pdfBytes, Base64.NO_WRAP))
            }
        }
    }
    post("/mail/send", body, idempotencyKey = UUID.randomUUID().toString())   // 202: queued, copy in Sent

Drafts are ordinary messages in Drafts (/mail/drafts), shared with webmail and IMAP apps: save as the person types, send with POST /mail/drafts/{id}/send.