This commit is contained in:
Philipp Heckel 2022-04-16 22:32:29 -04:00
parent 2f8be72c12
commit f62b7fa952
6 changed files with 190 additions and 52 deletions

View file

@ -123,7 +123,7 @@
<!-- Broadcast receiver for the "Download"/"Cancel" attachment action in the notification popup --> <!-- Broadcast receiver for the "Download"/"Cancel" attachment action in the notification popup -->
<receiver <receiver
android:name=".msg.NotificationService$DownloadBroadcastReceiver" android:name=".msg.NotificationService$UserActionBroadcastReceiver"
android:enabled="true" android:enabled="true"
android:exported="false"> android:exported="false">
</receiver> </receiver>

View file

@ -1,6 +1,5 @@
package io.heckel.ntfy.msg package io.heckel.ntfy.msg
import android.net.Uri
import android.os.Build import android.os.Build
import io.heckel.ntfy.BuildConfig import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Notification import io.heckel.ntfy.db.Notification
@ -9,7 +8,6 @@ import io.heckel.ntfy.util.*
import okhttp3.* import okhttp3.*
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException import java.io.IOException
import java.net.URL
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets.UTF_8 import java.nio.charset.StandardCharsets.UTF_8
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit

View file

@ -13,30 +13,27 @@ import io.heckel.ntfy.util.Log
* The indirection via WorkManager is required since this code may be executed * The indirection via WorkManager is required since this code may be executed
* in a doze state and Internet may not be available. It's also best practice apparently. * in a doze state and Internet may not be available. It's also best practice apparently.
*/ */
class DownloadManager { object DownloadManager {
companion object { private const val TAG = "NtfyDownloadManager"
private const val TAG = "NtfyDownloadManager" private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_"
private const val DOWNLOAD_WORK_NAME_PREFIX = "io.heckel.ntfy.DOWNLOAD_FILE_"
fun enqueue(context: Context, notificationId: String, userAction: Boolean) { fun enqueue(context: Context, notificationId: String, userAction: Boolean) {
val workManager = WorkManager.getInstance(context) val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + notificationId val workName = DOWNLOAD_WORK_NAME_PREFIX + notificationId
Log.d(TAG,"Enqueuing work to download attachment for notification $notificationId, work: $workName") Log.d(TAG,"Enqueuing work to download attachment for notification $notificationId, work: $workName")
val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java) val workRequest = OneTimeWorkRequest.Builder(DownloadWorker::class.java)
.setInputData(workDataOf( .setInputData(workDataOf(
DownloadWorker.INPUT_DATA_ID to notificationId, DownloadWorker.INPUT_DATA_ID to notificationId,
DownloadWorker.INPUT_DATA_USER_ACTION to userAction DownloadWorker.INPUT_DATA_USER_ACTION to userAction
)) ))
.build() .build()
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest) workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
} }
fun cancel(context: Context, id: String) {
val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + id
Log.d(TAG, "Cancelling download for notification $id, work: $workName")
workManager.cancelUniqueWork(workName)
}
fun cancel(context: Context, id: String) {
val workManager = WorkManager.getInstance(context)
val workName = DOWNLOAD_WORK_NAME_PREFIX + id
Log.d(TAG, "Cancelling download for notification $id, work: $workName")
workManager.cancelUniqueWork(workName)
} }
} }

View file

