Room, Firebase

This commit is contained in:
Philipp Heckel 2021-10-29 21:13:58 -04:00
parent 573ab5db19
commit fb755d486a
18 changed files with 303 additions and 306 deletions

3
.gitignore vendored
View file

@ -1,3 +1,6 @@
# Google services (Firebase/FCM) config and keys
google-services.json
# built application files
*.apk
*.ap_

View file

@ -8,8 +8,10 @@ This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy
## License
Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](LICENSE).
This app is heavily based on:
Thank you to these fantastic resources:
* [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)
* [Android Room with a View](https://github.com/googlecodelabs/android-room-with-a-view/tree/kotlin) (Apache 2.0)
* [Firebase Messaging Example](https://github.com/firebase/quickstart-android/blob/7147f60451b3eeaaa05fc31208ffb67e2df73c3c/messaging/app/src/main/java/com/google/firebase/quickstart/fcm/kotlin/MyFirebaseMessagingService.kt) (Apache 2.0)
Thanks to these projects for allowing me to copy-paste a lot.

View file

@ -1,22 +1,8 @@
/*
* 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.
*/
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.gms.google-services'
android {
compileSdkVersion 30
@ -55,6 +41,14 @@ dependencies {
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
implementation 'com.google.code.gson:gson:2.8.8'
// Room
def roomVersion = "2.3.0"
implementation "androidx.room:room-ktx:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
// Firebase, sigh ...
implementation 'com.google.firebase:firebase-messaging:22.0.0'
// RecyclerView
implementation "androidx.recyclerview:recyclerview:$rootProject.recyclerViewVersion"

View file

@ -2,15 +2,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.heckel.ntfy">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Main app -->
<application
android:name = ".app.Application"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<!-- Main activity -->
<activity android:name="io.heckel.ntfy.ui.MainActivity"
android:icon="@drawable/ntfy"
android:label="@string/app_name">
@ -19,5 +24,18 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Firebase messaging -->
<service android:name="io.heckel.ntfy.msg.MessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data android:name="firebase_analytics_collection_enabled"
android:value="false" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ntfy" /> <!-- FIXME Proper icon -->
</application>
</manifest>

View file

@ -0,0 +1,10 @@
package io.heckel.ntfy.app
import android.app.Application
import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Repository
class Application : Application() {
private val database by lazy { Database.getInstance(this) }
val repository by lazy { Repository.getInstance(database.subscriptionDao()) }
}

View file

@ -1,92 +0,0 @@
package io.heckel.ntfy.data
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
import kotlinx.coroutines.*
import java.net.HttpURLConnection
import java.net.URL
const val READ_TIMEOUT = 60_000 // Keep alive every 30s assumed
class ConnectionManager(private val repository: Repository) {
private val jobs = mutableMapOf<Long, Job>()
private val gson = GsonBuilder().create()
private var listener: NotificationListener? = null;
fun start(s: Subscription) {
jobs[s.id] = launchConnection(s.id, topicJsonUrl(s))
}
fun stop(s: Subscription) {
jobs.remove(s.id)?.cancel() // Cancel coroutine and remove
}
fun setListener(l: NotificationListener) {
this.listener = l
}
private fun launchConnection(subscriptionId: Long, topicUrl: String): Job {
return GlobalScope.launch(Dispatchers.IO) {
while (isActive) {
openConnection(subscriptionId, topicUrl)
delay(5000) // TODO exponential back-off
}
}
}
private fun openConnection(subscriptionId: Long, topicUrl: String) {
println("Connecting to $topicUrl ...")
val conn = (URL(topicUrl).openConnection() as HttpURLConnection).also {
it.doInput = true
it.readTimeout = READ_TIMEOUT
}
try {
updateStatus(subscriptionId, Status.CONNECTED)
val input = conn.inputStream.bufferedReader()
while (GlobalScope.isActive) {
val line = input.readLine() ?: break // Break if EOF is reached, i.e. readLine is null
if (!GlobalScope.isActive) {
break // Break if scope is not active anymore; readLine blocks for a while, so we want to be sure
}
val json = gson.fromJson(line, JsonObject::class.java) ?: break // Break on unexpected line
val validNotification = !json.isJsonNull
&& !json.has("event") // No keepalive or open messages
&& json.has("message")
if (validNotification) {
notify(subscriptionId, json.get("message").asString)
}
}
} catch (e: Exception) {
println("Connection error: " + e)
} finally {
conn.disconnect()
}
updateStatus(subscriptionId, Status.RECONNECTING)
println("Connection terminated: $topicUrl")
}
private fun updateStatus(subscriptionId: Long, status: Status) {
val subscription = repository.get(subscriptionId)
repository.update(subscription?.copy(status = status))
}
private fun notify(subscriptionId: Long, message: String) {
val subscription = repository.get(subscriptionId)
if (subscription != null) {
listener?.let { it(Notification(subscription, message)) }
repository.update(subscription.copy(messages = subscription.messages + 1))
}
}
companion object {
private var instance: ConnectionManager? = null
fun getInstance(repository: Repository): ConnectionManager {
return synchronized(ConnectionManager::class) {
val newInstance = instance ?: ConnectionManager(repository)
instance = newInstance
newInstance
}
}
}
}

View file

@ -0,0 +1,52 @@
package io.heckel.ntfy.data
import android.content.Context
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Entity
data class Subscription(
@PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
@ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "messages") val messages: Int
)
@androidx.room.Database(entities = [Subscription::class], version = 1)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
companion object {
@Volatile
private var instance: Database? = null
fun getInstance(context: Context): Database {
return instance ?: synchronized(this) {
val instance = Room
.databaseBuilder(context.applicationContext, Database::class.java,"AppDatabase")
.fallbackToDestructiveMigration()
.build()
this.instance = instance
instance
}
}
}
}
@Dao
interface SubscriptionDao {
@Query("SELECT * FROM subscription")
fun list(): Flow<List<Subscription>>
@Query("SELECT * FROM subscription WHERE baseUrl = :baseUrl AND topic = :topic")
fun get(baseUrl: String, topic: String): Subscription?
@Insert
fun add(subscription: Subscription)
@Update
fun update(subscription: Subscription)
@Delete
fun remove(subscription: Subscription)
}

View file

@ -1,24 +0,0 @@
package io.heckel.ntfy.data
enum class Status {
CONNECTED, CONNECTING, RECONNECTING
}
data class Subscription(
val id: Long, // Internal ID, only used in Repository and activities
val topic: String,
val baseUrl: String,
val status: Status,
val messages: Int
)
data class Notification(
val subscription: Subscription,
val message: String
)
typealias NotificationListener = (notification: Notification) -> Unit
fun topicUrl(s: Subscription) = "${s.baseUrl}/${s.topic}"
fun topicJsonUrl(s: Subscription) = "${s.baseUrl}/${s.topic}/json"
fun topicShortUrl(s: Subscription) = topicUrl(s).replace("http://", "").replace("https://", "")

View file

@ -1,55 +1,44 @@
package io.heckel.ntfy.data
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
class Repository {
private val subscriptions = mutableListOf<Subscription>()
private val subscriptionsLiveData: MutableLiveData<List<Subscription>> = MutableLiveData(subscriptions)
fun add(subscription: Subscription) {
synchronized(subscriptions) {
subscriptions.add(subscription)
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
}
}
fun update(subscription: Subscription?) {
if (subscription == null) {
return
}
synchronized(subscriptions) {
val index = subscriptions.indexOfFirst { it.id == subscription.id } // Find index by Topic ID
if (index == -1) return
subscriptions[index] = subscription
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
}
}
fun remove(subscription: Subscription) {
synchronized(subscriptions) {
if (subscriptions.remove(subscription)) {
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
}
}
}
fun get(id: Long): Subscription? {
synchronized(subscriptions) {
return subscriptions.firstOrNull { it.id == id } // Find index by Topic ID
}
}
import androidx.lifecycle.asLiveData
class Repository(private val subscriptionDao: SubscriptionDao) {
fun list(): LiveData<List<Subscription>> {
return subscriptionsLiveData
return subscriptionDao.list().asLiveData()
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun get(baseUrl: String, topic: String): Subscription? {
return subscriptionDao.get(baseUrl, topic)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun add(subscription: Subscription) {
subscriptionDao.add(subscription)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun update(subscription: Subscription) {
subscriptionDao.update(subscription)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun remove(subscription: Subscription) {
subscriptionDao.remove(subscription)
}
companion object {
private var instance: Repository? = null
fun getInstance(): Repository {
fun getInstance(subscriptionDao: SubscriptionDao): Repository {
return synchronized(Repository::class) {
val newInstance = instance ?: Repository()
val newInstance = instance ?: Repository(subscriptionDao)
instance = newInstance
newInstance
}

View file

@ -0,0 +1,6 @@
package io.heckel.ntfy.data
fun topicShortUrl(baseUrl: String, topic: String) =
"${baseUrl}/${topic}"
.replace("http://", "")
.replace("https://", "")

View file

@ -0,0 +1,87 @@
package io.heckel.ntfy.msg
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.RingtoneManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.topicShortUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.random.Random
class MessagingService : FirebaseMessagingService() {
private val database by lazy { Database.getInstance(this) }
private val repository by lazy { Repository.getInstance(database.subscriptionDao()) }
private val job = SupervisorJob()
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// We only process data messages
if (remoteMessage.data.isEmpty()) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}")
return
}
// Check if valid data, and send notification
val data = remoteMessage.data
val topic = data["topic"]
val message = data["message"]
if (topic == null || message == null) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}")
return
}
CoroutineScope(job).launch {
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!
// Update message counter
val subscription = repository.get(baseUrl, topic) ?: return@launch
val newSubscription = subscription.copy(messages = subscription.messages + 1)
repository.update(newSubscription)
// Send notification
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
val title = topicShortUrl(baseUrl, topic)
sendNotification(title, message)
}
}
override fun onNewToken(token: String) {
// Called if the FCM registration token is updated
// We don't actually use or care about the token, since we're using topics
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
}
private fun sendNotification(title: String, message: String) {
val channelId = getString(R.string.notification_channel_id)
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ntfy) // FIXME
.setContentTitle(title)
.setContentText(message)
.setSound(defaultSoundUri)
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelName = getString(R.string.notification_channel_name)
val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(Random.nextInt(), notificationBuilder.build())
}
companion object {
private const val TAG = "NtfyFirebase"
}
}

View file

@ -13,7 +13,7 @@ import io.heckel.ntfy.R
class AddFragment(private val listener: AddSubscriptionListener) : DialogFragment() {
interface AddSubscriptionListener {
fun onAddSubscription(topic: String, baseUrl: String)
fun onSubscribe(topic: String, baseUrl: String)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@ -24,6 +24,9 @@ class AddFragment(private val listener: AddSubscriptionListener) : DialogFragmen
val baseUrlText = view.findViewById(R.id.add_dialog_base_url_text) as TextInputEditText
val useAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox) as CheckBox
// FIXME For now, other servers are disabled
useAnotherServerCheckbox.visibility = View.GONE
// Build dialog
val alert = AlertDialog.Builder(it)
.setView(view)
@ -32,9 +35,9 @@ class AddFragment(private val listener: AddSubscriptionListener) : DialogFragmen
val baseUrl = if (useAnotherServerCheckbox.isChecked) {
baseUrlText.text.toString()
} else {
getString(R.string.add_dialog_base_url_default)
getString(R.string.app_base_url)
}
listener.onAddSubscription(topic, baseUrl)
listener.onSubscribe(topic, baseUrl)
}
.setNegativeButton(R.string.add_dialog_button_cancel) { _, _ ->
dialog?.cancel()

View file

@ -7,6 +7,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -15,19 +16,31 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.gms.tasks.OnCompleteListener
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Status
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl
import kotlin.random.Random
const val SUBSCRIPTION_ID = "topic_id"
import com.google.firebase.messaging.FirebaseMessaging
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.*
class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
private val subscriptionsViewModel by viewModels<SubscriptionsViewModel> {
SubscriptionsViewModelFactory()
SubscriptionsViewModelFactory((application as Application).repository)
}
fun doStuff() {
FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w(TAG, "Fetching FCM registration token failed", task.exception)
return@OnCompleteListener
}
// Get new FCM registration token
val token = task.result
// Log and toast
Log.d(TAG, "message token: $token")
})
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -41,12 +54,12 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
// Floating action button ("+")
val fab: View = findViewById(R.id.fab)
fab.setOnClickListener {
onAddButtonClick()
onSubscribeButtonClick()
}
// Update main list based on topicsViewModel (& its datasource/livedata)
val noSubscriptionsText: View = findViewById(R.id.main_no_subscriptions_text)
val adapter = SubscriptionsAdapter(this) { subscription -> onUnsubscribe(subscription) }
val adapter = SubscriptionsAdapter { subscription -> onUnsubscribe(subscription) }
val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list)
mainList.adapter = adapter
@ -62,10 +75,6 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
}
}
}
// Set up notification channel
createNotificationChannel()
subscriptionsViewModel.setListener { n -> displayNotification(n) }
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -80,55 +89,30 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
true
}
R.id.menu_action_website -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_website_url))))
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.app_base_url))))
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun onUnsubscribe(subscription: Subscription) {
subscriptionsViewModel.remove(subscription)
}
private fun onAddButtonClick() {
private fun onSubscribeButtonClick() {
val newFragment = AddFragment(this)
newFragment.show(supportFragmentManager, "AddFragment")
}
override fun onAddSubscription(topic: String, baseUrl: String) {
val subscription = Subscription(Random.nextLong(), topic, baseUrl, Status.CONNECTING, 0)
override fun onSubscribe(topic: String, baseUrl: String) {
val subscription = Subscription(Random.nextLong(), topic, baseUrl, messages = 0)
subscriptionsViewModel.add(subscription)
FirebaseMessaging.getInstance().subscribeToTopic(topic) // FIXME ignores baseUrl
}
private fun displayNotification(n: Notification) {
val channelId = getString(R.string.notification_channel_id)
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ntfy)
.setContentTitle(topicShortUrl(n.subscription))
.setContentText(n.message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
with(NotificationManagerCompat.from(this)) {
notify(Random.nextInt(), notification)
}
private fun onUnsubscribe(subscription: Subscription) {
subscriptionsViewModel.remove(subscription)
FirebaseMessaging.getInstance().unsubscribeFromTopic(subscription.topic)
}
private fun createNotificationChannel() {
// Create the NotificationChannel, but only on API 26+ because
// the NotificationChannel class is new and not in the support library
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = getString(R.string.notification_channel_id)
val name = getString(R.string.notification_channel_name)
val descriptionText = getString(R.string.notification_channel_name)
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(channelId, name, importance).apply {
description = descriptionText
}
// Register the channel with the system
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
companion object {
const val TAG = "NtfyMainActivity"
}
}

View file

@ -10,13 +10,25 @@ 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.Status
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl
class SubscriptionsAdapter(private val context: Context, private val onClick: (Subscription) -> Unit) :
class SubscriptionsAdapter(private val onClick: (Subscription) -> Unit) :
ListAdapter<Subscription, SubscriptionsAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
/* Creates and inflates view and return TopicViewHolder. */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.main_fragment_item, parent, false)
return SubscriptionViewHolder(view, onClick)
}
/* Gets current topic and uses it to bind view. */
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
val subscription = getItem(position)
holder.bind(subscription)
}
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
class SubscriptionViewHolder(itemView: View, val onUnsubscribe: (Subscription) -> Unit) :
RecyclerView.ViewHolder(itemView) {
@ -30,12 +42,12 @@ class SubscriptionsAdapter(private val context: Context, private val onClick: (S
popup.inflate(R.menu.main_item_popup_menu)
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.main_item_popup_unsubscribe -> {
subscription?.let { s -> onUnsubscribe(s) }
true
}
else -> false
}
R.id.main_item_popup_unsubscribe -> {
subscription?.let { s -> onUnsubscribe(s) }
true
}
else -> false
}
}
itemView.setOnLongClickListener {
subscription?.let { popup.show() }
@ -45,41 +57,23 @@ class SubscriptionsAdapter(private val context: Context, private val onClick: (S
fun bind(subscription: Subscription) {
this.subscription = subscription
val notificationsCountMessage = if (subscription.messages == 1) {
val statusMessage = if (subscription.messages == 1) {
context.getString(R.string.main_item_status_text_one, subscription.messages)
} else {
context.getString(R.string.main_item_status_text_not_one, subscription.messages)
}
val statusText = when (subscription.status) {
Status.CONNECTING -> notificationsCountMessage + ", " + context.getString(R.string.main_item_status_connecting)
Status.RECONNECTING -> notificationsCountMessage + ", " + context.getString(R.string.main_item_status_reconnecting)
else -> notificationsCountMessage
}
nameView.text = topicShortUrl(subscription)
statusView.text = statusText
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
statusView.text = statusMessage
}
}
/* Creates and inflates view and return TopicViewHolder. */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.main_fragment_item, parent, false)
return SubscriptionViewHolder(view, onClick)
}
object TopicDiffCallback : DiffUtil.ItemCallback<Subscription>() {
override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
return oldItem.id == newItem.id
}
/* Gets current topic and uses it to bind view. */
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
val subscription = getItem(position)
holder.bind(subscription)
}
}
object TopicDiffCallback : DiffUtil.ItemCallback<Subscription>() {
override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
return oldItem == newItem
override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
return oldItem == newItem
}
}
}

