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
|
# built application files
|
||||||
*.apk
|
*.apk
|
||||||
*.ap_
|
*.ap_
|
||||||
|
|
|
@ -8,8 +8,10 @@ This is the Android app for [ntfy](https://github.com/binwiederhier/ntfy) ([ntfy
|
||||||
## License
|
## License
|
||||||
Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](LICENSE).
|
Made with ❤️ by [Philipp C. Heckel](https://heckel.io), distributed under the [Apache License 2.0](LICENSE).
|
||||||
|
|
||||||
This app is heavily based on:
|
Thank you to these fantastic resources:
|
||||||
* [RecyclerViewKotlin](https://github.com/android/views-widgets-samples/tree/main/RecyclerViewKotlin) (Apache 2.0)
|
* [RecyclerViewKotlin](https://github.com/android/views-widgets-samples/tree/main/RecyclerViewKotlin) (Apache 2.0)
|
||||||
* [Just another Hacker News Android client](https://github.com/manoamaro/another-hacker-news-client) (MIT)
|
* [Just another Hacker News Android client](https://github.com/manoamaro/another-hacker-news-client) (MIT)
|
||||||
|
* [Android Room with a View](https://github.com/googlecodelabs/android-room-with-a-view/tree/kotlin) (Apache 2.0)
|
||||||
|
* [Firebase Messaging Example](https://github.com/firebase/quickstart-android/blob/7147f60451b3eeaaa05fc31208ffb67e2df73c3c/messaging/app/src/main/java/com/google/firebase/quickstart/fcm/kotlin/MyFirebaseMessagingService.kt) (Apache 2.0)
|
||||||
|
|
||||||
Thanks to these projects for allowing me to copy-paste a lot.
|
Thanks to these projects for allowing me to copy-paste a lot.
|
||||||
|
|
|
@ -1,22 +1,8 @@
|
||||||
/*
|
|
||||||
* Copyright (C) 2020 The Android Open Source Project
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
apply plugin: 'kotlin-android-extensions'
|
apply plugin: 'kotlin-android-extensions'
|
||||||
|
apply plugin: 'kotlin-kapt'
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 30
|
compileSdkVersion 30
|
||||||
|
@ -55,6 +41,14 @@ dependencies {
|
||||||
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
|
implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"
|
||||||
implementation 'com.google.code.gson:gson:2.8.8'
|
implementation 'com.google.code.gson:gson:2.8.8'
|
||||||
|
|
||||||
|
// Room
|
||||||
|
def roomVersion = "2.3.0"
|
||||||
|
implementation "androidx.room:room-ktx:$roomVersion"
|
||||||
|
kapt "androidx.room:room-compiler:$roomVersion"
|
||||||
|
|
||||||
|
// Firebase, sigh ...
|
||||||
|
implementation 'com.google.firebase:firebase-messaging:22.0.0'
|
||||||
|
|
||||||
// RecyclerView
|
// RecyclerView
|
||||||
implementation "androidx.recyclerview:recyclerview:$rootProject.recyclerViewVersion"
|
implementation "androidx.recyclerview:recyclerview:$rootProject.recyclerViewVersion"
|
||||||
|
|
||||||
|
|
|
@ -2,15 +2,20 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="io.heckel.ntfy">
|
package="io.heckel.ntfy">
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
|
||||||
|
<!-- Main app -->
|
||||||
<application
|
<application
|
||||||
|
android:name = ".app.Application"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<!-- Main activity -->
|
||||||
<activity android:name="io.heckel.ntfy.ui.MainActivity"
|
<activity android:name="io.heckel.ntfy.ui.MainActivity"
|
||||||
android:icon="@drawable/ntfy"
|
android:icon="@drawable/ntfy"
|
||||||
android:label="@string/app_name">
|
android:label="@string/app_name">
|
||||||
|
@ -19,5 +24,18 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Firebase messaging -->
|
||||||
|
<service android:name="io.heckel.ntfy.msg.MessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<meta-data android:name="firebase_analytics_collection_enabled"
|
||||||
|
android:value="false" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
|
android:resource="@drawable/ntfy" /> <!-- FIXME Proper icon -->
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
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
|
package io.heckel.ntfy.data
|
||||||
|
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.asLiveData
|
||||||
|
|
||||||
class Repository {
|
|
||||||
private val subscriptions = mutableListOf<Subscription>()
|
|
||||||
private val subscriptionsLiveData: MutableLiveData<List<Subscription>> = MutableLiveData(subscriptions)
|
|
||||||
|
|
||||||
fun add(subscription: Subscription) {
|
|
||||||
synchronized(subscriptions) {
|
|
||||||
subscriptions.add(subscription)
|
|
||||||
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun update(subscription: Subscription?) {
|
|
||||||
if (subscription == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
synchronized(subscriptions) {
|
|
||||||
val index = subscriptions.indexOfFirst { it.id == subscription.id } // Find index by Topic ID
|
|
||||||
if (index == -1) return
|
|
||||||
subscriptions[index] = subscription
|
|
||||||
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun remove(subscription: Subscription) {
|
|
||||||
synchronized(subscriptions) {
|
|
||||||
if (subscriptions.remove(subscription)) {
|
|
||||||
subscriptionsLiveData.postValue(ArrayList(subscriptions)) // Copy!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun get(id: Long): Subscription? {
|
|
||||||
synchronized(subscriptions) {
|
|
||||||
return subscriptions.firstOrNull { it.id == id } // Find index by Topic ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
class Repository(private val subscriptionDao: SubscriptionDao) {
|
||||||
fun list(): LiveData<List<Subscription>> {
|
fun list(): LiveData<List<Subscription>> {
|
||||||
return subscriptionsLiveData
|
return subscriptionDao.list().asLiveData()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("RedundantSuspendModifier")
|
||||||
|
@WorkerThread
|
||||||
|
suspend fun get(baseUrl: String, topic: String): Subscription? {
|
||||||
|
return subscriptionDao.get(baseUrl, topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("RedundantSuspendModifier")
|
||||||
|
@WorkerThread
|
||||||
|
suspend fun add(subscription: Subscription) {
|
||||||
|
subscriptionDao.add(subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("RedundantSuspendModifier")
|
||||||
|
@WorkerThread
|
||||||
|
suspend fun update(subscription: Subscription) {
|
||||||
|
subscriptionDao.update(subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("RedundantSuspendModifier")
|
||||||
|
@WorkerThread
|
||||||
|
suspend fun remove(subscription: Subscription) {
|
||||||
|
subscriptionDao.remove(subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private var instance: Repository? = null
|
private var instance: Repository? = null
|
||||||
|
|
||||||
fun getInstance(): Repository {
|
fun getInstance(subscriptionDao: SubscriptionDao): Repository {
|
||||||
return synchronized(Repository::class) {
|
return synchronized(Repository::class) {
|
||||||
val newInstance = instance ?: Repository()
|
val newInstance = instance ?: Repository(subscriptionDao)
|
||||||
instance = newInstance
|
instance = newInstance
|
||||||
newInstance
|
newInstance
|
||||||
}
|
}
|
||||||
|
|
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() {
|
class AddFragment(private val listener: AddSubscriptionListener) : DialogFragment() {
|
||||||
interface AddSubscriptionListener {
|
interface AddSubscriptionListener {
|
||||||
fun onAddSubscription(topic: String, baseUrl: String)
|
fun onSubscribe(topic: String, baseUrl: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
@ -24,6 +24,9 @@ class AddFragment(private val listener: AddSubscriptionListener) : DialogFragmen
|
||||||
val baseUrlText = view.findViewById(R.id.add_dialog_base_url_text) as TextInputEditText
|
val baseUrlText = view.findViewById(R.id.add_dialog_base_url_text) as TextInputEditText
|
||||||
val useAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox) as CheckBox
|
val useAnotherServerCheckbox = view.findViewById(R.id.add_dialog_use_another_server_checkbox) as CheckBox
|
||||||
|
|
||||||
|
// FIXME For now, other servers are disabled
|
||||||
|
useAnotherServerCheckbox.visibility = View.GONE
|
||||||
|
|
||||||
// Build dialog
|
// Build dialog
|
||||||
val alert = AlertDialog.Builder(it)
|
val alert = AlertDialog.Builder(it)
|
||||||
.setView(view)
|
.setView(view)
|
||||||
|
@ -32,9 +35,9 @@ class AddFragment(private val listener: AddSubscriptionListener) : DialogFragmen
|
||||||
val baseUrl = if (useAnotherServerCheckbox.isChecked) {
|
val baseUrl = if (useAnotherServerCheckbox.isChecked) {
|
||||||
baseUrlText.text.toString()
|
baseUrlText.text.toString()
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.add_dialog_base_url_default)
|
getString(R.string.app_base_url)
|
||||||
}
|
}
|
||||||
listener.onAddSubscription(topic, baseUrl)
|
listener.onSubscribe(topic, baseUrl)
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.add_dialog_button_cancel) { _, _ ->
|
.setNegativeButton(R.string.add_dialog_button_cancel) { _, _ ->
|
||||||
dialog?.cancel()
|
dialog?.cancel()
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
@ -15,19 +16,31 @@ import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.gms.tasks.OnCompleteListener
|
||||||
import io.heckel.ntfy.R
|
import io.heckel.ntfy.R
|
||||||
import io.heckel.ntfy.data.Notification
|
|
||||||
import io.heckel.ntfy.data.Status
|
|
||||||
import io.heckel.ntfy.data.Subscription
|
|
||||||
import io.heckel.ntfy.data.topicShortUrl
|
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import io.heckel.ntfy.app.Application
|
||||||
const val SUBSCRIPTION_ID = "topic_id"
|
import io.heckel.ntfy.data.*
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
|
class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
|
||||||
private val subscriptionsViewModel by viewModels<SubscriptionsViewModel> {
|
private val subscriptionsViewModel by viewModels<SubscriptionsViewModel> {
|
||||||
SubscriptionsViewModelFactory()
|
SubscriptionsViewModelFactory((application as Application).repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun doStuff() {
|
||||||
|
FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task ->
|
||||||
|
if (!task.isSuccessful) {
|
||||||
|
Log.w(TAG, "Fetching FCM registration token failed", task.exception)
|
||||||
|
return@OnCompleteListener
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get new FCM registration token
|
||||||
|
val token = task.result
|
||||||
|
|
||||||
|
// Log and toast
|
||||||
|
Log.d(TAG, "message token: $token")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
@ -41,12 +54,12 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
|
||||||
// Floating action button ("+")
|
// Floating action button ("+")
|
||||||
val fab: View = findViewById(R.id.fab)
|
val fab: View = findViewById(R.id.fab)
|
||||||
fab.setOnClickListener {
|
fab.setOnClickListener {
|
||||||
onAddButtonClick()
|
onSubscribeButtonClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update main list based on topicsViewModel (& its datasource/livedata)
|
// Update main list based on topicsViewModel (& its datasource/livedata)
|
||||||
val noSubscriptionsText: View = findViewById(R.id.main_no_subscriptions_text)
|
val noSubscriptionsText: View = findViewById(R.id.main_no_subscriptions_text)
|
||||||
val adapter = SubscriptionsAdapter(this) { subscription -> onUnsubscribe(subscription) }
|
val adapter = SubscriptionsAdapter { subscription -> onUnsubscribe(subscription) }
|
||||||
val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list)
|
val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list)
|
||||||
mainList.adapter = adapter
|
mainList.adapter = adapter
|
||||||
|
|
||||||
|
@ -62,10 +75,6 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up notification channel
|
|
||||||
createNotificationChannel()
|
|
||||||
subscriptionsViewModel.setListener { n -> displayNotification(n) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
|
@ -80,55 +89,30 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.menu_action_website -> {
|
R.id.menu_action_website -> {
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_website_url))))
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.app_base_url))))
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onUnsubscribe(subscription: Subscription) {
|
private fun onSubscribeButtonClick() {
|
||||||
subscriptionsViewModel.remove(subscription)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onAddButtonClick() {
|
|
||||||
val newFragment = AddFragment(this)
|
val newFragment = AddFragment(this)
|
||||||
newFragment.show(supportFragmentManager, "AddFragment")
|
newFragment.show(supportFragmentManager, "AddFragment")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAddSubscription(topic: String, baseUrl: String) {
|
override fun onSubscribe(topic: String, baseUrl: String) {
|
||||||
val subscription = Subscription(Random.nextLong(), topic, baseUrl, Status.CONNECTING, 0)
|
val subscription = Subscription(Random.nextLong(), topic, baseUrl, messages = 0)
|
||||||
subscriptionsViewModel.add(subscription)
|
subscriptionsViewModel.add(subscription)
|
||||||
|
FirebaseMessaging.getInstance().subscribeToTopic(topic) // FIXME ignores baseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun displayNotification(n: Notification) {
|
private fun onUnsubscribe(subscription: Subscription) {
|
||||||
val channelId = getString(R.string.notification_channel_id)
|
subscriptionsViewModel.remove(subscription)
|
||||||
val notification = NotificationCompat.Builder(this, channelId)
|
FirebaseMessaging.getInstance().unsubscribeFromTopic(subscription.topic)
|
||||||
.setSmallIcon(R.drawable.ntfy)
|
|
||||||
.setContentTitle(topicShortUrl(n.subscription))
|
|
||||||
.setContentText(n.message)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.build()
|
|
||||||
with(NotificationManagerCompat.from(this)) {
|
|
||||||
notify(Random.nextInt(), notification)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
companion object {
|
||||||
// Create the NotificationChannel, but only on API 26+ because
|
const val TAG = "NtfyMainActivity"
|
||||||
// the NotificationChannel class is new and not in the support library
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channelId = getString(R.string.notification_channel_id)
|
|
||||||
val name = getString(R.string.notification_channel_name)
|
|
||||||
val descriptionText = getString(R.string.notification_channel_name)
|
|
||||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
|
||||||
val channel = NotificationChannel(channelId, name, importance).apply {
|
|
||||||
description = descriptionText
|
|
||||||
}
|
|
||||||
// Register the channel with the system
|
|
||||||
val notificationManager: NotificationManager =
|
|
||||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,13 +10,25 @@ import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.ListAdapter
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import io.heckel.ntfy.R
|
import io.heckel.ntfy.R
|
||||||
import io.heckel.ntfy.data.Status
|
|
||||||
import io.heckel.ntfy.data.Subscription
|
import io.heckel.ntfy.data.Subscription
|
||||||
import io.heckel.ntfy.data.topicShortUrl
|
import io.heckel.ntfy.data.topicShortUrl
|
||||||
|
|
||||||
class SubscriptionsAdapter(private val context: Context, private val onClick: (Subscription) -> Unit) :
|
class SubscriptionsAdapter(private val onClick: (Subscription) -> Unit) :
|
||||||
ListAdapter<Subscription, SubscriptionsAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
|
ListAdapter<Subscription, SubscriptionsAdapter.SubscriptionViewHolder>(TopicDiffCallback) {
|
||||||
|
|
||||||
|
/* Creates and inflates view and return TopicViewHolder. */
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.main_fragment_item, parent, false)
|
||||||
|
return SubscriptionViewHolder(view, onClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gets current topic and uses it to bind view. */
|
||||||
|
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
|
||||||
|
val subscription = getItem(position)
|
||||||
|
holder.bind(subscription)
|
||||||
|
}
|
||||||
|
|
||||||
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
|
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
|
||||||
class SubscriptionViewHolder(itemView: View, val onUnsubscribe: (Subscription) -> Unit) :
|
class SubscriptionViewHolder(itemView: View, val onUnsubscribe: (Subscription) -> Unit) :
|
||||||
RecyclerView.ViewHolder(itemView) {
|
RecyclerView.ViewHolder(itemView) {
|
||||||
|
@ -30,12 +42,12 @@ class SubscriptionsAdapter(private val context: Context, private val onClick: (S
|
||||||
popup.inflate(R.menu.main_item_popup_menu)
|
popup.inflate(R.menu.main_item_popup_menu)
|
||||||
popup.setOnMenuItemClickListener { item ->
|
popup.setOnMenuItemClickListener { item ->
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.main_item_popup_unsubscribe -> {
|
R.id.main_item_popup_unsubscribe -> {
|
||||||
subscription?.let { s -> onUnsubscribe(s) }
|
subscription?.let { s -> onUnsubscribe(s) }
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemView.setOnLongClickListener {
|
itemView.setOnLongClickListener {
|
||||||
subscription?.let { popup.show() }
|
subscription?.let { popup.show() }
|
||||||
|
@ -45,41 +57,23 @@ class SubscriptionsAdapter(private val context: Context, private val onClick: (S
|
||||||
|
|
||||||
fun bind(subscription: Subscription) {
|
fun bind(subscription: Subscription) {
|
||||||
this.subscription = subscription
|
this.subscription = subscription
|
||||||
val notificationsCountMessage = if (subscription.messages == 1) {
|
val statusMessage = if (subscription.messages == 1) {
|
||||||
context.getString(R.string.main_item_status_text_one, subscription.messages)
|
context.getString(R.string.main_item_status_text_one, subscription.messages)
|
||||||
} else {
|
} else {
|
||||||
context.getString(R.string.main_item_status_text_not_one, subscription.messages)
|
context.getString(R.string.main_item_status_text_not_one, subscription.messages)
|
||||||
}
|
}
|
||||||
val statusText = when (subscription.status) {
|
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
|
||||||
Status.CONNECTING -> notificationsCountMessage + ", " + context.getString(R.string.main_item_status_connecting)
|
statusView.text = statusMessage
|
||||||
Status.RECONNECTING -> notificationsCountMessage + ", " + context.getString(R.string.main_item_status_reconnecting)
|
|
||||||
else -> notificationsCountMessage
|
|
||||||
}
|
|
||||||
nameView.text = topicShortUrl(subscription)
|
|
||||||
statusView.text = statusText
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Creates and inflates view and return TopicViewHolder. */
|
object TopicDiffCallback : DiffUtil.ItemCallback<Subscription>() {
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
|
override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
||||||
val view = LayoutInflater.from(parent.context)
|
return oldItem.id == newItem.id
|
||||||
.inflate(R.layout.main_fragment_item, parent, false)
|
}
|
||||||
return SubscriptionViewHolder(view, onClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gets current topic and uses it to bind view. */
|
override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
||||||
override fun onBindViewHolder(holder: SubscriptionViewHolder, position: Int) {
|
return oldItem == newItem
|
||||||
val subscription = getItem(position)
|
}
|
||||||
holder.bind(subscription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object TopicDiffCallback : DiffUtil.ItemCallback<Subscription>() {
|
|
||||||
override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
|
||||||
return oldItem.id == newItem.id
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean {
|
|
||||||
return oldItem == newItem
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,43 +3,32 @@ package io.heckel.ntfy.ui
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.heckel.ntfy.data.*
|
import io.heckel.ntfy.data.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.collections.List
|
import kotlin.collections.List
|
||||||
|
|
||||||
class SubscriptionsViewModel(private val repository: Repository, private val connectionManager: ConnectionManager) : ViewModel() {
|
class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
|
||||||
fun add(topic: Subscription) {
|
|
||||||
repository.add(topic)
|
|
||||||
connectionManager.start(topic)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun get(id: Long) : Subscription? {
|
|
||||||
return repository.get(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun list(): LiveData<List<Subscription>> {
|
fun list(): LiveData<List<Subscription>> {
|
||||||
return repository.list()
|
return repository.list()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(topic: Subscription) {
|
fun add(topic: Subscription) = viewModelScope.launch(Dispatchers.IO) {
|
||||||
repository.remove(topic)
|
repository.add(topic)
|
||||||
connectionManager.stop(topic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setListener(listener: NotificationListener) {
|
fun remove(topic: Subscription) = viewModelScope.launch(Dispatchers.IO) {
|
||||||
connectionManager.setListener(listener)
|
repository.remove(topic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SubscriptionsViewModelFactory : ViewModelProvider.Factory {
|
class SubscriptionsViewModelFactory(private val repository: Repository) : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel?> create(modelClass: Class<T>) =
|
override fun <T : ViewModel?> create(modelClass: Class<T>) =
|
||||||
with(modelClass){
|
with(modelClass){
|
||||||
when {
|
when {
|
||||||
isAssignableFrom(SubscriptionsViewModel::class.java) -> {
|
isAssignableFrom(SubscriptionsViewModel::class.java) -> SubscriptionsViewModel(repository) as T
|
||||||
val repository = Repository.getInstance()
|
|
||||||
val connectionManager = ConnectionManager.getInstance(repository)
|
|
||||||
SubscriptionsViewModel(repository, connectionManager) as T
|
|
||||||
}
|
|
||||||
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
|
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,5 +29,5 @@
|
||||||
android:id="@+id/add_dialog_base_url_text"
|
android:id="@+id/add_dialog_base_url_text"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content" android:visibility="gone"
|
android:layout_height="wrap_content" android:visibility="gone"
|
||||||
android:hint="@string/add_dialog_base_url_hint" android:inputType="textUri" android:maxLines="1"/>
|
android:hint="@string/app_base_url" android:inputType="textUri" android:maxLines="1"/>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Main app -->
|
<!-- Main app-->
|
||||||
<string name="app_name">Ntfy</string>
|
<string name="app_name">Ntfy</string>
|
||||||
|
<string name="app_base_url">https://ntfy.sh</string> <!-- If changed, you must also change google-services.json! -->
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<string name="notification_channel_name">Ntfy</string>
|
<string name="notification_channel_name">Ntfy</string>
|
||||||
|
@ -11,7 +12,6 @@
|
||||||
<string name="main_menu_source_title">Show source & license</string>
|
<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_source_url">https://heckel.io/ntfy-android</string>
|
||||||
<string name="main_menu_website_title">Visit ntfy.sh</string>
|
<string name="main_menu_website_title">Visit ntfy.sh</string>
|
||||||
<string name="main_menu_website_url">https://ntfy.sh</string>
|
|
||||||
|
|
||||||
<!-- Main activity: List and such -->
|
<!-- Main activity: List and such -->
|
||||||
<string name="main_item_status_connecting">connecting …</string>
|
<string name="main_item_status_connecting">connecting …</string>
|
||||||
|
@ -26,8 +26,6 @@
|
||||||
<string name="add_dialog_title">Subscribe to topic</string>
|
<string name="add_dialog_title">Subscribe to topic</string>
|
||||||
<string name="add_dialog_topic_name_hint">Topic name, e.g. phils_alerts</string>
|
<string name="add_dialog_topic_name_hint">Topic name, e.g. phils_alerts</string>
|
||||||
<string name="add_dialog_use_another_server">Use another server</string>
|
<string name="add_dialog_use_another_server">Use another server</string>
|
||||||
<string name="add_dialog_base_url_hint">https://ntfy.sh</string>
|
|
||||||
<string name="add_dialog_base_url_default">https://ntfy.sh</string>
|
|
||||||
<string name="add_dialog_button_cancel">Cancel</string>
|
<string name="add_dialog_button_cancel">Cancel</string>
|
||||||
<string name="add_dialog_button_subscribe">Subscribe</string>
|
<string name="add_dialog_button_subscribe">Subscribe</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
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 {
|
buildscript {
|
||||||
ext.kotlin_version = '1.4.10'
|
ext.kotlin_version = '1.4.10'
|
||||||
repositories {
|
repositories {
|
||||||
|
@ -23,6 +7,7 @@ buildscript {
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:4.1.0'
|
classpath 'com.android.tools.build:gradle:4.1.0'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
classpath 'com.google.gms:google-services:4.3.10'
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
// in the individual module build.gradle files
|
// in the individual module build.gradle files
|
||||||
|
@ -33,7 +18,6 @@ allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
jcenter()
|
jcenter()
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,4 +33,4 @@ ext {
|
||||||
coreKtxVersion = '1.3.2'
|
coreKtxVersion = '1.3.2'
|
||||||
constraintLayoutVersion = '2.0.4'
|
constraintLayoutVersion = '2.0.4'
|
||||||
activityVersion = '1.1.0'
|
activityVersion = '1.1.0'
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue