Do not crash on wrong URL
This commit is contained in:
parent
ebe3ab4505
commit
12d194b8c4
6 changed files with 39 additions and 82 deletions
59
README.md
59
README.md
|
@ -1,52 +1,15 @@
|
||||||
Android RecyclerView Sample (Kotlin)
|
# ntfy Android App
|
||||||
====================================
|
This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy.sh](https://ntfy.sh)).
|
||||||
|
**It is very much work in progress. Also: I'm new to Android development, so I'm still learning.**
|
||||||
|
|
||||||
This application implements a RecyclerView in Kotlin with ListAdapter, onClickListener
|
## ...
|
||||||
and Headers. If you are looking for a simpler sample, look at the RecyclerViewSimple sample
|
...
|
||||||
in the directory.
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](LICENSE).
|
||||||
|
|
||||||
Introduction
|
This app is heavily based on:
|
||||||
------------
|
* [RecyclerViewKotlin](https://github.com/android/views-widgets-samples/tree/main/RecyclerViewKotlin) (Apache 2.0)
|
||||||
|
* [Just another Hacker News Android client](https://github.com/manoamaro/another-hacker-news-client) (MIT)
|
||||||
|
|
||||||
Sample demonstrating the use of [RecyclerView][1] to layout elements with a
|
Thanks to these projects for allowing me to copy-paste a lot.
|
||||||
[LinearLayoutManager][2].
|
|
||||||
|
|
||||||
[RecyclerView][1] can display large datasets that can be scrolled
|
|
||||||
efficiently by recycling a limited number of views. [ListAdapter][3] is used to
|
|
||||||
efficiently compute diffs when items are added/removed from the list. Click listeners can be
|
|
||||||
defined when [ViewHolder][4] views are instantiated.
|
|
||||||
|
|
||||||
|
|
||||||
[1]: https://developer.android.com/reference/kotlin/androidx/recyclerview/widget/RecyclerView
|
|
||||||
[2]: https://developer.android.com/reference/androidx/recyclerview/widget/LinearLayoutManager
|
|
||||||
[3]: https://developer.android.com/reference/androidx/recyclerview/widget/ListAdapter
|
|
||||||
[4]: https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.ViewHolder
|
|
||||||
|
|
||||||
Pre-requisites
|
|
||||||
--------------
|
|
||||||
|
|
||||||
- Android SDK 27
|
|
||||||
- Android Gradle Plugin 3.0
|
|
||||||
- Android Support Repository
|
|
||||||
|
|
||||||
Screenshots
|
|
||||||
-------------
|
|
||||||
|
|
||||||
![image](https://user-images.githubusercontent.com/46006059/98028846-8b6df700-1dc3-11eb-9f0b-ad93569be189.png)
|
|
||||||
|
|
||||||
Getting Started
|
|
||||||
---------------
|
|
||||||
|
|
||||||
To build this project, use "Import Project" in Android Studio.
|
|
||||||
|
|
||||||
Support
|
|
||||||
-------
|
|
||||||
|
|
||||||
- Stack Overflow: http://stackoverflow.com/questions/tagged/android
|
|
||||||
|
|
||||||
If you've found an error in this sample, please file an issue:
|
|
||||||
https://github.com/android/views-widgets
|
|
||||||
|
|
||||||
Patches are encouraged, and may be submitted by forking this project and
|
|
||||||
submitting a pull request through GitHub. Please see CONTRIBUTING.md for more details.
|
|
||||||
|
|
|
@ -22,8 +22,7 @@ import androidx.lifecycle.MutableLiveData
|
||||||
|
|
||||||
/* Handles operations on topicsLiveData and holds details about it. */
|
/* Handles operations on topicsLiveData and holds details about it. */
|
||||||
class DataSource(resources: Resources) {
|
class DataSource(resources: Resources) {
|
||||||
private val initialTopicList: List<Topic> = mutableListOf()
|
private val topicsLiveData: MutableLiveData<List<Topic>> = MutableLiveData(mutableListOf())
|
||||||
private val topicsLiveData = MutableLiveData(initialTopicList)
|
|
||||||
|
|
||||||
/* Adds topic to liveData and posts value. */
|
/* Adds topic to liveData and posts value. */
|
||||||
fun addTopic(topic: Topic) {
|
fun addTopic(topic: Topic) {
|
||||||
|
|
|
@ -11,34 +11,27 @@ import java.io.IOException
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
|
||||||
|
data class Event(val name: String = "", val data: JsonObject = JsonObject())
|
||||||
|
|
||||||
class NtfyApi(context: Context) {
|
class NtfyApi(context: Context) {
|
||||||
private val gson = GsonBuilder().create()
|
private val gson = GsonBuilder().create()
|
||||||
|
|
||||||
private suspend fun getStreamConnection(url: String): HttpURLConnection =
|
fun createEventsFlow(url: String): Flow<Event> = flow {
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
return@withContext (URL(url).openConnection() as HttpURLConnection).also {
|
|
||||||
it.setRequestProperty("Accept", "text/event-stream")
|
|
||||||
it.doInput = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Event(val name: String = "", val data: JsonObject = JsonObject())
|
|
||||||
|
|
||||||
fun getEventsFlow(): Flow<Event> = flow {
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
println("111111111111")
|
val conn = getStreamConnection(url)
|
||||||
|
|
||||||
val conn = getStreamConnection("https://ntfy.sh/_phil/sse")
|
|
||||||
println("2222222222222")
|
println("2222222222222")
|
||||||
val input = conn.inputStream.bufferedReader()
|
val input = conn.inputStream.bufferedReader()
|
||||||
try {
|
try {
|
||||||
conn.connect()
|
conn.connect()
|
||||||
var event = Event()
|
var event = Event()
|
||||||
println("CCCCCCCCCCCCCCc")
|
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val line = input.readLine()
|
val line = input.readLine()
|
||||||
println("PHIL: " + line)
|
println("PHIL: " + line)
|
||||||
when {
|
when {
|
||||||
|
line == null -> {
|
||||||
|
this.cancel(CancellationException("EOF"))
|
||||||
|
break
|
||||||
|
}
|
||||||
line.startsWith("event:") -> {
|
line.startsWith("event:") -> {
|
||||||
event = event.copy(name = line.substring(6).trim())
|
event = event.copy(name = line.substring(6).trim())
|
||||||
}
|
}
|
||||||
|
@ -65,4 +58,11 @@ class NtfyApi(context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun getStreamConnection(url: String): HttpURLConnection =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
return@withContext (URL(url).openConnection() as HttpURLConnection).also {
|
||||||
|
it.doInput = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,6 @@ import io.heckel.ntfy.R
|
||||||
import io.heckel.ntfy.list.TOPIC_ID
|
import io.heckel.ntfy.list.TOPIC_ID
|
||||||
|
|
||||||
class TopicDetailActivity : AppCompatActivity() {
|
class TopicDetailActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private val topicDetailViewModel by viewModels<TopicDetailViewModel> {
|
private val topicDetailViewModel by viewModels<TopicDetailViewModel> {
|
||||||
TopicDetailViewModelFactory(this)
|
TopicDetailViewModelFactory(this)
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import io.heckel.ntfy.R
|
import io.heckel.ntfy.R
|
||||||
import io.heckel.ntfy.add.AddTopicActivity
|
import io.heckel.ntfy.add.AddTopicActivity
|
||||||
import io.heckel.ntfy.add.TOPIC_URL
|
import io.heckel.ntfy.add.TOPIC_URL
|
||||||
|
import io.heckel.ntfy.data.Event
|
||||||
import io.heckel.ntfy.data.NtfyApi
|
import io.heckel.ntfy.data.NtfyApi
|
||||||
import io.heckel.ntfy.data.Topic
|
import io.heckel.ntfy.data.Topic
|
||||||
import io.heckel.ntfy.detail.TopicDetailActivity
|
import io.heckel.ntfy.detail.TopicDetailActivity
|
||||||
|
@ -59,7 +60,7 @@ class TopicsListActivity : AppCompatActivity() {
|
||||||
val recyclerView: RecyclerView = findViewById(R.id.recycler_view)
|
val recyclerView: RecyclerView = findViewById(R.id.recycler_view)
|
||||||
recyclerView.adapter = adapter
|
recyclerView.adapter = adapter
|
||||||
|
|
||||||
topicsListViewModel.topicsLiveData.observe(this) {
|
topicsListViewModel.topics.observe(this) {
|
||||||
it?.let {
|
it?.let {
|
||||||
adapter.submitList(it as MutableList<Topic>)
|
adapter.submitList(it as MutableList<Topic>)
|
||||||
}
|
}
|
||||||
|
@ -71,8 +72,10 @@ class TopicsListActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
api.getEventsFlow().asLiveData(Dispatchers.IO).observe(this) { event ->
|
private fun startFlow(url: String) {
|
||||||
|
api.createEventsFlow(url).asLiveData(Dispatchers.IO).observe(this) { event ->
|
||||||
this.lifecycleScope.launch(Dispatchers.Main) {
|
this.lifecycleScope.launch(Dispatchers.Main) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
handleEvent(event)
|
handleEvent(event)
|
||||||
|
@ -80,8 +83,7 @@ class TopicsListActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private fun handleEvent(event: Event) {
|
||||||
private fun handleEvent(event: NtfyApi.Event) {
|
|
||||||
if (event.data.isJsonNull || !event.data.has("message")) {
|
if (event.data.isJsonNull || !event.data.has("message")) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -117,8 +119,9 @@ class TopicsListActivity : AppCompatActivity() {
|
||||||
/* Inserts topic into viewModel. */
|
/* Inserts topic into viewModel. */
|
||||||
if (requestCode == newTopicActivityRequestCode && resultCode == Activity.RESULT_OK) {
|
if (requestCode == newTopicActivityRequestCode && resultCode == Activity.RESULT_OK) {
|
||||||
intentData?.let { data ->
|
intentData?.let { data ->
|
||||||
val topicName = data.getStringExtra(TOPIC_URL)
|
val topicUrl = data.getStringExtra(TOPIC_URL) ?: return
|
||||||
topicsListViewModel.insertTopic(topicName)
|
startFlow(topicUrl)
|
||||||
|
topicsListViewModel.insertTopic(topicUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
package io.heckel.ntfy.list
|
package io.heckel.ntfy.list
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import io.heckel.ntfy.data.DataSource
|
import io.heckel.ntfy.data.DataSource
|
||||||
|
@ -24,26 +25,18 @@ import io.heckel.ntfy.data.Topic
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
class TopicsListViewModel(val dataSource: DataSource) : ViewModel() {
|
class TopicsListViewModel(val dataSource: DataSource) : ViewModel() {
|
||||||
|
val topics: LiveData<List<Topic>> = dataSource.getTopicList()
|
||||||
|
|
||||||
val topicsLiveData = dataSource.getTopicList()
|
fun insertTopic(topicUrl: String) {
|
||||||
|
|
||||||
/* If the name and description are present, create new Topic and add it to the datasource */
|
|
||||||
fun insertTopic(topicUrl: String?) {
|
|
||||||
if (topicUrl == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val newTopic = Topic(
|
val newTopic = Topic(
|
||||||
Random.nextLong(),
|
Random.nextLong(),
|
||||||
topicUrl
|
topicUrl
|
||||||
)
|
)
|
||||||
|
|
||||||
dataSource.addTopic(newTopic)
|
dataSource.addTopic(newTopic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class TopicsListViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
|
class TopicsListViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
if (modelClass.isAssignableFrom(TopicsListViewModel::class.java)) {
|
if (modelClass.isAssignableFrom(TopicsListViewModel::class.java)) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
|
Loading…
Reference in a new issue