diff --git a/.gitignore b/.gitignore
index 07cd930..fe2bb71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# Google services (Firebase/FCM) config and keys
+google-services.json
+
# built application files
*.apk
*.ap_
diff --git a/README.md b/README.md
index 35460f7..5de8756 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/app/build.gradle b/app/build.gradle
index 530058f..2911aac 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index acaad9f..618bac3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,15 +2,20 @@
+
+
+
+
@@ -19,5 +24,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/io/heckel/ntfy/app/Application.kt b/app/src/main/java/io/heckel/ntfy/app/Application.kt
new file mode 100644
index 0000000..317cdfa
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/app/Application.kt
@@ -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()) }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/data/ConnectionManager.kt b/app/src/main/java/io/heckel/ntfy/data/ConnectionManager.kt
deleted file mode 100644
index ed1afd6..0000000
--- a/app/src/main/java/io/heckel/ntfy/data/ConnectionManager.kt
+++ /dev/null
@@ -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()
- 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
- }
- }
- }
-}
diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt
new file mode 100644
index 0000000..ff5c440
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt
@@ -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>
+
+ @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)
+}
diff --git a/app/src/main/java/io/heckel/ntfy/data/Models.kt b/app/src/main/java/io/heckel/ntfy/data/Models.kt
deleted file mode 100644
index ae3bed7..0000000
--- a/app/src/main/java/io/heckel/ntfy/data/Models.kt
+++ /dev/null
@@ -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://", "")
diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
index 947cc3b..5fcda49 100644
--- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt
+++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt
@@ -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()
- private val subscriptionsLiveData: MutableLiveData> = 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> {
- 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
}
diff --git a/app/src/main/java/io/heckel/ntfy/data/Util.kt b/app/src/main/java/io/heckel/ntfy/data/Util.kt
new file mode 100644
index 0000000..e4394b7
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/data/Util.kt
@@ -0,0 +1,6 @@
+package io.heckel.ntfy.data
+
+fun topicShortUrl(baseUrl: String, topic: String) =
+ "${baseUrl}/${topic}"
+ .replace("http://", "")
+ .replace("https://", "")
diff --git a/app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt b/app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt
new file mode 100644
index 0000000..c46f1f4
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt
@@ -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"
+ }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
index a070440..e881996 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt
@@ -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()
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index cdbcf47..27fbfc0 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -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 {
- 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"
}
}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsAdapter.kt
index d630eba..455c7a2 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsAdapter.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsAdapter.kt
@@ -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(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() {
+ 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() {
- 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
+ }
}
}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsViewModel.kt
index 44b3b6c..538ee27 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsViewModel.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/SubscriptionsViewModel.kt
@@ -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> {
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 create(modelClass: Class) =
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")
}
}
diff --git a/app/src/main/res/layout/add_dialog_fragment.xml b/app/src/main/res/layout/add_dialog_fragment.xml
index eeab923..bc27862 100644
--- a/app/src/main/res/layout/add_dialog_fragment.xml
+++ b/app/src/main/res/layout/add_dialog_fragment.xml
@@ -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"/>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7f6c9fd..5f632c5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,6 +1,7 @@
-
+
Ntfy
+ https://ntfy.sh
Ntfy
@@ -11,7 +12,6 @@
Show source & license
https://heckel.io/ntfy-android
Visit ntfy.sh
- https://ntfy.sh
connecting …
@@ -26,8 +26,6 @@
Subscribe to topic
Topic name, e.g. phils_alerts
Use another server
- https://ntfy.sh
- https://ntfy.sh
Cancel
Subscribe
diff --git a/build.gradle b/build.gradle
index afcb5fe..642b6ef 100644
--- a/build.gradle
+++ b/build.gradle
@@ -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'
-}
\ No newline at end of file
+}