From fb755d486a07d7413bf316b391acc064df37422f Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 29 Oct 2021 21:13:58 -0400 Subject: [PATCH] Room, Firebase --- .gitignore | 3 + README.md | 4 +- app/build.gradle | 26 ++---- app/src/main/AndroidManifest.xml | 18 ++++ .../java/io/heckel/ntfy/app/Application.kt | 10 ++ .../io/heckel/ntfy/data/ConnectionManager.kt | 92 ------------------- .../main/java/io/heckel/ntfy/data/Database.kt | 52 +++++++++++ .../main/java/io/heckel/ntfy/data/Models.kt | 24 ----- .../java/io/heckel/ntfy/data/Repository.kt | 71 ++++++-------- app/src/main/java/io/heckel/ntfy/data/Util.kt | 6 ++ .../io/heckel/ntfy/msg/MessagingService.kt | 87 ++++++++++++++++++ .../java/io/heckel/ntfy/ui/AddFragment.kt | 9 +- .../java/io/heckel/ntfy/ui/MainActivity.kt | 82 +++++++---------- .../io/heckel/ntfy/ui/SubscriptionsAdapter.kt | 66 ++++++------- .../heckel/ntfy/ui/SubscriptionsViewModel.kt | 31 ++----- .../main/res/layout/add_dialog_fragment.xml | 2 +- app/src/main/res/values/strings.xml | 6 +- build.gradle | 20 +--- 18 files changed, 303 insertions(+), 306 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/app/Application.kt delete mode 100644 app/src/main/java/io/heckel/ntfy/data/ConnectionManager.kt create mode 100644 app/src/main/java/io/heckel/ntfy/data/Database.kt delete mode 100644 app/src/main/java/io/heckel/ntfy/data/Models.kt create mode 100644 app/src/main/java/io/heckel/ntfy/data/Util.kt create mode 100644 app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt 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 +}