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.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)) {

View file

@ -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")

View file

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

View file

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