From b25ce1f06a08bfdd1b2e44ba2c564a92188dfdf5 Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Tue, 26 Oct 2021 15:55:59 -0400
Subject: [PATCH] Move stuff to ViewModel, but as it turns out that's not a
 singleton so that's great

---
 .../main/java/io/heckel/ntfy/MainActivity.kt  | 88 +++++--------------
 .../java/io/heckel/ntfy/TopicsViewModel.kt    | 82 +++++++++++++++--
 .../java/io/heckel/ntfy/data/DataSource.kt    |  2 +-
 .../io/heckel/ntfy/detail/DetailActivity.kt   |  6 +-
 4 files changed, 98 insertions(+), 80 deletions(-)

diff --git a/app/src/main/java/io/heckel/ntfy/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/MainActivity.kt
index de64c3d..dcbd2b0 100644
--- a/app/src/main/java/io/heckel/ntfy/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/MainActivity.kt
@@ -28,31 +28,19 @@ import androidx.activity.viewModels
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.app.NotificationCompat
 import androidx.core.app.NotificationManagerCompat
-import androidx.lifecycle.lifecycleScope
 import androidx.recyclerview.widget.RecyclerView
-import com.google.gson.GsonBuilder
-import com.google.gson.JsonObject
-import com.google.gson.JsonSyntaxException
 import io.heckel.ntfy.add.AddTopicActivity
 import io.heckel.ntfy.data.Topic
 import io.heckel.ntfy.detail.DetailActivity
