Move stuff to ViewModel, but as it turns out that's not a singleton so that's great

This commit is contained in:
Philipp Heckel 2021-10-26 15:55:59 -04:00
parent c6dd0c08e6
commit b25ce1f06a
4 changed files with 98 additions and 80 deletions

View file

@ -28,31 +28,19 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView 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.add.AddTopicActivity
import io.heckel.ntfy.data.Topic import io.heckel.ntfy.data.Topic
import io.heckel.ntfy.detail.DetailActivity import io.heckel.ntfy.detail.DetailActivity
import io.heckel.ntfy.list.TopicsAdapter import io.heckel.ntfy.list.*
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 kotlin.random.Random import kotlin.random.Random
const val TOPIC_ID = "topic id" const val TOPIC_ID = "topic id"
const val TOPIC_URL = "url" const val TOPIC_URL = "url"
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val gson = GsonBuilder().create()
private val jobs = mutableMapOf<Long, Job>()
private val newTopicActivityRequestCode = 1 private val newTopicActivityRequestCode = 1
private val topicsListViewModel by viewModels<TopicsViewModel> { private val topicsViewModel by viewModels<TopicsViewModel> {
TopicsViewModelFactory(this) TopicsViewModelFactory(this)
} }
@ -60,26 +48,30 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
val adapter = TopicsAdapter { topic -> adapterOnClick(topic) } // Floating action button ("+")
val recyclerView: RecyclerView = findViewById(R.id.recycler_view)
recyclerView.adapter = adapter
topicsListViewModel.topics.observe(this) {
it?.let {
adapter.submitList(it as MutableList<Topic>)
}
}
val fab: View = findViewById(R.id.fab) val fab: View = findViewById(R.id.fab)
fab.setOnClickListener { fab.setOnClickListener {
fabOnClick() 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() createNotificationChannel()
topicsViewModel.setNotificationListener { n -> displayNotification(n) }
} }
/* Opens TopicDetailActivity when RecyclerView item is clicked. */ /* Opens TopicDetailActivity when RecyclerView item is clicked. */
private fun adapterOnClick(topic: Topic) { private fun topicOnClick(topic: Topic) {
val intent = Intent(this, DetailActivity()::class.java) val intent = Intent(this, DetailActivity()::class.java)
intent.putExtra(TOPIC_ID, topic.id) intent.putExtra(TOPIC_ID, topic.id)
startActivity(intent) startActivity(intent)
@ -94,61 +86,23 @@ class MainActivity : AppCompatActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, intentData: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, intentData: Intent?) {
super.onActivityResult(requestCode, resultCode, intentData) super.onActivityResult(requestCode, resultCode, intentData)
/* Inserts topic into viewModel. */
if (requestCode == newTopicActivityRequestCode && resultCode == Activity.RESULT_OK) { if (requestCode == newTopicActivityRequestCode && resultCode == Activity.RESULT_OK) {
intentData?.let { data -> intentData?.let { data ->
val topicId = Random.nextLong() val topicId = Random.nextLong()
val topicUrl = data.getStringExtra(TOPIC_URL) ?: return val topicUrl = data.getStringExtra(TOPIC_URL) ?: return
val topic = Topic(topicId, topicUrl) val topic = Topic(topicId, topicUrl)
jobs[topicId] = subscribeTopic(topicUrl) topicsViewModel.add(topic)
topicsListViewModel.add(topic)
} }
} }
} }
private fun subscribeTopic(url: String): Job { private fun displayNotification(n: Notification) {
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
}
val channelId = getString(R.string.notification_channel_id) val channelId = getString(R.string.notification_channel_id)
val notification = NotificationCompat.Builder(this, channelId) val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ntfy) .setSmallIcon(R.drawable.ntfy)
.setContentTitle("ntfy") .setContentTitle(n.topic)
.setContentText(json.get("message").asString) .setContentText(n.message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build() .build()
with(NotificationManagerCompat.from(this)) { with(NotificationManagerCompat.from(this)) {

View file

@ -17,25 +17,89 @@
package io.heckel.ntfy.list package io.heckel.ntfy.list
import android.content.Context import android.content.Context
import androidx.lifecycle.LiveData import androidx.lifecycle.*
import androidx.lifecycle.ViewModel import com.google.gson.GsonBuilder
import androidx.lifecycle.ViewModelProvider import com.google.gson.JsonObject
import com.google.gson.JsonSyntaxException
import io.heckel.ntfy.data.DataSource import io.heckel.ntfy.data.DataSource
import io.heckel.ntfy.data.Topic 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() { data class Notification(val topic: String, val message: String)
val topics: LiveData<List<Topic>> = dataSource.getTopicList() 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) { 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? { 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) { 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)) { if (modelClass.isAssignableFrom(TopicsViewModel::class.java)) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return TopicsViewModel( return TopicsViewModel(
dataSource = DataSource.getDataSource(context.resources) datasource = DataSource.getDataSource(context.resources)
) as T ) as T
} }
throw IllegalArgumentException("Unknown ViewModel class") throw IllegalArgumentException("Unknown ViewModel class")

View file

@ -54,7 +54,7 @@ class DataSource(resources: Resources) {
return null return null
} }
fun getTopicList(): LiveData<List<Topic>> { fun list(): LiveData<List<Topic>> {
return topicsLiveData return topicsLiveData
} }

View file

@ -27,7 +27,7 @@ import io.heckel.ntfy.list.TopicsViewModel
import io.heckel.ntfy.list.TopicsViewModelFactory import io.heckel.ntfy.list.TopicsViewModelFactory
class DetailActivity : AppCompatActivity() { class DetailActivity : AppCompatActivity() {
private val topicDetailViewModel by viewModels<TopicsViewModel> { private val topicsViewModel by viewModels<TopicsViewModel> {
TopicsViewModelFactory(this) TopicsViewModelFactory(this)
} }
@ -49,12 +49,12 @@ class DetailActivity : AppCompatActivity() {
/* If currentTopicId is not null, get corresponding topic and set name, image and /* If currentTopicId is not null, get corresponding topic and set name, image and
description */ description */
currentTopicId?.let { currentTopicId?.let {
val currentTopic = topicDetailViewModel.get(it) val currentTopic = topicsViewModel.get(it)
topicUrl.text = currentTopic?.url topicUrl.text = currentTopic?.url
removeTopicButton.setOnClickListener { removeTopicButton.setOnClickListener {
if (currentTopic != null) { if (currentTopic != null) {
topicDetailViewModel.remove(currentTopic) topicsViewModel.remove(currentTopic)
} }
finish() finish()
} }