Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit b25ce1f0 authored by Philipp Heckel's avatar Philipp Heckel
Browse files

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

parent c6dd0c08
Loading
Loading
Loading
Loading
+18 −64
Original line number Diff line number Diff line
@@ -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) }
        // 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)
@@ -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)) {
+73 −9
Original line number Diff line number Diff line
@@ -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")
+1 −1
Original line number Diff line number Diff line
@@ -54,7 +54,7 @@ class DataSource(resources: Resources) {
        return null
    }

    fun getTopicList(): LiveData<List<Topic>> {
    fun list(): LiveData<List<Topic>> {
        return topicsLiveData
    }

+3 −3
Original line number Diff line number Diff line
@@ -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()
            }