-import io.heckel.ntfy.list.TopicsAdapter
-import io.heckel.ntfy.list.TopicsViewModel
-import io.heckel.ntfy.list.TopicsViewModelFactory
-import kotlinx.coroutines.*
-import java.io.IOException
-import java.net.HttpURLConnection
-import java.net.URL
+import io.heckel.ntfy.list.*
 import kotlin.random.Random
 
 const val TOPIC_ID = "topic id"
 const val TOPIC_URL = "url"
 
 class MainActivity : AppCompatActivity() {
-    private val gson = GsonBuilder().create()
-    private val jobs = mutableMapOf<Long, Job>()
     private val newTopicActivityRequestCode = 1
-    private val topicsListViewModel by viewModels<TopicsViewModel> {
+    private val topicsViewModel by viewModels<TopicsViewModel> {
         TopicsViewModelFactory(this)
     }
 
@@ -60,26 +48,30 @@ class MainActivity : AppCompatActivity() {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
 
-        val adapter = TopicsAdapter { topic -> adapterOnClick(topic) }
-        val recyclerView: RecyclerView = findViewById(R.id.recycler_view)
-        recyclerView.adapter = adapter
-
-        topicsListViewModel.topics.observe(this) {
-            it?.let {
-                adapter.submitList(it as MutableList<Topic>)
-            }
-        }
-
+        // Floating action button ("+")
         val fab: View = findViewById(R.id.fab)
         fab.setOnClickListener {
             fabOnClick()
         }
 
+        // Update main list based on topicsViewModel (& its datasource/livedata)
+        val adapter = TopicsAdapter { topic -> topicOnClick(topic) }
+        val recyclerView: RecyclerView = findViewById(R.id.recycler_view)
+        recyclerView.adapter = adapter
+
+        topicsViewModel.list().observe(this) {
+            it?.let {
+                adapter.submitList(it as MutableList<Topic>)
+            }
+        }
+
+        // Set up notification channel
         createNotificationChannel()
+        topicsViewModel.setNotificationListener { n -> displayNotification(n) }
     }
 
     /* Opens TopicDetailActivity when RecyclerView item is clicked. */
-    private fun adapterOnClick(topic: Topic) {
+    private fun topicOnClick(topic: Topic) {
         val intent = Intent(this, DetailActivity()::class.java)
         intent.putExtra(TOPIC_ID, topic.id)
         startActivity(intent)
@@ -94,61 +86,23 @@ class MainActivity : AppCompatActivity() {
     override fun onActivityResult(requestCode: Int, resultCode: Int, intentData: Intent?) {
         super.onActivityResult(requestCode, resultCode, intentData)
 
-        /* Inserts topic into viewModel. */
         if (requestCode == newTopicActivityRequestCode && resultCode == Activity.RESULT_OK) {
             intentData?.let { data ->
                 val topicId = Random.nextLong()
                 val topicUrl = data.getStringExtra(TOPIC_URL) ?: return
                 val topic = Topic(topicId, topicUrl)
 
-                jobs[topicId] = subscribeTopic(topicUrl)
-                topicsListViewModel.add(topic)
+                topicsViewModel.add(topic)
             }
         }
     }
 
-    private fun subscribeTopic(url: String): Job {
-        return this.lifecycleScope.launch(Dispatchers.IO) {
-            while (isActive) {
-                openURL(this, url)
-                delay(5000) // TODO exponential back-off
-            }
-        }
-    }
-
-    private fun openURL(scope: CoroutineScope, url: String) {
-        println("Connecting to $url ...")
-        val conn = (URL(url).openConnection() as HttpURLConnection).also {
-            it.doInput = true
-        }
-        try {
-            val input = conn.inputStream.bufferedReader()
-            while (scope.isActive) {
-                val line = input.readLine() ?: break // Exit if null
-                try {
-                    val json = gson.fromJson(line, JsonObject::class.java) ?: break // Exit if null
-                    displayNotification(json)
-                } catch (e: JsonSyntaxException) {
-                    // Ignore invalid JSON
-                }
-            }
-        } catch (e: IOException) {
-            println("PHIL: " + e.message)
-        } finally {
-            conn.disconnect()
-        }
-        println("Connection terminated: $url")
-    }
-
-    private fun displayNotification(json: JsonObject) {
-        if (json.isJsonNull || !json.has("message")) {
-            return
-        }
+    private fun displayNotification(n: Notification) {
         val channelId = getString(R.string.notification_channel_id)
         val notification = NotificationCompat.Builder(this, channelId)
             .setSmallIcon(R.drawable.ntfy)
-            .setContentTitle("ntfy")
-            .setContentText(json.get("message").asString)
+            .setContentTitle(n.topic)
+            .setContentText(n.message)
             .setPriority(NotificationCompat.PRIORITY_DEFAULT)
             .build()
         with(NotificationManagerCompat.from(this)) {
diff --git a/app/src/main/java/io/heckel/ntfy/TopicsViewModel.kt b/app/src/main/java/io/heckel/ntfy/TopicsViewModel.kt
index d0baf9d..46b3976 100644
--- a/app/src/main/java/io/heckel/ntfy/TopicsViewModel.kt
+++ b/app/src/main/java/io/heckel/ntfy/TopicsViewModel.kt
@@ -17,25 +17,89 @@
 package io.heckel.ntfy.list
 
 import android.content.Context
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.*
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonObject
+import com.google.gson.JsonSyntaxException
 import io.heckel.ntfy.data.DataSource
 import io.heckel.ntfy.data.Topic
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
 
-class TopicsViewModel(val dataSource: DataSource) : ViewModel() {
-    val topics: LiveData<List<Topic>> = dataSource.getTopicList()
+data class Notification(val topic: String, val message: String)
+typealias NotificationListener = (notification: Notification) -> Unit
+
+class TopicsViewModel(val datasource: DataSource) : ViewModel() {
+    private val gson = GsonBuilder().create()
+    private val jobs = mutableMapOf<Long, Job>()
+    private var notificationListener: NotificationListener? = null;
 
     fun add(topic: Topic) {
-        dataSource.add(topic)
+        println("Adding topic $topic $this")
+        datasource.add(topic)
+        jobs[topic.id] = subscribeTopic(topic.url)
     }
 
     fun get(id: Long) : Topic? {
-        return dataSource.get(id)
+        return datasource.get(id)
+    }
+
+    fun list(): LiveData<List<Topic>> {
+        return datasource.list()
     }
 
     fun remove(topic: Topic) {
-        dataSource.remove(topic)
+        println("Removing topic $topic $this")
+        jobs[topic.id]?.cancel()
+        println("${jobs[topic.id]}")
+
+        jobs.remove(topic.id)?.cancel() // Cancel and remove
+        println("${jobs[topic.id]}")
+        datasource.remove(topic)
+    }
+
+    fun setNotificationListener(listener: NotificationListener) {
+        notificationListener = listener
+    }
+
+    private fun subscribeTopic(url: String): Job {
+        return viewModelScope.launch(Dispatchers.IO) {
+            while (isActive) {
+                openURL(this, url)
+                delay(5000) // TODO exponential back-off
+            }
+        }
+    }
+
+    private fun openURL(scope: CoroutineScope, url: String) {
+        println("Connecting to $url ...")
+        val conn = (URL(url).openConnection() as HttpURLConnection).also {
+            it.doInput = true
+        }
+        try {
+            val input = conn.inputStream.bufferedReader()
+            while (scope.isActive) {
+                val line = input.readLine() ?: break // Exit if null
+                try {
+                    val json = gson.fromJson(line, JsonObject::class.java) ?: break // Exit if null
+                    if (!json.isJsonNull && json.has("message")) {
+                        val message = json.get("message").asString
+                        notificationListener?.let { it(Notification(url, message)) }
+                    }
+                } catch (e: JsonSyntaxException) {
+                    // Ignore invalid JSON
+                }
+            }
+        } catch (e: IOException) {
+            println("PHIL: " + e.message)
+        } finally {
+            conn.disconnect()
+        }
+        println("Connection terminated: $url")
     }
 }
 
@@ -44,7 +108,7 @@ class TopicsViewModelFactory(private val context: Context) : ViewModelProvider.F
         if (modelClass.isAssignableFrom(TopicsViewModel::class.java)) {
             @Suppress("UNCHECKED_CAST")
             return TopicsViewModel(
-                dataSource = DataSource.getDataSource(context.resources)
+                datasource = DataSource.getDataSource(context.resources)
             ) as T
         }
         throw IllegalArgumentException("Unknown ViewModel class")
diff --git a/app/src/main/java/io/heckel/ntfy/data/DataSource.kt b/app/src/main/java/io/heckel/ntfy/data/DataSource.kt
index 7ce8bc1..20d2a60 100644
--- a/app/src/main/java/io/heckel/ntfy/data/DataSource.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/DataSource.kt
@@ -54,7 +54,7 @@ class DataSource(resources: Resources) {
         return null
     }
 
-    fun getTopicList(): LiveData<List<Topic>> {
+    fun list(): LiveData<List<Topic>> {
         return topicsLiveData
     }
 
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 a2df000..4127936 100644
--- a/app/src/main/java/io/heckel/ntfy/detail/DetailActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/detail/DetailActivity.kt
@@ -27,7 +27,7 @@ import io.heckel.ntfy.list.TopicsViewModel
 import io.heckel.ntfy.list.TopicsViewModelFactory
 
 class DetailActivity : AppCompatActivity() {
-    private val topicDetailViewModel by viewModels<TopicsViewModel> {
+    private val topicsViewModel by viewModels<TopicsViewModel> {
         TopicsViewModelFactory(this)
     }
 
@@ -49,12 +49,12 @@ class DetailActivity : AppCompatActivity() {
         /* If currentTopicId is not null, get corresponding topic and set name, image and
         description */
         currentTopicId?.let {
-            val currentTopic = topicDetailViewModel.get(it)
+            val currentTopic = topicsViewModel.get(it)
             topicUrl.text = currentTopic?.url
 
             removeTopicButton.setOnClickListener {
                 if (currentTopic != null) {
-                    topicDetailViewModel.remove(currentTopic)
+                    topicsViewModel.remove(currentTopic)
                 }
                 finish()
             }