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() }