Room, Firebase
This commit is contained in:
parent
573ab5db19
commit
fb755d486a
18 changed files with 303 additions and 306 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,3 +1,6 @@
|
|||
# Google services (Firebase/FCM) config and keys
|
||||
google-services.json
|
||||
|
||||
# built application files
|
||||
*.apk
|
||||
*.ap_
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -2,15 +2,20 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="io.heckel.ntfy">
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- Main app -->
|
||||
<application
|
||||
android:name = ".app.Application"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<!-- Main activity -->
|
||||
<activity android:name="io.heckel.ntfy.ui.MainActivity"
|
||||
android:icon="@drawable/ntfy"
|
||||
android:label="@string/app_name">
|
||||
|
@ -19,5 +24,18 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Firebase messaging -->
|
||||
<service android:name="io.heckel.ntfy.msg.MessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<meta-data android:name="firebase_analytics_collection_enabled"
|
||||
android:value="false" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/ntfy" /> <!-- FIXME Proper icon -->
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
10
app/src/main/java/io/heckel/ntfy/app/Application.kt
Normal file
10
app/src/main/java/io/heckel/ntfy/app/Application.kt
Normal 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()) }
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
52
app/src/main/java/io/heckel/ntfy/data/Database.kt
Normal file
52
app/src/main/java/io/heckel/ntfy/data/Database.kt
Normal 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)
|
||||
}
|
|
@ -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://", "")
|
|
@ -1,55 +1,44 @@
|
|||
package io.heckel.ntfy.data
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
|
||||
class Repository {
|
||||
private val subscriptions = mutableListOf<Subscription>()
|
||||
private val subscriptionsLiveData: MutableLiveData<List<Subscription>> = MutableLiveData(subscriptions)
|
||||
|
||||
fun add(subscription: Subscription) {
|
||||
synchronized(subscriptions) {
|
||||
subscriptions.add(subscription)
|
||||
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
|
||||
}
|
||||
}
|
||||
|
||||
fun update(subscription: Subscription?) {
|
||||
if (subscription == null) {
|
||||
return
|
||||
}
|
||||
synchronized(subscriptions) {
|
||||
val index = subscriptions.indexOfFirst { it.id == subscription.id } // Find index by Topic ID
|
||||
if (index == -1) return
|
||||
subscriptions[index] = subscription
|
||||
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(subscription: Subscription) {
|
||||
synchronized(subscriptions) {
|
||||
if (subscriptions.remove(subscription)) {
|
||||
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(id: Long): Subscription? {
|
||||
synchronized(subscriptions) {
|
||||
return subscriptions.firstOrNull { it.id == id } // Find index by Topic ID
|
||||
}
|
||||
}
|
||||
import androidx.lifecycle.asLiveData
|
||||
|
||||
class Repository(private val subscriptionDao: SubscriptionDao) {
|
||||
fun list(): LiveData<List<Subscription>> {
|
||||
return subscriptionsLiveData
|
||||
return subscriptionDao.list().asLiveData()
|
||||
}
|
||||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
@WorkerThread
|
||||
suspend fun get(baseUrl: String, topic: String): Subscription? {
|
||||
return subscriptionDao.get(baseUrl, topic)
|
||||
}
|
||||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
@WorkerThread
|
||||
suspend fun add(subscription: Subscription) {
|
||||
subscriptionDao.add(subscription)
|
||||
}
|
||||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
@WorkerThread
|
||||
suspend fun update(subscription: Subscription) {
|
||||
subscriptionDao.update(subscription)
|
||||
}
|
||||
|
||||
@Suppress("RedundantSuspendModifier")
|
||||
@WorkerThread
|
||||
suspend fun remove(subscription: Subscription) {
|
||||
subscriptionDao.remove(subscription)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var instance: Repository? = null
|
||||
|
||||
fun getInstance(): Repository {
|
||||
fun getInstance(subscriptionDao: SubscriptionDao): Repository {
|
||||
return synchronized(Repository::class) {
|
||||
val newInstance = instance ?: Repository()
|
||||
val newInstance = instance ?: Repository(subscriptionDao)
|
||||
instance = newInstance
|
||||
newInstance
|
||||
}
|
||||
|
|
6
app/src/main/java/io/heckel/ntfy/data/Util.kt
Normal file
6
app/src/main/java/io/heckel/ntfy/data/Util.kt
Normal file
|
@ -0,0 +1,6 @@
|
|||
package io.heckel.ntfy.data
|
||||
|
||||
fun topicShortUrl(baseUrl: String, topic: String) =
|
||||
"${baseUrl}/${topic}"
|
||||
.replace("http://", "")
|
||||
.replace("https://", "")
|
87
app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt
Normal file
87
app/src/main/java/io/heckel/ntfy/msg/MessagingService.kt
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.content.Intent
|
|||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
|
@ -15,19 +16,31 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.gms.tasks.OnCompleteListener
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.data.Notification
|
||||
import io.heckel.ntfy.data.Status
|
||||
import io.heckel.ntfy.data.Subscription
|
||||
import io.heckel.ntfy.data.topicShortUrl
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
const val SUBSCRIPTION_ID = "topic_id"
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import io.heckel.ntfy.app.Application
|
||||
import io.heckel.ntfy.data.*
|
||||
|
||||
class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
|
||||
private val subscriptionsViewModel by viewModels<SubscriptionsViewModel> {
|
||||
SubscriptionsViewModelFactory()
|
||||
SubscriptionsViewModelFactory((application as Application).repository)
|
||||
}
|
||||
|
||||
fun doStuff() {
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task ->
|
||||
if (!task.isSuccessful) {
|
||||
Log.w(TAG, "Fetching FCM registration token failed", task.exception)
|
||||
return@OnCompleteListener
|
||||
}
|
||||
|
||||
// Get new FCM registration token
|
||||
val token = task.result
|
||||
|
||||
// Log and toast
|
||||
Log.d(TAG, "message token: $token")
|
||||
})
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -41,12 +54,12 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
|
|||
// Floating action button ("+")
|
||||
val fab: View = findViewById(R.id.fab)
|
||||
fab.setOnClickListener {
|
||||
onAddButtonClick()
|
||||
onSubscribeButtonClick()
|
||||
}
|
||||
|
||||
// Update main list based on topicsViewModel (& its datasource/livedata)
|
||||
val noSubscriptionsText: View = findViewById(R.id.main_no_subscriptions_text)
|
||||
val adapter = SubscriptionsAdapter(this) { subscription -> onUnsubscribe(subscription) }
|
||||
val adapter = SubscriptionsAdapter { subscription -> onUnsubscribe(subscription) }
|
||||
val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list)
|
||||
mainList.adapter = adapter
|
||||
|
||||
|
@ -62,10 +75,6 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up notification channel
|
||||
createNotificationChannel()
|
||||
subscriptionsViewModel.setListener { n -> displayNotification(n) }
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
|
@ -80,55 +89,30 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
|
|||
true
|
||||
}
|
||||
R.id.menu_action_website -> {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_website_url))))
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.app_base_url))))
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onUnsubscribe(subscription: Subscription) {
|
||||
subscriptionsViewModel.remove(subscription)
|
||||
}
|
||||
|
||||
private fun onAddButtonClick() {
|
||||
private fun onSubscribeButtonClick() {
|
||||
val newFragment = AddFragment(this)
|
||||
newFragment.show(supportFragmentManager, "AddFragment")
|
||||
}
|
||||
|
||||
override fun onAddSubscription(topic: String, baseUrl: String) {
|
||||
val subscription = Subscription(Random.nextLong(), topic, baseUrl, Status.CONNECTING, 0)
|
||||
override fun onSubscribe(topic: String, baseUrl: String) {
|
||||
val subscription = Subscription(Random.nextLong(), topic, baseUrl, messages = 0)
|
||||
subscriptionsViewModel.add(subscription)
|
||||
FirebaseMessaging.getInstance().subscribeToTopic(topic) // FIXME ignores baseUrl
|
||||
}
|
||||
|
||||
private fun displayNotification(n: Notification) {
|
||||
val channelId = getString(R.string.notification_channel_id)
|
||||
val notification = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.drawable.ntfy)
|
||||
.setContentTitle(topicShortUrl(n.subscription))
|
||||
.setContentText(n.message)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.build()
|
||||
with(NotificationManagerCompat.from(this)) {
|
||||
notify(Random.nextInt(), notification)
|
||||
}
|
||||
private fun onUnsubscribe(subscription: Subscription) {
|
||||
subscriptionsViewModel.remove(subscription)
|
||||
FirebaseMessaging.getInstance().unsubscribeFromTopic(subscription.topic)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
// Create the NotificationChannel, but only on API 26+ because
|
||||
// the NotificationChannel class is new and not in the support library
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = getString(R.string.notification_channel_id)
|
||||
val name = getString(R.string.notification_channel_name)
|
||||
val descriptionText = getString(R.string.notification_channel_name)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val channel = NotificationChannel(channelId, name, importance).apply {
|
||||
description = descriptionText
|
||||
}
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
companion object {
|
||||
const val TAG = "NtfyMainActivity"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,13 +10,25 @@ import androidx.recyclerview.widget.DiffUtil
|
|||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.heckel.ntfy.R
|
||||
import io.heckel.ntfy.data.Status
|
||||
import io.heckel.ntfy.data.Subscription
|
||||
import io.heckel.ntfy.data.topicShortUrl
|
||||
|
||||
class SubscriptionsAdapter(private val context: Context, private val onClick: (Subscription) -> Unit) :
|
||||
class SubscriptionsAdapter(private val onClick: (Subscription) -> Unit) :
|
||||
ListAdapter<Subscription, SubscriptionsAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
|
||||
|
||||
/* Creates and inflates view and return TopicViewHolder. */
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.main_fragment_item, parent, false)
|
||||
return SubscriptionViewHolder(view, onClick)
|
||||
}
|
||||
|
||||
/* Gets current topic and uses it to bind view. */
|
||||
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
|
||||
val subscription = getItem(position)
|
||||
holder.bind(subscription)
|
||||
}
|
||||
|
||||
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
|
||||
class SubscriptionViewHolder(itemView: View, val onUnsubscribe: (Subscription) -> Unit) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
|
@ -30,12 +42,12 @@ class SubscriptionsAdapter(private val context: Context, private val onClick: (S
|
|||
popup.inflate(R.menu.main_item_popup_menu)
|
||||
popup.setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
R.id.main_item_popup_unsubscribe -> {
|
||||
subscription?.let { s -> onUnsubscribe(s) }
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
R.id.main_item_popup_unsubscribe -> {
|
||||
subscription?.let { s -> onUnsubscribe(s) }
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
itemView.setOnLongClickListener {
|
||||
subscription?.let { popup.show() }
|
||||
|
@ -45,41 +57,23 @@ class SubscriptionsAdapter(private val context: Context, private val onClick: (S
|
|||
|
||||
fun bind(subscription: Subscription) {
|
||||
this.subscription = subscription
|
||||
val notificationsCountMessage = if (subscription.messages == 1) {
|
||||
val statusMessage = if (subscription.messages == 1) {
|
||||
context.getString(R.string.main_item_status_text_one, subscription.messages)
|
||||
} else {
|
||||
context.getString(R.string.main_item_status_text_not_one, subscription.messages)
|
||||
}
|
||||
val statusText = when (subscription.status) {
|
||||
Status.CONNECTING -> notificationsCountMessage + ", " + context.getString(R.string.main_item_status_connecting)
|
||||
Status.RECONNECTING -> notificationsCountMessage + ", " + context.getString(R.string.main_item_status_reconnecting)
|
||||
else -> notificationsCountMessage
|
||||
}
|
||||
nameView.text = topicShortUrl(subscription)
|
||||
statusView.text = statusText
|
||||
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
|
||||
statusView.text = statusMessage
|
||||
}
|
||||
}
|
||||
|
||||
/* Creates and inflates view and return TopicViewHolder. */
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.main_fragment_item, parent, false)
|
||||
return SubscriptionViewHolder(view, onClick)
|
||||
}
|
||||
object TopicDiffCallback : DiffUtil.ItemCallback<Subscription>() {
|
||||
override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
/* Gets current topic and uses it to bind view. */
|
||||
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
|
||||
val subscription = getItem(position)
|
||||
holder.bind(subscription)
|
||||
}
|
||||
}
|
||||
|
||||
object TopicDiffCallback : DiffUtil.ItemCallback<Subscription>() {
|
||||
override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
||||
return oldItem == newItem
|
||||
override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,43 +3,32 @@ package io.heckel.ntfy.ui
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.heckel.ntfy.data.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.collections.List
|
||||
|
||||
class SubscriptionsViewModel(private val repository: Repository, private val connectionManager: ConnectionManager) : ViewModel() {
|
||||
fun add(topic: Subscription) {
|
||||
repository.add(topic)
|
||||
connectionManager.start(topic)
|
||||
}
|
||||
|
||||
fun get(id: Long) : Subscription? {
|
||||
return repository.get(id)
|
||||
}
|
||||
|
||||
class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
|
||||
fun list(): LiveData<List<Subscription>> {
|
||||
return repository.list()
|
||||
}
|
||||
|
||||
fun remove(topic: Subscription) {
|
||||
repository.remove(topic)
|
||||
connectionManager.stop(topic)
|
||||
fun add(topic: Subscription) = viewModelScope.launch(Dispatchers.IO) {
|
||||
repository.add(topic)
|
||||
}
|
||||
|
||||
fun setListener(listener: NotificationListener) {
|
||||
connectionManager.setListener(listener)
|
||||
fun remove(topic: Subscription) = viewModelScope.launch(Dispatchers.IO) {
|
||||
repository.remove(topic)
|
||||
}
|
||||
}
|
||||
|
||||
class SubscriptionsViewModelFactory : ViewModelProvider.Factory {
|
||||
class SubscriptionsViewModelFactory(private val repository: Repository) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>) =
|
||||
with(modelClass){
|
||||
when {
|
||||
isAssignableFrom(SubscriptionsViewModel::class.java) -> {
|
||||
val repository = Repository.getInstance()
|
||||
val connectionManager = ConnectionManager.getInstance(repository)
|
||||
SubscriptionsViewModel(repository, connectionManager) as T
|
||||
}
|
||||
isAssignableFrom(SubscriptionsViewModel::class.java) -> SubscriptionsViewModel(repository) as T
|
||||
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,5 +29,5 @@
|
|||
android:id="@+id/add_dialog_base_url_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" android:visibility="gone"
|
||||
android:hint="@string/add_dialog_base_url_hint" android:inputType="textUri" android:maxLines="1"/>
|
||||
android:hint="@string/app_base_url" android:inputType="textUri" android:maxLines="1"/>
|
||||
</LinearLayout>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<resources>
|
||||
<!-- Main app -->
|
||||
<!-- Main app-->
|
||||
<string name="app_name">Ntfy</string>
|
||||
<string name="app_base_url">https://ntfy.sh</string> <!-- If changed, you must also change google-services.json! -->
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="notification_channel_name">Ntfy</string>
|
||||
|
@ -11,7 +12,6 @@
|
|||
<string name="main_menu_source_title">Show source & license</string>
|
||||
<string name="main_menu_source_url">https://heckel.io/ntfy-android</string>
|
||||
<string name="main_menu_website_title">Visit ntfy.sh</string>
|
||||
<string name="main_menu_website_url">https://ntfy.sh</string>
|
||||
|
||||
<!-- Main activity: List and such -->
|
||||
<string name="main_item_status_connecting">connecting …</string>
|
||||
|
@ -26,8 +26,6 @@
|
|||
<string name="add_dialog_title">Subscribe to topic</string>
|
||||
<string name="add_dialog_topic_name_hint">Topic name, e.g. phils_alerts</string>
|
||||
<string name="add_dialog_use_another_server">Use another server</string>
|
||||
<string name="add_dialog_base_url_hint">https://ntfy.sh</string>
|
||||
<string name="add_dialog_base_url_default">https://ntfy.sh</string>
|
||||
<string name="add_dialog_button_cancel">Cancel</string>
|
||||
<string name="add_dialog_button_subscribe">Subscribe</string>
|
||||
</resources>
|
||||
|
|
20
build.gradle
20
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'
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue