We're getting there

This commit is contained in:
Philipp Heckel 2021-10-26 21:44:12 -04:00
parent 638c8f093a
commit 49b3898977
10 changed files with 91 additions and 144 deletions

View file

@ -1,19 +1,3 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.heckel.ntfy package io.heckel.ntfy
import android.app.Activity import android.app.Activity
@ -32,11 +16,11 @@ import androidx.recyclerview.widget.RecyclerView
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.*
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_NAME = "topic_name"
const val TOPIC_BASE_URL = "base_url"
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val newTopicActivityRequestCode = 1 private val newTopicActivityRequestCode = 1
@ -88,9 +72,9 @@ class MainActivity : AppCompatActivity() {
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 name = data.getStringExtra(TOPIC_NAME) ?: return
val topicUrl = data.getStringExtra(TOPIC_URL) ?: return val baseUrl = data.getStringExtra(TOPIC_BASE_URL) ?: return
val topic = Topic(topicId, topicUrl) val topic = Topic(Random.nextLong(), name, baseUrl)
topicsViewModel.add(topic) topicsViewModel.add(topic)
} }

View file

@ -1,20 +1,4 @@
/* package io.heckel.ntfy
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.heckel.ntfy.list
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -23,7 +7,6 @@ import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Topic import io.heckel.ntfy.data.Topic
class TopicsAdapter(private val onClick: (Topic) -> Unit) : class TopicsAdapter(private val onClick: (Topic) -> Unit) :
@ -43,10 +26,11 @@ class TopicsAdapter(private val onClick: (Topic) -> Unit) :
} }
} }
/* Bind topic name and image. */
fun bind(topic: Topic) { fun bind(topic: Topic) {
currentTopic = topic currentTopic = topic
topicTextView.text = topic.url val shortBaseUrl = topic.baseUrl.replace("https://", "") // Leave http:// untouched
val shortName = itemView.context.getString(R.string.topic_short_name_format, shortBaseUrl, topic.name)
topicTextView.text = shortName
} }
} }
@ -71,6 +55,6 @@ object TopicDiffCallback : DiffUtil.ItemCallback<Topic>() {
} }
override fun areContentsTheSame(oldItem: Topic, newItem: Topic): Boolean { override fun areContentsTheSame(oldItem: Topic, newItem: Topic): Boolean {
return oldItem.id == newItem.id return oldItem.name == newItem.name
} }
} }

View file

