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 # built application files
*.apk *.apk
*.ap_ *.ap_

View file

@ -8,8 +8,10 @@ This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy
## License ## License
Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](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) * [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) * [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. 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: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.gms.google-services'
android { android {
compileSdkVersion 30 compileSdkVersion 30
@ -55,6 +41,14 @@ dependencies {
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion" implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
implementation 'com.google.code.gson:gson:2.8.8' 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 // RecyclerView
implementation "androidx.recyclerview:recyclerview:$rootProject.recyclerViewVersion" implementation "androidx.recyclerview:recyclerview:$rootProject.recyclerViewVersion"

View file

@ -2,15 +2,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.heckel.ntfy"> package="io.heckel.ntfy">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Main app -->
<application <application
android:name = ".app.Application"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<!-- Main activity -->
<activity android:name="io.heckel.ntfy.ui.MainActivity" <activity android:name="io.heckel.ntfy.ui.MainActivity"
android:icon="@drawable/ntfy" android:icon="@drawable/ntfy"
android:label="@string/app_name"> android:label="@string/app_name">
@ -19,5 +24,18 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </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 package io.heckel.ntfy.data
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.asLiveData
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
}
}
class Repository(private val subscriptionDao: SubscriptionDao) {
fun list(): LiveData<List<Subscription>> { 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 { companion object {
private var instance: Repository? = null private var instance: Repository? = null
fun getInstance(): Repository { fun getInstance(subscriptionDao: SubscriptionDao): Repository {
return synchronized(Repository::class) { return synchronized(Repository::class) {
val newInstance = instance ?: Repository() val newInstance = instance ?: Repository(subscriptionDao)
instance = newInstance instance = newInstance
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() { class AddFragment(private val listener: AddSubscriptionListener) : DialogFragment() {
interface AddSubscriptionListener { interface AddSubscriptionListener {
fun onAddSubscription(topic: String, baseUrl: String) fun onSubscribe(topic: String, baseUrl: String)
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { 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 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 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 // Build dialog
val alert = AlertDialog.Builder(it) val alert = AlertDialog.Builder(it)
.setView(view) .setView(view)
@ -32,9 +35,9 @@ class AddFragment(private val listener: AddSubscriptionListener) : DialogFragmen
val baseUrl = if (useAnotherServerCheckbox.isChecked) { val baseUrl = if (useAnotherServerCheckbox.isChecked) {
baseUrlText.text.toString() baseUrlText.text.toString()
} else { } 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) { _, _ -> .setNegativeButton(R.string.add_dialog_button_cancel) { _, _ ->
dialog?.cancel() dialog?.cancel()

View file

@ -7,6 +7,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
@ -15,19 +16,31 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.gms.tasks.OnCompleteListener
import io.heckel.ntfy.R 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 import kotlin.random.Random
import com.google.firebase.messaging.FirebaseMessaging
import io.heckel.ntfy.app.Application
const val SUBSCRIPTION_ID = "topic_id" import io.heckel.ntfy.data.*
class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener { class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
private val subscriptionsViewModel by viewModels<SubscriptionsViewModel> { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -41,12 +54,12 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
// Floating action button ("+") // Floating action button ("+")
val fab: View = findViewById(R.id.fab) val fab: View = findViewById(R.id.fab)
fab.setOnClickListener { fab.setOnClickListener {
onAddButtonClick() onSubscribeButtonClick()
} }
// Update main list based on topicsViewModel (& its datasource/livedata) // Update main list based on topicsViewModel (& its datasource/livedata)
val noSubscriptionsText: View = findViewById(R.id.main_no_subscriptions_text) 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) val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list)
mainList.adapter = adapter 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 { override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -80,55 +89,30 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
true true
} }
R.id.menu_action_website -> { 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 true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
private fun onUnsubscribe(subscription: Subscription) { private fun onSubscribeButtonClick() {
subscriptionsViewModel.remove(subscription)
}
private fun onAddButtonClick() {
val newFragment = AddFragment(this) val newFragment = AddFragment(this)
newFragment.show(supportFragmentManager, "AddFragment") newFragment.show(supportFragmentManager, "AddFragment")
} }
override fun onAddSubscription(topic: String, baseUrl: String) { override fun onSubscribe(topic: String, baseUrl: String) {
val subscription = Subscription(Random.nextLong(), topic, baseUrl, Status.CONNECTING, 0) val subscription = Subscription(Random.nextLong(), topic, baseUrl, messages = 0)
subscriptionsViewModel.add(subscription) subscriptionsViewModel.add(subscription)
FirebaseMessaging.getInstance().subscribeToTopic(topic) // FIXME ignores baseUrl
} }
private fun displayNotification(n: Notification) { private fun onUnsubscribe(subscription: Subscription) {
val channelId = getString(R.string.notification_channel_id) subscriptionsViewModel.remove(subscription)
val notification = NotificationCompat.Builder(this, channelId) FirebaseMessaging.getInstance().unsubscribeFromTopic(subscription.topic)
.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 createNotificationChannel() { companion object {
// Create the NotificationChannel, but only on API 26+ because const val TAG = "NtfyMainActivity"
// 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)
}
} }
} }

View file

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

View file

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

View file

@ -29,5 +29,5 @@
android:id="@+id/add_dialog_base_url_text" android:id="@+id/add_dialog_base_url_text"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:visibility="gone" 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> </LinearLayout>

View file

@ -1,6 +1,7 @@
<resources> <resources>
<!-- Main app --> <!-- Main app-->
<string name="app_name">Ntfy</string> <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 --> <!-- Notifications -->
<string name="notification_channel_name">Ntfy</string> <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_title">Show source &amp; license</string>
<string name="main_menu_source_url">https://heckel.io/ntfy-android</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_title">Visit ntfy.sh</string>
<string name="main_menu_website_url">https://ntfy.sh</string>
<!-- Main activity: List and such --> <!-- Main activity: List and such -->
<string name="main_item_status_connecting">connecting …</string> <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_title">Subscribe to topic</string>
<string name="add_dialog_topic_name_hint">Topic name, e.g. phils_alerts</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_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_cancel">Cancel</string>
<string name="add_dialog_button_subscribe">Subscribe</string> <string name="add_dialog_button_subscribe">Subscribe</string>
</resources> </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 { buildscript {
ext.kotlin_version = '1.4.10' ext.kotlin_version = '1.4.10'
repositories { repositories {
@ -23,6 +7,7 @@ buildscript {
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:4.1.0' classpath 'com.android.tools.build:gradle:4.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 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 // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files
@ -33,7 +18,6 @@ allprojects {
repositories { repositories {
google() google()
jcenter() jcenter()
} }
} }
@ -49,4 +33,4 @@ ext {
coreKtxVersion = '1.3.2' coreKtxVersion = '1.3.2'
constraintLayoutVersion = '2.0.4' constraintLayoutVersion = '2.0.4'
activityVersion = '1.1.0' activityVersion = '1.1.0'
} }