From 43b3aec311c7702a48236c711ed1e62b20cd71d2 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 27 Oct 2021 16:15:59 -0400 Subject: [PATCH] Stupid live data --- .../main/java/io/heckel/ntfy/MainActivity.kt | 24 +-- .../io/heckel/ntfy/SubscriptionViewModel.kt | 67 +++++++++ ...picsAdapter.kt => SubscriptionsAdapter.kt} | 35 +++-- .../java/io/heckel/ntfy/TopicsViewModel.kt | 45 ------ .../io/heckel/ntfy/data/ConnectionManager.kt | 84 +++++++++++ .../main/java/io/heckel/ntfy/data/Models.kt | 29 ++++ .../java/io/heckel/ntfy/data/Repository.kt | 138 +++--------------- .../main/java/io/heckel/ntfy/data/Topic.kt | 16 -- .../io/heckel/ntfy/detail/DetailActivity.kt | 23 +-- app/src/main/res/values/strings.xml | 4 +- 10 files changed, 250 insertions(+), 215 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/SubscriptionViewModel.kt rename app/src/main/java/io/heckel/ntfy/{TopicsAdapter.kt => SubscriptionsAdapter.kt} (61%) delete mode 100644 app/src/main/java/io/heckel/ntfy/TopicsViewModel.kt create mode 100644 app/src/main/java/io/heckel/ntfy/data/ConnectionManager.kt create mode 100644 app/src/main/java/io/heckel/ntfy/data/Models.kt delete mode 100644 app/src/main/java/io/heckel/ntfy/data/Topic.kt diff --git a/app/src/main/java/io/heckel/ntfy/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/MainActivity.kt index 2d3c6a1..b036011 100644 --- a/app/src/main/java/io/heckel/ntfy/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/MainActivity.kt @@ -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 { - TopicsViewModelFactory() + private val topicsViewModel by viewModels { + SubscriptionsViewModelFactory() } override fun onCreate(savedInstanceState: Bundle?) { @@ -48,17 +45,22 @@ class MainActivity : AppCompatActivity() { topicsViewModel.list().observe(this) { it?.let { - adapter.submitList(it as MutableList) + println("new data arrived: $it") + adapter.submitList(it as MutableList) } } // 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() diff --git a/app/src/main/java/io/heckel/ntfy/SubscriptionViewModel.kt b/app/src/main/java/io/heckel/ntfy/SubscriptionViewModel.kt new file mode 100644 index 0000000..193aa38 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/SubscriptionViewModel.kt @@ -0,0 +1,67 @@ +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> { + 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 create(modelClass: Class) = + 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") + } + } +} diff --git a/app/src/main/java/io/heckel/ntfy/TopicsAdapter.kt b/app/src/main/java/io/heckel/ntfy/SubscriptionsAdapter.kt similarity index 61% rename from app/src/main/java/io/heckel/ntfy/TopicsAdapter.kt rename to app/src/main/java/io/heckel/ntfy/SubscriptionsAdapter.kt index dced274..bb21d33 100644 --- a/app/src/main/java/io/heckel/ntfy/TopicsAdapter.kt +++ b/app/src/main/java/io/heckel/ntfy/SubscriptionsAdapter.kt @@ -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(TopicDiffCallback) { +class TopicsAdapter(private val onClick: (Subscription) -> Unit) : + ListAdapter(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() { - override fun areItemsTheSame(oldItem: Topic, newItem: Topic): Boolean { +object TopicDiffCallback : DiffUtil.ItemCallback() { + 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 } } diff --git a/app/src/main/java/io/heckel/ntfy/TopicsViewModel.kt b/app/src/main/java/io/heckel/ntfy/TopicsViewModel.kt deleted file mode 100644 index 9faf507..0000000 --- a/app/src/main/java/io/heckel/ntfy/TopicsViewModel.kt +++ /dev/null @@ -1,45 +0,0 @@ -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> { - 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 create(modelClass: Class) = - with(modelClass){ - when { - isAssignableFrom(TopicsViewModel::class.java) -> TopicsViewModel(Repository.getInstance()) as T - else -> throw IllegalArgumentException("Unknown viewModel class $modelClass") - } - } -} diff --git a/app/src/main/java/io/heckel/ntfy/data/ConnectionManager.kt b/app/src/main/java/io/heckel/ntfy/data/ConnectionManager.kt new file mode 100644 index 0000000..683827c --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/data/ConnectionManager.kt @@ -0,0 +1,84 @@ +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() + 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 + } + } + } +} diff --git a/app/src/main/java/io/heckel/ntfy/data/Models.kt b/app/src/main/java/io/heckel/ntfy/data/Models.kt new file mode 100644 index 0000000..21e68de --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/data/Models.kt @@ -0,0 +1,29 @@ +package io.heckel.ntfy.data + +enum class Status { + CONNECTED, CONNECTING +} + +data class Subscription( + val id: Long, // Internal ID, only used in Repository and activities + val topic: String, + val baseUrl: String, + val status: Status, + val messages: Int +) + +data class Notification( + val subscription: Subscription, + val message: String +) + +interface NotificationListener { + fun onNotification(subscriptionId: Long, notification: Notification) +} + +interface ConnectionListener : NotificationListener { + fun onStatusChanged(subcriptionId: Long, status: Status) +} + +fun topicUrl(s: Subscription) = "${s.baseUrl}/${s.topic}" +fun topicShortUrl(s: Subscription) = topicUrl(s).replace("http://", "").replace("https://", "") diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index 9090e4b..14e21b5 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -2,133 +2,43 @@ package io.heckel.ntfy.data import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import com.google.gson.GsonBuilder -import com.google.gson.JsonObject -import com.google.gson.JsonSyntaxException -import io.heckel.ntfy.Notification -import io.heckel.ntfy.NotificationListener -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 Repository { - private val topics: MutableLiveData> = MutableLiveData(mutableListOf()) - private val jobs = mutableMapOf() - private val gson = GsonBuilder().create() - private var notificationListener: NotificationListener? = null; + private val subscriptions = mutableListOf() + private val subscriptionsLiveData: MutableLiveData> = MutableLiveData(subscriptions) - fun add(topic: Topic, scope: CoroutineScope) { - val currentList = topics.value - if (currentList == null) { - topics.postValue(listOf(topic)) - } else { - val updatedList = currentList.toMutableList() - updatedList.add(0, topic) - topics.postValue(updatedList) + fun add(subscription: Subscription) { + synchronized(subscriptions) { + subscriptions.add(subscription) + subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy! } - jobs[topic.id] = subscribeTopic(topic, scope) } - fun update(topic: Topic) { - val currentList = topics.value - if (currentList == null) { - topics.postValue(listOf(topic)) - } else { - val index = currentList.indexOfFirst { it.id == topic.id } // Find index by Topic ID - if (index == -1) { - return // TODO race? - } else { - val updatedList = currentList.toMutableList() - updatedList[index] = topic - println("PHIL updated list:") - println(updatedList) - topics.postValue(updatedList) + fun update(subscription: Subscription) { + synchronized(subscriptions) { + val index = subscriptions.indexOfFirst { it.id == subscription.id } // Find index by Topic ID + if (index == -1) return + subscriptions[index] = subscription + subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy! + } + } + + fun remove(subscription: Subscription) { + synchronized(subscriptions) { + if (subscriptions.remove(subscription)) { + subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy! } } } - fun remove(topic: Topic) { - val currentList = topics.value - if (currentList != null) { - val updatedList = currentList.toMutableList() - updatedList.remove(topic) - topics.postValue(updatedList) - } - jobs.remove(topic.id)?.cancel() // Cancel coroutine and remove - } - - fun get(id: Long): Topic? { - topics.value?.let { topics -> - return topics.firstOrNull{ it.id == id} - } - return null - } - - fun list(): LiveData> { - return topics - } - - fun setNotificationListener(listener: NotificationListener) { - notificationListener = listener - } - - private fun subscribeTopic(topic: Topic, scope: CoroutineScope): Job { - return scope.launch(Dispatchers.IO) { - while (isActive) { - openConnection(this, topic) - delay(5000) // TODO exponential back-off - } + fun get(id: Long): Subscription? { + synchronized(subscriptions) { + return subscriptions.firstOrNull { it.id == id } // Find index by Topic ID } } - private fun openConnection(scope: CoroutineScope, topic: Topic) { - val url = "${topic.baseUrl}/${topic.name}/json" - println("Connecting to $url ...") - val conn = (URL(url).openConnection() as HttpURLConnection).also { - it.doInput = true - it.readTimeout = READ_TIMEOUT - } - // TODO ugly - val currentTopic = get(topic.id) - if (currentTopic != null) { - update(currentTopic.copy(status = Status.SUBSCRIBED)) - } - try { - 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("message")) { - val message = json.get("message").asString - notificationListener?.let { it(Notification(topic, message)) } - - // TODO ugly - val currentTopic = get(topic.id) - if (currentTopic != null) { - update(currentTopic.copy(messages = currentTopic.messages+1)) - } - } - } catch (e: JsonSyntaxException) { - break // Break on unexpected line - } - } - } catch (e: IOException) { - println("Connection error: " + e.message) - } finally { - conn.disconnect() - } - val currentTopic2 = get(topic.id) - if (currentTopic2 != null) { - update(currentTopic2.copy(status = Status.CONNECTING)) - } - println("Connection terminated: $url") + fun list(): LiveData> { + return subscriptionsLiveData } companion object { diff --git a/app/src/main/java/io/heckel/ntfy/data/Topic.kt b/app/src/main/java/io/heckel/ntfy/data/Topic.kt deleted file mode 100644 index 558cdfb..0000000 --- a/app/src/main/java/io/heckel/ntfy/data/Topic.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.heckel.ntfy.data - -enum class Status { - SUBSCRIBED, CONNECTING -} - -data class Topic( - val id: Long, // Internal ID, only used in Repository and activities - val name: String, - val baseUrl: String, - val status: Status, - val messages: Int -) - -fun topicUrl(t: Topic) = "${t.baseUrl}/${t.name}" -fun topicShortUrl(t: Topic) = topicUrl(t).replace("http://", "").replace("https://", "") diff --git a/app/src/main/java/io/heckel/ntfy/detail/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/detail/DetailActivity.kt index a5149bf..c39b071 100644 --- a/app/src/main/java/io/heckel/ntfy/detail/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/detail/DetailActivity.kt @@ -23,19 +23,20 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import io.heckel.ntfy.R import io.heckel.ntfy.TOPIC_ID -import io.heckel.ntfy.TopicsViewModel -import io.heckel.ntfy.TopicsViewModelFactory +import io.heckel.ntfy.SubscriptionViewModel +import io.heckel.ntfy.SubscriptionsViewModelFactory +import io.heckel.ntfy.data.topicShortUrl class DetailActivity : AppCompatActivity() { - private val topicsViewModel by viewModels { - TopicsViewModelFactory() + private val subscriptionsViewModel by viewModels { + SubscriptionsViewModelFactory() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.topic_detail_activity) - var topicId: Long? = null + var subscriptionId: Long? = null /* Connect variables to UI elements. */ val topicText: TextView = findViewById(R.id.topic_detail_url) @@ -43,20 +44,20 @@ class DetailActivity : AppCompatActivity() { val bundle: Bundle? = intent.extras if (bundle != null) { - topicId = bundle.getLong(TOPIC_ID) + subscriptionId = bundle.getLong(TOPIC_ID) } // TODO This should probably fail hard if topicId is null /* If currentTopicId is not null, get corresponding topic and set name, image and description */ - topicId?.let { - val topic = topicsViewModel.get(it) - topicText.text = "${topic?.baseUrl}/${topic?.name}" + subscriptionId?.let { + val subscription = subscriptionsViewModel.get(it) + topicText.text = subscription?.let { s -> topicShortUrl(s) } removeButton.setOnClickListener { - if (topic != null) { - topicsViewModel.remove(topic) + if (subscription != null) { + subscriptionsViewModel.remove(subscription) } finish() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 05b351d..e175556 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,12 +2,12 @@ Ntfy Add Topic - Topics + Topic Name Service URL https://ntfy.sh Subscribe - Subscribed + Connected Connecting %1$s, %2$d notification %1$s, %2$d notifications