@ -154,9 +154,10 @@ class NotificationService(val context: Context) {
private fun maybeAddOpenAction(builder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddOpenAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri != null) { if (notification.attachment?.contentUri != null) {
val contentUri = Uri.parse(notification.attachment.contentUri) val contentUri = Uri.parse(notification.attachment.contentUri)
val intent = Intent(Intent.ACTION_VIEW, contentUri) val intent = Intent(Intent.ACTION_VIEW, contentUri).apply {
intent.setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P setDataAndType(contentUri, notification.attachment.type ?: "application/octet-stream") // Required for Android <= P
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_open), pendingIntent).build())
} }
@ -164,8 +165,9 @@ class NotificationService(val context: Context) {
private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddBrowseAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri != null) { if (notification.attachment?.contentUri != null) {
val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS) val intent = Intent(android.app.DownloadManager.ACTION_VIEW_DOWNLOADS).apply {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_browse), pendingIntent).build())
} }
@ -173,9 +175,10 @@ class NotificationService(val context: Context) {
private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddDownloadAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri == null && listOf(PROGRESS_NONE, PROGRESS_FAILED).contains(notification.attachment?.progress)) { if (notification.attachment?.contentUri == null && listOf(PROGRESS_NONE, PROGRESS_FAILED).contains(notification.attachment?.progress)) {
val intent = Intent(context, DownloadBroadcastReceiver::class.java) val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
intent.putExtra("action", DOWNLOAD_ACTION_START) putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_START)
intent.putExtra("id", notification.id) putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
}
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_download), pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_download), pendingIntent).build())
} }
@ -183,9 +186,10 @@ class NotificationService(val context: Context) {
private fun maybeAddCancelAction(builder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddCancelAction(builder: NotificationCompat.Builder, notification: Notification) {
if (notification.attachment?.contentUri == null && notification.attachment?.progress in 0..99) { if (notification.attachment?.contentUri == null && notification.attachment?.progress in 0..99) {
val intent = Intent(context, DownloadBroadcastReceiver::class.java) val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
intent.putExtra("action", DOWNLOAD_ACTION_CANCEL) putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_DOWNLOAD_CANCEL)
intent.putExtra("id", notification.id) putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
}
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_cancel), pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, context.getString(R.string.notification_popup_action_cancel), pendingIntent).build())
} }
@ -194,19 +198,19 @@ class NotificationService(val context: Context) {
private fun maybeAddCustomActions(builder: NotificationCompat.Builder, notification: Notification) { private fun maybeAddCustomActions(builder: NotificationCompat.Builder, notification: Notification) {
notification.actions?.forEach { action -> notification.actions?.forEach { action ->
when (action.action) { when (action.action) {
"view" -> maybeAddOpenUserAction(builder, notification, action) "view" -> maybeAddViewUserAction(builder, action)
"http-post" -> maybeAddHttpPostUserAction(builder, notification, action)
} }
} }
} }
private fun maybeAddOpenUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) { private fun maybeAddViewUserAction(builder: NotificationCompat.Builder, action: Action) {
Log.d(TAG, "Adding user action $action") Log.d(TAG, "Adding user action $action")
val url = action.url ?: return
try { try {
val uri = Uri.parse(url) val url = action.url ?: return
val intent = Intent(Intent.ACTION_VIEW, uri) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build()) builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build())
} catch (e: Exception) { } catch (e: Exception) {
@ -214,13 +218,40 @@ class NotificationService(val context: Context) {
} }
} }
class DownloadBroadcastReceiver : BroadcastReceiver() { private fun maybeAddHttpPostUserAction(builder: NotificationCompat.Builder, notification: Notification, action: Action) {
val intent = Intent(context, UserActionBroadcastReceiver::class.java).apply {
putExtra(BROADCAST_EXTRA_NOTIFICATION_ID, notification.id)
putExtra(BROADCAST_EXTRA_TYPE, BROADCAST_TYPE_HTTP)
putExtra(BROADCAST_EXTRA_ACTION, action.action)
putExtra(BROADCAST_EXTRA_URL, action.url)
}
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
builder.addAction(NotificationCompat.Action.Builder(0, action.label, pendingIntent).build())
}
class UserActionBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val id = intent.getStringExtra("id") ?: return Log.d(TAG, "Received $intent")
val action = intent.getStringExtra("action") ?: return val type = intent.getStringExtra(BROADCAST_EXTRA_TYPE) ?: return
when (action) { val notificationId = intent.getStringExtra(BROADCAST_EXTRA_NOTIFICATION_ID) ?: return
DOWNLOAD_ACTION_START -> DownloadManager.enqueue(context, id, userAction = true) when (type) {
DOWNLOAD_ACTION_CANCEL -> DownloadManager.cancel(context, id) BROADCAST_TYPE_DOWNLOAD_START, BROADCAST_TYPE_DOWNLOAD_CANCEL -> handleDownloadAction(context, type, notificationId)
BROADCAST_TYPE_HTTP -> handleCustomUserAction(context, intent, type, notificationId)
}
}
private fun handleDownloadAction(context: Context, type: String, notificationId: String) {
when (type) {
BROADCAST_TYPE_DOWNLOAD_START -> DownloadManager.enqueue(context, notificationId, userAction = true)
BROADCAST_TYPE_DOWNLOAD_CANCEL -> DownloadManager.cancel(context, notificationId)
}
}
private fun handleCustomUserAction(context: Context, intent: Intent, type: String, notificationId: String) {
val action = intent.getStringExtra(BROADCAST_EXTRA_ACTION) ?: return
val url = intent.getStringExtra(BROADCAST_EXTRA_URL) ?: return
when (type) {
BROADCAST_TYPE_HTTP -> UserActionManager.enqueue(context, notificationId, action, url)
} }
} }
} }
@ -287,8 +318,15 @@ class NotificationService(val context: Context) {
companion object { companion object {
private const val TAG = "NtfyNotifService" private const val TAG = "NtfyNotifService"
private const val DOWNLOAD_ACTION_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START"
private const val DOWNLOAD_ACTION_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL" private const val BROADCAST_EXTRA_TYPE = "type"
private const val BROADCAST_EXTRA_NOTIFICATION_ID = "notificationId"
private const val BROADCAST_EXTRA_ACTION = "action"
private const val BROADCAST_EXTRA_URL = "url"
private const val BROADCAST_TYPE_DOWNLOAD_START = "io.heckel.ntfy.DOWNLOAD_ACTION_START"
private const val BROADCAST_TYPE_DOWNLOAD_CANCEL = "io.heckel.ntfy.DOWNLOAD_ACTION_CANCEL"
private const val BROADCAST_TYPE_HTTP = "io.heckel.ntfy.USER_ACTION_HTTP"
private const val CHANNEL_ID_MIN = "ntfy-min" private const val CHANNEL_ID_MIN = "ntfy-min"
private const val CHANNEL_ID_LOW = "ntfy-low" private const val CHANNEL_ID_LOW = "ntfy-low"

View file

@ -0,0 +1,37 @@
package io.heckel.ntfy.msg
import android.content.Context
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.workDataOf
import io.heckel.ntfy.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/**
* Trigger user actions clicked from notification popups.
*
* The indirection via WorkManager is required since this code may be executed
* in a doze state and Internet may not be available. It's also best practice, apparently.
*/
object UserActionManager {
private const val TAG = "NtfyUserActionEx"
private const val WORK_NAME_PREFIX = "io.heckel.ntfy.USER_ACTION_"
fun enqueue(context: Context, notificationId: String, action: String, url: String) {
val workManager = WorkManager.getInstance(context)
val workName = WORK_NAME_PREFIX + notificationId + action + url
Log.d(TAG,"Enqueuing work to execute user action for notification $notificationId, work: $workName")
val workRequest = OneTimeWorkRequest.Builder(UserActionWorker::class.java)
.setInputData(workDataOf(
UserActionWorker.INPUT_DATA_ID to notificationId,
UserActionWorker.INPUT_DATA_ACTION to action,
UserActionWorker.INPUT_DATA_URL to url,
))
.build()
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.KEEP, workRequest)
}
}

View file

@ -0,0 +1,68 @@
package io.heckel.ntfy.msg
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.ensureSafeNewFile
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.io.File
import java.util.concurrent.TimeUnit
class UserActionWorker(private val context: Context, params: WorkerParameters) : Worker(context, params) {
private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
override fun doWork(): Result {
if (context.applicationContext !is Application) return Result.failure()
val notificationId = inputData.getString(INPUT_DATA_ID) ?: return Result.failure()
val action = inputData.getString(INPUT_DATA_ACTION) ?: return Result.failure()
val url = inputData.getString(INPUT_DATA_URL) ?: return Result.failure()
val app = context.applicationContext as Application
http(context, url)
return Result.success()
}
fun http(context: Context, url: String) { // FIXME Worker!
Log.d(TAG, "HTTP POST againt $url")
val request = Request.Builder()
.url(url)
.addHeader("User-Agent", ApiService.USER_AGENT)
.method("POST", "".toRequestBody())
.build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
return
}
throw Exception("Unexpected server response ${response.code}")
}
}
companion object {
const val INPUT_DATA_ID = "id"
const val INPUT_DATA_ACTION = "action"
const val INPUT_DATA_URL = "url"
private const val TAG = "NtfyUserActWrk"
}
}