@ -4,15 +4,14 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.heckel.ntfy.data.TopicsRepository import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Topic import io.heckel.ntfy.data.Topic
import kotlinx.coroutines.*
import kotlin.collections.List import kotlin.collections.List
data class Notification(val topic: String, val message: String) data class Notification(val topic: String, val message: String)
typealias NotificationListener = (notification: Notification) -> Unit typealias NotificationListener = (notification: Notification) -> Unit
class TopicsViewModel(private val repository: TopicsRepository) : ViewModel() { class TopicsViewModel(private val repository: Repository) : ViewModel() {
fun add(topic: Topic) { fun add(topic: Topic) {
repository.add(topic, viewModelScope) repository.add(topic, viewModelScope)
} }
@ -39,7 +38,7 @@ class TopicsViewModelFactory() : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>) = override fun <T : ViewModel?> create(modelClass: Class<T>) =
with(modelClass){ with(modelClass){
when { when {
isAssignableFrom(TopicsViewModel::class.java) -> TopicsViewModel(TopicsRepository.getInstance()) as T isAssignableFrom(TopicsViewModel::class.java) -> TopicsViewModel(Repository.getInstance()) as T
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass") else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
} }
} }

View file

@ -1,19 +1,3 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.heckel.ntfy.add package io.heckel.ntfy.add
import android.app.Activity import android.app.Activity
@ -23,10 +7,12 @@ import android.widget.Button
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.TOPIC_URL import io.heckel.ntfy.TOPIC_BASE_URL
import io.heckel.ntfy.TOPIC_NAME
class AddTopicActivity : AppCompatActivity() { class AddTopicActivity : AppCompatActivity() {
private lateinit var addTopicUrl: TextInputEditText private lateinit var topicName: TextInputEditText
private lateinit var baseUrl: TextInputEditText
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -35,8 +21,9 @@ class AddTopicActivity : AppCompatActivity() {
findViewById<Button>(R.id.subscribe_button).setOnClickListener { findViewById<Button>(R.id.subscribe_button).setOnClickListener {
addTopic() addTopic()
} }
addTopicUrl = findViewById(R.id.add_topic_url) topicName = findViewById(R.id.add_topic_name)
addTopicUrl.setText("https://ntfy.sh/") baseUrl = findViewById(R.id.add_topic_base_url)
baseUrl.setText(R.string.topic_base_url_default_value)
} }
/* The onClick action for the done button. Closes the activity and returns the new topic name /* The onClick action for the done button. Closes the activity and returns the new topic name
@ -46,11 +33,13 @@ class AddTopicActivity : AppCompatActivity() {
private fun addTopic() { private fun addTopic() {
val resultIntent = Intent() val resultIntent = Intent()
if (addTopicUrl.text.isNullOrEmpty()) { // TODO don't allow this
if (baseUrl.text.isNullOrEmpty()) {
setResult(Activity.RESULT_CANCELED, resultIntent) setResult(Activity.RESULT_CANCELED, resultIntent)
} else { } else {
val url = addTopicUrl.text.toString() resultIntent.putExtra(TOPIC_NAME, topicName.text.toString())
resultIntent.putExtra(TOPIC_URL, url) resultIntent.putExtra(TOPIC_BASE_URL, baseUrl.text.toString())
setResult(Activity.RESULT_OK, resultIntent) setResult(Activity.RESULT_OK, resultIntent)
} }
finish() finish()

View file

@ -12,8 +12,8 @@ import java.io.IOException
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
/* Handles operations on topicsLiveData and holds details about it. */ class Repository {
class TopicsRepository { private val READ_TIMEOUT = 60_000 // Keep alive every 30s assumed
private val topics: MutableLiveData<List<Topic>> = MutableLiveData(mutableListOf()) private val topics: MutableLiveData<List<Topic>> = MutableLiveData(mutableListOf())
private val jobs = mutableMapOf<Long, Job>() private val jobs = mutableMapOf<Long, Job>()
private val gson = GsonBuilder().create() private val gson = GsonBuilder().create()
@ -62,33 +62,38 @@ class TopicsRepository {
private fun subscribeTopic(topic: Topic, scope: CoroutineScope): Job { private fun subscribeTopic(topic: Topic, scope: CoroutineScope): Job {
return scope.launch(Dispatchers.IO) { return scope.launch(Dispatchers.IO) {
while (isActive) { while (isActive) {
openURL(this, topic.url, topic.url) // TODO openConnection(this, topic)
delay(5000) // TODO exponential back-off delay(5000) // TODO exponential back-off
} }
} }
} }
private fun openURL(scope: CoroutineScope, topic: String, url: String) { private fun openConnection(scope: CoroutineScope, topic: Topic) {
val url = "${topic.baseUrl}/${topic.name}/json"
println("Connecting to $url ...") println("Connecting to $url ...")
val conn = (URL(url).openConnection() as HttpURLConnection).also { val conn = (URL(url).openConnection() as HttpURLConnection).also {
it.doInput = true it.doInput = true
it.readTimeout = READ_TIMEOUT
} }
try { try {
val input = conn.inputStream.bufferedReader() val input = conn.inputStream.bufferedReader()
while (scope.isActive) { while (scope.isActive) {
val line = input.readLine() ?: break // Exit if null val line = input.readLine() ?: break // Break if EOF is reached, i.e. readLine is null
if (!scope.isActive) {
break // Break if scope is not active anymore; readLine blocks for a while, so we want to be sure
}
try { try {
val json = gson.fromJson(line, JsonObject::class.java) ?: break // Exit if null val json = gson.fromJson(line, JsonObject::class.java) ?: break // Break on unexpected line
if (!json.isJsonNull && json.has("message")) { if (!json.isJsonNull && json.has("message")) {
val message = json.get("message").asString val message = json.get("message").asString
notificationListener?.let { it(Notification(url, message)) } notificationListener?.let { it(Notification(topic.name, message)) }
} }
} catch (e: JsonSyntaxException) { } catch (e: JsonSyntaxException) {
// Ignore invalid JSON break // Break on unexpected line
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
println("PHIL: " + e.message) println("Connection error: " + e.message)
} finally { } finally {
conn.disconnect() conn.disconnect()
} }
@ -96,11 +101,11 @@ class TopicsRepository {
} }
companion object { companion object {
private var instance: TopicsRepository? = null private var instance: Repository? = null
fun getInstance(): TopicsRepository { fun getInstance(): Repository {
return synchronized(TopicsRepository::class) { return synchronized(Repository::class) {
val newInstance = instance ?: TopicsRepository() val newInstance = instance ?: Repository()
instance = newInstance instance = newInstance
newInstance newInstance
} }

View file

@ -1,22 +1,7 @@
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.heckel.ntfy.data package io.heckel.ntfy.data
data class Topic( data class Topic(
val id: Long, val id: Long, // Internal to Repository only
val url: String, val name: String,
val baseUrl: String,
) )

View file

@ -35,30 +35,31 @@ class DetailActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.topic_detail_activity) setContentView(R.layout.topic_detail_activity)
var currentTopicId: Long? = null var topicId: Long? = null
/* Connect variables to UI elements. */ /* Connect variables to UI elements. */
val topicUrl: TextView = findViewById(R.id.topic_detail_url) val topicText: TextView = findViewById(R.id.topic_detail_url)
val removeTopicButton: Button = findViewById(R.id.remove_button) val removeButton: Button = findViewById(R.id.remove_button)
val bundle: Bundle? = intent.extras val bundle: Bundle? = intent.extras
if (bundle != null) { if (bundle != null) {
currentTopicId = bundle.getLong(TOPIC_ID) topicId = bundle.getLong(TOPIC_ID)
} }
// TODO This should probably fail hard if topicId is null
/* 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 { topicId?.let {
val currentTopic = topicsViewModel.get(it) val topic = topicsViewModel.get(it)
topicUrl.text = currentTopic?.url topicText.text = "${topic?.baseUrl}/${topic?.name}"
removeTopicButton.setOnClickListener { removeButton.setOnClickListener {
if (currentTopic != null) { if (topic != null) {
topicsViewModel.remove(currentTopic) topicsViewModel.remove(topic)
} }
finish() finish()
} }
} }
} }
} }

View file

@ -20,27 +20,40 @@
android:paddingRight="16dp"> android:paddingRight="16dp">
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingTop="16dp" android:paddingTop="16dp"
android:paddingBottom="16dp" android:paddingBottom="16dp"
android:text="@string/add_topic" android:text="@string/add_topic"
android:textAlignment="center" android:textAlignment="center"
android:textAppearance="?attr/textAppearanceHeadline4" /> android:textAppearance="?attr/textAppearanceHeadline4"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:hint="@string/topic_name_edit_text"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/add_topic_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:hint="@string/topic_name_edit_text" android:hint="@string/topic_base_url_edit_text"
android:paddingTop="16dp" android:paddingTop="16dp"
android:paddingBottom="16dp"> android:paddingBottom="16dp">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/add_topic_url" android:id="@+id/add_topic_base_url"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content"/>
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>

View file

@ -16,13 +16,13 @@
android:layout_height="80dp" android:layout_height="80dp"
android:orientation="vertical"> android:orientation="vertical">
<TextView <TextView
android:text="https://ntfy.sh/example" android:text="ntfy.sh/example"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/topic_text" android:layout_height="wrap_content" android:id="@+id/topic_text"
android:layout_marginTop="16dp" android:layout_marginLeft="16dp" android:layout_marginTop="16dp" android:layout_marginLeft="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/> android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
<TextView <TextView
android:text="Subscribed, 0 messages" android:text="Subscribed, 0 notifications"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/topic_status" android:layout_height="wrap_content" android:id="@+id/topic_status"
android:textAppearance="@style/TextAppearance.AppCompat.Small" android:layout_marginLeft="16dp"/> android:textAppearance="@style/TextAppearance.AppCompat.Small" android:layout_marginLeft="16dp"/>

View file

@ -1,25 +1,12 @@
<!--
Copyright (C) 2020 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<resources> <resources>
<string name="app_name">Ntfy</string> <string name="app_name">Ntfy</string>
<string name="add_topic">Add Topic</string> <string name="add_topic">Add Topic</string>
<string name="topic_string">Topics</string> <string name="topic_string">Topics</string>
<string name="topic_name_edit_text">Topic URL</string> <string name="topic_name_edit_text">Topic Name</string>
<string name="topic_base_url_edit_text">Service URL</string>
<string name="topic_base_url_default_value">https://ntfy.sh</string>
<string name="topic_short_name_format">%1$s/%2$s</string>
<string name="subscribe_button_text">Subscribe</string> <string name="subscribe_button_text">Subscribe</string>
<string name="fab_content_description">fab</string> <string name="fab_content_description">fab</string>
<string name="remove_topic">Unsubscribe</string> <string name="remove_topic">Unsubscribe</string>