View file

@ -3,43 +3,32 @@ package io.heckel.ntfy.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.heckel.ntfy.data.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.collections.List
class SubscriptionsViewModel(private val repository: Repository, private val connectionManager: ConnectionManager) : ViewModel() {
fun add(topic: Subscription) {
repository.add(topic)
connectionManager.start(topic)
}
fun get(id: Long) : Subscription? {
return repository.get(id)
}
class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
fun list(): LiveData<List<Subscription>> {
return repository.list()
}
fun remove(topic: Subscription) {
repository.remove(topic)
connectionManager.stop(topic)
fun add(topic: Subscription) = viewModelScope.launch(Dispatchers.IO) {
repository.add(topic)
}
fun setListener(listener: NotificationListener) {
connectionManager.setListener(listener)
fun remove(topic: Subscription) = viewModelScope.launch(Dispatchers.IO) {
repository.remove(topic)
}
}
class SubscriptionsViewModelFactory : ViewModelProvider.Factory {
class SubscriptionsViewModelFactory(private val repository: Repository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>) =
with(modelClass){
when {
isAssignableFrom(SubscriptionsViewModel::class.java) -> {
val repository = Repository.getInstance()
val connectionManager = ConnectionManager.getInstance(repository)
SubscriptionsViewModel(repository, connectionManager) as T
}
isAssignableFrom(SubscriptionsViewModel::class.java) -> SubscriptionsViewModel(repository) as T
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
}
}

View file

@ -29,5 +29,5 @@
android:id="@+id/add_dialog_base_url_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:visibility="gone"
android:hint="@string/add_dialog_base_url_hint" android:inputType="textUri" android:maxLines="1"/>
android:hint="@string/app_base_url" android:inputType="textUri" android:maxLines="1"/>
</LinearLayout>

View file

@ -1,6 +1,7 @@
<resources>
<!-- Main app -->
<!-- Main app-->
<string name="app_name">Ntfy</string>
<string name="app_base_url">https://ntfy.sh</string> <!-- If changed, you must also change google-services.json! -->
<!-- Notifications -->
<string name="notification_channel_name">Ntfy</string>
@ -11,7 +12,6 @@
<string name="main_menu_source_title">Show source &amp; license</string>
<string name="main_menu_source_url">https://heckel.io/ntfy-android</string>
<string name="main_menu_website_title">Visit ntfy.sh</string>
<string name="main_menu_website_url">https://ntfy.sh</string>
<!-- Main activity: List and such -->
<string name="main_item_status_connecting">connecting …</string>
@ -26,8 +26,6 @@
<string name="add_dialog_title">Subscribe to topic</string>
<string name="add_dialog_topic_name_hint">Topic name, e.g. phils_alerts</string>
<string name="add_dialog_use_another_server">Use another server</string>
<string name="add_dialog_base_url_hint">https://ntfy.sh</string>
<string name="add_dialog_base_url_default">https://ntfy.sh</string>
<string name="add_dialog_button_cancel">Cancel</string>
<string name="add_dialog_button_subscribe">Subscribe</string>
</resources>

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.
*/
buildscript {
ext.kotlin_version = '1.4.10'
repositories {
@ -23,6 +7,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:4.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.10'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
@ -33,7 +18,6 @@ allprojects {
repositories {
google()
jcenter()
}
}
@ -49,4 +33,4 @@ ext {
coreKtxVersion = '1.3.2'
constraintLayoutVersion = '2.0.4'
activityVersion = '1.1.0'
}
}