Loading app/src/main/java/io/heckel/ntfy/MainActivity.kt +18 −64 Original line number Diff line number Diff line Loading @@ -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) } Loading @@ -60,26 +48,30 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val adapter = TopicsAdapter { topic -> adapterOnClick(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 topicsListViewModel.topics.observe(this) { topicsViewModel.list().observe(this) { it?.let { adapter.submitList(it as MutableList<Topic>) } } val fab: View = findViewById(R.id.fab) fab.setOnClickListener { fabOnClick() } // 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) Loading @@ -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)) { Loading app/src/main/java/io/heckel/ntfy/TopicsViewModel.kt +73 −9 Original line number Diff line number Diff line Loading @@ -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") } } Loading @@ -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") Loading app/src/main/java/io/heckel/ntfy/data/DataSource.kt +1 −1 Original line number Diff line number Diff line Loading @@ -54,7 +54,7 @@ class DataSource(resources: Resources) { return null } fun getTopicList(): LiveData<List<Topic>> { fun list(): LiveData<List<Topic>> { return topicsLiveData } Loading app/src/main/java/io/heckel/ntfy/detail/DetailActivity.kt +3 −3 Original line number Diff line number Diff line Loading @@ -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) } Loading @@ -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() } Loading Loading
app/src/main/java/io/heckel/ntfy/MainActivity.kt +18 −64 Original line number Diff line number Diff line Loading @@ -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) } Loading @@ -60,26 +48,30 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val adapter = TopicsAdapter { topic -> adapterOnClick(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 topicsListViewModel.topics.observe(this) { topicsViewModel.list().observe(this) { it?.let { adapter.submitList(it as MutableList<Topic>) } } val fab: View = findViewById(R.id.fab) fab.setOnClickListener { fabOnClick() } // 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) Loading @@ -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)) { Loading
app/src/main/java/io/heckel/ntfy/TopicsViewModel.kt +73 −9 Original line number Diff line number Diff line Loading @@ -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") } } Loading @@ -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") Loading
app/src/main/java/io/heckel/ntfy/data/DataSource.kt +1 −1 Original line number Diff line number Diff line Loading @@ -54,7 +54,7 @@ class DataSource(resources: Resources) { return null } fun getTopicList(): LiveData<List<Topic>> { fun list(): LiveData<List<Topic>> { return topicsLiveData } Loading
app/src/main/java/io/heckel/ntfy/detail/DetailActivity.kt +3 −3 Original line number Diff line number Diff line Loading @@ -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) } Loading @@ -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() } Loading