Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 43b3aec3 authored by Philipp Heckel's avatar Philipp Heckel
Browse files

Stupid live data

parent 391b0436
Loading
Loading
Loading
Loading
+13 −11
Original line number Diff line number Diff line
@@ -14,10 +14,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.add.AddTopicActivity
import io.heckel.ntfy.data.Status
import io.heckel.ntfy.data.Topic
import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.data.*
import io.heckel.ntfy.detail.DetailActivity
import kotlin.random.Random

@@ -27,8 +24,8 @@ const val TOPIC_BASE_URL = "base_url"

class MainActivity : AppCompatActivity() {
    private val newTopicActivityRequestCode = 1
    private val topicsViewModel by viewModels<TopicsViewModel> {
        TopicsViewModelFactory()
    private val topicsViewModel by viewModels<SubscriptionViewModel> {
        SubscriptionsViewModelFactory()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
@@ -48,17 +45,22 @@ class MainActivity : AppCompatActivity() {

        topicsViewModel.list().observe(this) {
            it?.let {
                adapter.submitList(it as MutableList<Topic>)
                println("new data arrived: $it")
                adapter.submitList(it as MutableList<Subscription>)
            }
        }

        // Set up notification channel
        createNotificationChannel()
        topicsViewModel.setNotificationListener { n -> displayNotification(n) }
        topicsViewModel.setListener(object : NotificationListener {
            override fun onNotification(subscriptionId: Long, notification: Notification) {
                displayNotification(notification)
            }
        })
    }

    /* Opens TopicDetailActivity when RecyclerView item is clicked. */
    private fun topicOnClick(topic: Topic) {
    private fun topicOnClick(topic: Subscription) {
        val intent = Intent(this, DetailActivity()::class.java)
        intent.putExtra(TOPIC_ID, topic.id)
        startActivity(intent)
@@ -77,7 +79,7 @@ class MainActivity : AppCompatActivity() {
            intentData?.let { data ->
                val name = data.getStringExtra(TOPIC_NAME) ?: return
                val baseUrl = data.getStringExtra(TOPIC_BASE_URL) ?: return
                val topic = Topic(Random.nextLong(), name, baseUrl, Status.CONNECTING, 0)
                val topic = Subscription(Random.nextLong(), name, baseUrl, Status.CONNECTING, 0)

                topicsViewModel.add(topic)
            }
@@ -88,7 +90,7 @@ class MainActivity : AppCompatActivity() {
        val channelId = getString(R.string.notification_channel_id)
        val notification = NotificationCompat.Builder(this, channelId)
            .setSmallIcon(R.drawable.ntfy)
            .setContentTitle(topicShortUrl(n.topic))
            .setContentTitle(topicShortUrl(n.subscription))
            .setContentText(n.message)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .build()
+67 −0
Original line number Diff line number Diff line
package io.heckel.ntfy

import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.heckel.ntfy.data.*
import kotlin.collections.List


class SubscriptionViewModel(private val repository: Repository, private val connectionManager: ConnectionManager) : ViewModel() {
    fun add(topic: Subscription) {
        repository.add(topic)
        connectionManager.start(topic, viewModelScope)
    }

    fun get(id: Long) : Subscription? {
        return repository.get(id)
    }

    fun list(): LiveData<List<Subscription>> {
        return repository.list()
    }

    fun remove(topic: Subscription) {
        repository.remove(topic)
        connectionManager.stop(topic)
    }

    fun setListener(listener: NotificationListener) {
        connectionManager.setListener(object : ConnectionListener {
            override fun onStatusChanged(subcriptionId: Long, status: Status) {
                println("onStatusChanged($subcriptionId, $status)")
                val topic = repository.get(subcriptionId)
                if (topic != null) {
                    println("-> old topic: $topic")
                    repository.update(topic.copy(status = status))
                }
            }

            override fun onNotification(subscriptionId: Long, notification: Notification) {
                println("onNotification($subscriptionId, $notification)")
                val topic = repository.get(subscriptionId)
                if (topic != null) {
                    println("-> old topic: $topic")
                    repository.update(topic.copy(messages = topic.messages + 1))
                }
                listener.onNotification(subscriptionId, notification) // Forward downstream
            }
        })
    }
}

class SubscriptionsViewModelFactory : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel?> create(modelClass: Class<T>) =
        with(modelClass){
            when {
                isAssignableFrom(SubscriptionViewModel::class.java) -> {
                    val repository = Repository.getInstance()
                    val connectionManager = ConnectionManager.getInstance()
                    SubscriptionViewModel(repository, connectionManager) as T
                }
                else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
            }
        }
}
+19 −16
Original line number Diff line number Diff line
@@ -9,16 +9,16 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.data.Status
import io.heckel.ntfy.data.Topic
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicUrl

class TopicsAdapter(private val onClick: (Topic) -> Unit) :
    ListAdapter<Topic, TopicsAdapter.TopicViewHolder>(TopicDiffCallback) {
class TopicsAdapter(private val onClick: (Subscription) -> Unit) :
    ListAdapter<Subscription, TopicsAdapter.TopicViewHolder>(TopicDiffCallback) {

    /* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
    class TopicViewHolder(itemView: View, val onClick: (Topic) -> Unit) :
    class TopicViewHolder(itemView: View, val onClick: (Subscription) -> Unit) :
        RecyclerView.ViewHolder(itemView) {
        private var topic: Topic? = null
        private var topic: Subscription? = null
        private val context: Context = itemView.context
        private val nameView: TextView = itemView.findViewById(R.id.topic_text)
        private val statusView: TextView = itemView.findViewById(R.id.topic_status)
@@ -31,18 +31,19 @@ class TopicsAdapter(private val onClick: (Topic) -> Unit) :
            }
        }

        fun bind(topic: Topic) {
            this.topic = topic
            val statusText = when (topic.status) {
        fun bind(subscription: Subscription) {
            println("bind sub: $subscription")
            this.topic = subscription
            val statusText = when (subscription.status) {
                Status.CONNECTING -> context.getString(R.string.status_connecting)
                else -> context.getString(R.string.status_subscribed)
                else -> context.getString(R.string.status_connected)
            }
            val statusMessage = if (topic.messages == 1) {
                context.getString(R.string.status_text_one, statusText, topic.messages)
            val statusMessage = if (subscription.messages == 1) {
                context.getString(R.string.status_text_one, statusText, subscription.messages)
            } else {
                context.getString(R.string.status_text_not_one, statusText, topic.messages)
                context.getString(R.string.status_text_not_one, statusText, subscription.messages)
            }
            nameView.text = topicUrl(topic)
            nameView.text = topicUrl(subscription)
            statusView.text = statusMessage
        }
    }
@@ -61,12 +62,14 @@ class TopicsAdapter(private val onClick: (Topic) -> Unit) :
    }
}

object TopicDiffCallback : DiffUtil.ItemCallback<Topic>() {
    override fun areItemsTheSame(oldItem: Topic, newItem: Topic): Boolean {
object TopicDiffCallback : DiffUtil.ItemCallback<Subscription>() {
    override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
        println("areItemsTheSame: $oldItem.id ==? $newItem.id")
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: Topic, newItem: Topic): Boolean {
    override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
        println("areContentsTheSame: $oldItem ==? $newItem")
        return oldItem == newItem
    }
}
+0 −45
Original line number Diff line number Diff line
package io.heckel.ntfy

import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Topic
import kotlin.collections.List

data class Notification(val topic: Topic, val message: String)
typealias NotificationListener = (notification: Notification) -> Unit

class TopicsViewModel(private val repository: Repository) : ViewModel() {
    fun add(topic: Topic) {
        repository.add(topic, viewModelScope)
    }

    fun get(id: Long) : Topic? {
        return repository.get(id)
    }

    fun list(): LiveData<List<Topic>> {
        return repository.list()
    }

    fun remove(topic: Topic) {
        repository.remove(topic)
    }

    fun setNotificationListener(listener: NotificationListener) {
        repository.setNotificationListener(listener)
    }
}

class TopicsViewModelFactory() : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel?> create(modelClass: Class<T>) =
        with(modelClass){
            when {
                isAssignableFrom(TopicsViewModel::class.java) -> TopicsViewModel(Repository.getInstance()) as T
                else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
            }
        }
}
+84 −0
Original line number Diff line number Diff line
package io.heckel.ntfy.data

import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.*
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL

const val READ_TIMEOUT = 60_000 // Keep alive every 30s assumed

class ConnectionManager {
    private val jobs = mutableMapOf<Long, Job>()
    private val gson = GsonBuilder().create()
    private var listener: ConnectionListener? = null;

    fun start(subscription: Subscription, scope: CoroutineScope) {
        jobs[subscription.id] = launchConnection(subscription, scope)
    }

    fun stop(subscription: Subscription) {
        jobs.remove(subscription.id)?.cancel() // Cancel coroutine and remove
    }

    fun setListener(listener: ConnectionListener) {
        this.listener = listener
    }

    private fun launchConnection(subscription: Subscription, scope: CoroutineScope): Job {
        return scope.launch(Dispatchers.IO) {
            while (isActive) {
                openConnection(this, subscription)
                delay(5000) // TODO exponential back-off
            }
        }
    }

    private fun openConnection(scope: CoroutineScope, subscription: Subscription) {
        val url = "${subscription.baseUrl}/${subscription.topic}/json"
        println("Connecting to $url ...")
        val conn = (URL(url).openConnection() as HttpURLConnection).also {
            it.doInput = true
            it.readTimeout = READ_TIMEOUT
        }
        try {
            listener?.onStatusChanged(subscription.id, Status.CONNECTED)
            val input = conn.inputStream.bufferedReader()
            while (scope.isActive) {
                val line = input.readLine() ?: break // Break if EOF is reached, i.e. readLine is null
                if (!scope.isActive) {
                    break // Break if scope is not active anymore; readLine blocks for a while, so we want to be sure
                }
                try {
                    val json = gson.fromJson(line, JsonObject::class.java) ?: break // Break on unexpected line
                    if (!json.isJsonNull && !json.has("event") && json.has("message")) {
                        val message = json.get("message").asString
                        listener?.onNotification(subscription.id, Notification(subscription, message))
                    }
                } catch (e: JsonSyntaxException) {
                    break // Break on unexpected line
                }
            }
        } catch (e: IOException) {
            println("Connection error: " + e.message)
        } finally {
            conn.disconnect()
        }
        listener?.onStatusChanged(subscription.id, Status.CONNECTING)
        println("Connection terminated: $url")
    }

    companion object {
        private var instance: ConnectionManager? = null

        fun getInstance(): ConnectionManager {
            return synchronized(ConnectionManager::class) {
                val newInstance = instance ?: ConnectionManager()
                instance = newInstance
                newInstance
            }
        }
    }
}
Loading