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
import android.app.Activity
@ -32,11 +16,11 @@ import androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.add.AddTopicActivity
import io.heckel.ntfy.data.Topic
import io.heckel.ntfy.detail.DetailActivity
import io.heckel.ntfy.list.*
import kotlin.random.Random
const val TOPIC_ID = "topic id"
const val TOPIC_URL = "url"
const val TOPIC_ID = "topic_id"
const val TOPIC_NAME = "topic_name"
const val TOPIC_BASE_URL = "base_url"
class MainActivity : AppCompatActivity() {
private val newTopicActivityRequestCode = 1
@ -88,9 +72,9 @@ class MainActivity : AppCompatActivity() {
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)
val name = data.getStringExtra(TOPIC_NAME) ?: return
val baseUrl = data.getStringExtra(TOPIC_BASE_URL) ?: return
val topic = Topic(Random.nextLong(), name, baseUrl)
topicsViewModel.add(topic)
}

View file

@ -1,20 +1,4 @@
/*
* 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
package io.heckel.ntfy
import android.view.LayoutInflater
import android.view.View
@ -23,7 +7,6 @@ import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Topic
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) {
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 {
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.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.heckel.ntfy.data.TopicsRepository
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Topic
import kotlinx.coroutines.*
import kotlin.collections.List
data class Notification(val topic: String, val message: String)
typealias NotificationListener = (notification: Notification) -> Unit
class TopicsViewModel(private val repository: TopicsRepository) : ViewModel() {
class TopicsViewModel(private val repository: Repository) : ViewModel() {
fun add(topic: Topic) {
repository.add(topic, viewModelScope)
}
@ -39,7 +38,7 @@ class TopicsViewModelFactory() : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>) =
with(modelClass){
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")
}
}

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
import android.app.Activity
@ -23,10 +7,12 @@ import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.textfield.TextInputEditText
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() {
private lateinit var addTopicUrl: TextInputEditText
private lateinit var topicName: TextInputEditText
private lateinit var baseUrl: TextInputEditText
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -35,8 +21,9 @@ class AddTopicActivity : AppCompatActivity() {
findViewById<Button>(R.id.subscribe_button).setOnClickListener {
addTopic()
}
addTopicUrl = findViewById(R.id.add_topic_url)
addTopicUrl.setText("https://ntfy.sh/")
topicName = findViewById(R.id.add_topic_name)
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
@ -46,11 +33,13 @@ class AddTopicActivity : AppCompatActivity() {
private fun addTopic() {
val resultIntent = Intent()
if (addTopicUrl.text.isNullOrEmpty()) {
// TODO don't allow this
if (baseUrl.text.isNullOrEmpty()) {
setResult(Activity.RESULT_CANCELED, resultIntent)
} else {
val url = addTopicUrl.text.toString()
resultIntent.putExtra(TOPIC_URL, url)
resultIntent.putExtra(TOPIC_NAME, topicName.text.toString())
resultIntent.putExtra(TOPIC_BASE_URL, baseUrl.text.toString())
setResult(Activity.RESULT_OK, resultIntent)
}
finish()

View file

@ -12,8 +12,8 @@ import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/* Handles operations on topicsLiveData and holds details about it. */
class TopicsRepository {
class Repository {
private val READ_TIMEOUT = 60_000 // Keep alive every 30s assumed
private val topics: MutableLiveData<List<Topic>> = MutableLiveData(mutableListOf())
private val jobs = mutableMapOf<Long, Job>()
private val gson = GsonBuilder().create()
@ -62,33 +62,38 @@ class TopicsRepository {
private fun subscribeTopic(topic: Topic, scope: CoroutineScope): Job {
return scope.launch(Dispatchers.IO) {
while (isActive) {
openURL(this, topic.url, topic.url) // TODO
openConnection(this, topic)
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 ...")
val conn = (URL(url).openConnection() as HttpURLConnection).also {
it.doInput = true
it.readTimeout = READ_TIMEOUT
}
try {
val input = conn.inputStream.bufferedReader()
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 {
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")) {
val message = json.get("message").asString
notificationListener?.let { it(Notification(url, message)) }
notificationListener?.let { it(Notification(topic.name, message)) }
}
} catch (e: JsonSyntaxException) {
// Ignore invalid JSON
break // Break on unexpected line
}
}
} catch (e: IOException) {
println("PHIL: " + e.message)
println("Connection error: " + e.message)
} finally {
conn.disconnect()
}
@ -96,11 +101,11 @@ class TopicsRepository {
}
companion object {
private var instance: TopicsRepository? = null
private var instance: Repository? = null
fun getInstance(): TopicsRepository {
return synchronized(TopicsRepository::class) {
val newInstance = instance ?: TopicsRepository()
fun getInstance(): Repository {
return synchronized(Repository::class) {
val newInstance = instance ?: Repository()
instance = 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
data class Topic(
val id: Long,
val url: String,
val id: Long, // Internal to Repository only
val name: String,
val baseUrl: String,
)

View file

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

View file

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

View file

@ -16,13 +16,13 @@
android:layout_height="80dp"
android:orientation="vertical">
<TextView
android:text="https://ntfy.sh/example"
android:text="ntfy.sh/example"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/topic_text"
android:layout_marginTop="16dp" android:layout_marginLeft="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
<TextView
android:text="Subscribed, 0 messages"
android:text="Subscribed, 0 notifications"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/topic_status"
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>
<string name="app_name">Ntfy</string>
<string name="add_topic">Add Topic</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="fab_content_description">fab</string>
<string name="remove_topic">Unsubscribe</string>