Implement poll_request for Firebase to account for protected topics

This commit is contained in:
Philipp Heckel 2022-02-01 11:57:48 -05:00
parent 28bfd087c7
commit 82177253a7
8 changed files with 89 additions and 53 deletions

View file

@ -422,9 +422,9 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
private const val TAG = "NtfyRepository"
private var instance: Repository? = null
fun getInstance(activity: Activity): Repository {
val database = Database.getInstance(activity.applicationContext)
val sharedPrefs = activity.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
fun getInstance(context: Context): Repository {
val database = Database.getInstance(context.applicationContext)
val sharedPrefs = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
return getInstance(sharedPrefs, database)
}

View file

@ -16,7 +16,6 @@ import java.nio.charset.StandardCharsets.UTF_8
import java.util.concurrent.TimeUnit
import kotlin.random.Random
class ApiService {
private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request
@ -29,27 +28,23 @@ class ApiService {
.build()
private val parser = NotificationParser()
fun publish(baseUrl: String, topic: String, message: String, title: String, priority: Int, tags: List<String>, delay: String) {
fun publish(baseUrl: String, topic: String, user: User?, message: String, title: String, priority: Int, tags: List<String>, delay: String) {
val url = topicUrl(baseUrl, topic)
Log.d(TAG, "Publishing to $url")
// XXXXXXXXXXXx
var builder = Request.Builder()
.url(url)
val builder = builder(url, user)
.put(message.toRequestBody())
.addHeader("User-Agent", USER_AGENT)
if (priority in 1..5) {
builder = builder.addHeader("X-Priority", priority.toString())
builder.addHeader("X-Priority", priority.toString())
}
if (tags.isNotEmpty()) {
builder = builder.addHeader("X-Tags", tags.joinToString(","))
builder.addHeader("X-Tags", tags.joinToString(","))
}
if (title.isNotEmpty()) {
builder = builder.addHeader("X-Title", title)
builder.addHeader("X-Title", title)
}
if (delay.isNotEmpty()) {
builder = builder.addHeader("X-Delay", delay)
builder.addHeader("X-Delay", delay)
}
client.newCall(builder.build()).execute().use { response ->
if (!response.isSuccessful) {
@ -59,18 +54,12 @@ class ApiService {
}
}
fun poll(subscriptionId: Long, baseUrl: String, topic: String, since: Long = 0L): List<Notification> {
fun poll(subscriptionId: Long, baseUrl: String, topic: String, user: User?, since: Long = 0L): List<Notification> {
val sinceVal = if (since == 0L) "all" else since.toString()
val url = topicUrlJsonPoll(baseUrl, topic, sinceVal)
Log.d(TAG, "Polling topic $url")
val request = Request.Builder()
.url(url)
.addHeader("User-Agent", USER_AGENT)
.build()
// XXXXXXXXXXXx
val request = builder(url, user).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw Exception("Unexpected response ${response.code} when polling topic $url")
@ -97,14 +86,7 @@ class ApiService {
val sinceVal = if (since == 0L) "all" else since.toString()
val url = topicUrlJson(baseUrl, topics, sinceVal)
Log.d(TAG, "Opening subscription connection to $url")
val builder = Request.Builder()
.get()
.url(url)
.addHeader("User-Agent", USER_AGENT)
if (user != null) {
builder.addHeader("Authorization", Credentials.basic(user.username, user.password, UTF_8))
}
val request = builder.build()
val request = builder(url, user).build()
val call = subscriberClient.newCall(request)
call.enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
@ -140,14 +122,7 @@ class ApiService {
Log.d(TAG, "Checking read access for user ${user.username} against ${topicUrl(baseUrl, topic)}")
}
val url = topicUrlAuth(baseUrl, topic)
val builder = Request.Builder()
.get()
.url(url)
.addHeader("User-Agent", USER_AGENT)
if (user != null) {
builder.addHeader("Authorization", Credentials.basic(user.username, user.password, UTF_8))
}
val request = builder.build()
val request = builder(url, user).build()
client.newCall(request).execute().use { response ->
return if (user == null) {
response.isSuccessful || response.code == 404 // Treat 404 as success (old server; to be removed in future versions)
@ -157,6 +132,16 @@ class ApiService {
}
}
private fun builder(url: String, user: User?): Request.Builder {
val builder = Request.Builder()
.url(url)
.addHeader("User-Agent", USER_AGENT)
if (user != null) {
builder.addHeader("Authorization", Credentials.basic(user.username, user.password, UTF_8))
}
return builder
}
companion object {
val USER_AGENT = "ntfy/${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}; Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT})"
private const val TAG = "NtfyApiService"
@ -165,5 +150,6 @@ class ApiService {
const val CONTROL_TOPIC = "~control"
const val EVENT_MESSAGE = "message"
const val EVENT_KEEPALIVE = "keepalive"
const val EVENT_POLL_REQUEST = "poll_request"
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.log.Log
import io.heckel.ntfy.util.joinTagsMap
@ -65,9 +66,12 @@ class BroadcastService(private val ctx: Context) {
}
val delay = getStringExtra(intent,"delay") ?: ""
GlobalScope.launch(Dispatchers.IO) {
val repository = Repository.getInstance(ctx)
val user = repository.getUser(baseUrl) // May be null
api.publish(
baseUrl = baseUrl,
topic = topic,
user = user,
message = message,
title = title,
priority = priority,
@ -94,8 +98,10 @@ class BroadcastService(private val ctx: Context) {
companion object {
private const val TAG = "NtfyBroadcastService"
private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED"
private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE" // If changed, change in manifest too!
private const val DOES_NOT_EXIST = -2586000
// These constants cannot be changed without breaking the contract; also see manifest
private const val MESSAGE_RECEIVED_ACTION = "io.heckel.ntfy.MESSAGE_RECEIVED"
private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE"
}
}

View file

@ -269,6 +269,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
lifecycleScope.launch(Dispatchers.IO) {
try {
val user = repository.getUser(subscriptionBaseUrl) // May be null
val possibleTags = listOf(
"warning", "skull", "success", "triangular_flag_on_post", "de", "dog", "rotating_light", "cat", "bike", // Emojis
"backup", "rsync", "de-server1", "this-is-a-tag"
@ -277,7 +278,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
val tags = possibleTags.shuffled().take(Random.nextInt(0, 4))
val title = if (Random.nextBoolean()) getString(R.string.detail_test_title) else ""
val message = getString(R.string.detail_test_message, priority)
api.publish(subscriptionBaseUrl, subscriptionTopic, message, title, priority, tags, delay = "")
api.publish(subscriptionBaseUrl, subscriptionTopic, user, message, title, priority, tags, delay = "")
} catch (e: Exception) {
runOnUiThread {
Toast
@ -339,7 +340,8 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
lifecycleScope.launch(Dispatchers.IO) {
try {
val notifications = api.poll(subscriptionId, subscriptionBaseUrl, subscriptionTopic)
val user = repository.getUser(subscriptionBaseUrl) // May be null
val notifications = api.poll(subscriptionId, subscriptionBaseUrl, subscriptionTopic, user)
val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications)
val toastMessage = if (newNotifications.isEmpty()) {
getString(R.string.refresh_message_no_results)

View file

@ -193,10 +193,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
val work = PeriodicWorkRequestBuilder<PollWorker>(POLL_WORKER_INTERVAL_MINUTES, TimeUnit.MINUTES)
.setConstraints(constraints)
.addTag(PollWorker.TAG)
.addTag(PollWorker.WORK_NAME_PERIODIC)
.addTag(PollWorker.WORK_NAME_PERIODIC_ALL)
.build()
Log.d(TAG, "Poll worker: Scheduling period work every $POLL_WORKER_INTERVAL_MINUTES minutes")
workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC, workPolicy, work)
workManager!!.enqueueUniquePeriodicWork(PollWorker.WORK_NAME_PERIODIC_ALL, workPolicy, work)
}
private fun startPeriodicServiceRestartWorker() {
@ -375,7 +375,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
// Fetch cached messages
lifecycleScope.launch(Dispatchers.IO) {
try {
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
notifications.forEach { notification -> repository.addNotification(notification) }
} catch (e: Exception) {
Log.e(TAG, "Unable to fetch notifications: ${e.stackTrace}")
@ -418,7 +419,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
var newNotificationsCount = 0
repository.getSubscriptions().forEach { subscription ->
try {
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val user = repository.getUser(subscription.baseUrl) // May be null
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification ->
newNotificationsCount++

View file

@ -23,7 +23,6 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
}
override suspend fun doWork(): Result {
return withContext(Dispatchers.IO) {
Log.d(TAG, "Polling for new notifications")
val database = Database.getInstance(applicationContext)
@ -32,9 +31,25 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
val dispatcher = NotificationDispatcher(applicationContext, repository)
val api = ApiService()
repository.getSubscriptions().forEach{ subscription ->
val baseUrl = inputData.getString(INPUT_DATA_BASE_URL)
val topic = inputData.getString(INPUT_DATA_TOPIC)
val subscriptions = if (baseUrl != null && topic != null) {
val subscription = repository.getSubscription(baseUrl, topic) ?: return@withContext Result.success()
listOf(subscription)
} else {
repository.getSubscriptions()
}
subscriptions.forEach{ subscription ->
try {
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, since = subscription.lastActive)
val user = repository.getUser(subscription.baseUrl)
val notifications = api.poll(
subscriptionId = subscription.id,
baseUrl = subscription.baseUrl,
topic = subscription.topic,
user = user,
since = subscription.lastActive
)
val newNotifications = repository
.onlyNewNotifications(subscription.id, notifications)
.map { it.copy(notificationId = Random.nextInt()) }
@ -55,6 +70,9 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
companion object {
const val VERSION = BuildConfig.VERSION_CODE
const val TAG = "NtfyPollWorker"
const val WORK_NAME_PERIODIC = "NtfyPollWorkerPeriodic" // Do not change
const val WORK_NAME_PERIODIC_ALL = "NtfyPollWorkerPeriodic" // Do not change
const val WORK_NAME_ONCE_SINGE_PREFIX = "NtfyPollWorkerSingle" // e.g. NtfyPollWorkerSingle_https://ntfy.sh_mytopic
const val INPUT_DATA_BASE_URL = "baseUrl"
const val INPUT_DATA_TOPIC = "topic"
}
}

View file

@ -2,8 +2,6 @@
<!-- 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! -->
<string name="app_base_scheme">https</string> <!-- If changed, you must also change google-services.json! -->
<string name="app_base_host">ntfy.sh</string> <!-- If changed, you must also change google-services.json! -->
<!-- Notification channels -->
<string name="channel_notifications_min_name">Notifications (Min Priority)</string>

View file

@ -2,6 +2,7 @@ package io.heckel.ntfy.firebase
import android.content.Intent
import android.util.Base64
import androidx.work.*
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R
@ -14,6 +15,8 @@ import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.toPriority
import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
@ -38,8 +41,9 @@ class FirebaseService : FirebaseMessagingService() {
// Dispatch event
val data = remoteMessage.data
when (data["event"]) {
ApiService.EVENT_KEEPALIVE -> handleKeepalive(remoteMessage)
ApiService.EVENT_MESSAGE -> handleMessage(remoteMessage)
ApiService.EVENT_KEEPALIVE -> handleKeepalive(remoteMessage)
ApiService.EVENT_POLL_REQUEST -> handlePollRequest(remoteMessage)
else -> Log.d(TAG, "Discarding unexpected message (2): from=${remoteMessage.from}, data=${data}")
}
}
@ -54,6 +58,26 @@ class FirebaseService : FirebaseMessagingService() {
}
}
private fun handlePollRequest(remoteMessage: RemoteMessage) {
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!
val topic = remoteMessage.data["topic"] ?: return
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workName = "${PollWorker.WORK_NAME_ONCE_SINGE_PREFIX}_${baseUrl}_${topic}"
val workManager = WorkManager.getInstance(this)
val workRequest = OneTimeWorkRequest.Builder(PollWorker::class.java)
.setInputData(workDataOf(
PollWorker.INPUT_DATA_BASE_URL to baseUrl,
PollWorker.INPUT_DATA_TOPIC to topic
))
.setConstraints(constraints)
.build()
Log.d(TAG, "Poll request for ${topicShortUrl(baseUrl, topic)} received, scheduling unique poll worker with name $workName")
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
}
private fun handleMessage(remoteMessage: RemoteMessage) {
val data = remoteMessage.data
val id = data["id"]