package org.meshbay.client.notify import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import org.json.JSONObject import org.meshbay.client.MainActivity import org.meshbay.client.R /** * A notification from the hub, pushed or fetched, drawn by the system. * * The text is the hub's own line ("… posted in …") — the hub never holds a * message, so neither does a push. A conversation is one entry per group, * replaced as it goes on, as it is one row on the hub. */ object Notifier { const val CHANNEL_CHAT = "chat" const val CHANNEL_OTHER = "account" const val EXTRA_LINK = "org.meshbay.client.LINK" private val LINK = Regex("^#/[A-Za-z0-9/_-]{0,200}$") data class Shown(val channel: String, val tag: String, val title: String, val link: String?, val id: Long, val createdAt: String) /** * What to draw for a payload, or null for one that is not ours to draw. * It was decrypted with this phone's key or fetched from the signed-in hub * with this phone's secret; it is still checked like any input. */ fun parse(content: ByteArray): Shown? { val o = try { JSONObject(String(content, Charsets.UTF_8)) } catch (e: Exception) { return null } if (o.optInt("v", 0) != 1) return null val kind = o.optString("kind", "").take(32) val title = o.optString("title", "").take(256).ifBlank { return null } val group = if (o.isNull("group_id")) "" else o.optString("group_id", "").take(64) val link = (if (o.isNull("link")) null else o.optString("link", null))?.takeIf { LINK.matches(it) } val chat = kind == "chat_message" && group.isNotEmpty() val id = o.optLong("id", 0) val createdAt = o.optString("created_at", "").take(40) val tag = if (chat) "chat:$group" else "n:$id" return Shown(if (chat) CHANNEL_CHAT else CHANNEL_OTHER, tag, title, link, id, createdAt) } /** Draw it unless it was already drawn — pushed, then fetched, is one line. */ fun deliver(context: Context, state: PushState, shown: Shown) { if (state.firstSight(shown.id, shown.createdAt)) show(context, shown) } /** * The two channels, named in the person's language. Called with names when * the page turns push on; from the receiver with none, only to make sure a * channel exists, so a localized name is never overwritten by the fallback. */ fun ensureChannels(context: Context, names: Map? = null) { val nm = context.getSystemService(NotificationManager::class.java) for ((id, fallback, importance) in listOf( Triple(CHANNEL_CHAT, "Messages", NotificationManager.IMPORTANCE_DEFAULT), Triple(CHANNEL_OTHER, "Invitations and account", NotificationManager.IMPORTANCE_DEFAULT), )) { if (names == null && nm.getNotificationChannel(id) != null) continue nm.createNotificationChannel(NotificationChannel(id, names?.get(id) ?: fallback, importance)) } } fun show(context: Context, shown: Shown) { ensureChannels(context) val open = Intent(context, MainActivity::class.java) .setAction(Intent.ACTION_VIEW) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) shown.link?.let { open.putExtra(EXTRA_LINK, it) } val pending = PendingIntent.getActivity(context, shown.tag.hashCode(), open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) val n = Notification.Builder(context, shown.channel) .setSmallIcon(R.drawable.ic_notify) .setContentTitle("MeshBay") .setContentText(shown.title) .setStyle(Notification.BigTextStyle().bigText(shown.title)) .setContentIntent(pending) .setAutoCancel(true) .setCategory(if (shown.channel == CHANNEL_CHAT) Notification.CATEGORY_MESSAGE else Notification.CATEGORY_SOCIAL) .build() context.getSystemService(NotificationManager::class.java).notify(shown.tag, 1, n) } /** A link from a notification the shell itself drew, checked again on the way in. */ fun linkOf(intent: Intent?): String? = intent?.getStringExtra(EXTRA_LINK)?.takeIf { LINK.matches(it) } }