Merge pull request #278 from cyb3rko/notification-channels

Separate Notification channels for each app
This commit is contained in:
Jannis Mattheis
2023-02-21 13:59:20 +01:00
committed by GitHub
11 changed files with 281 additions and 85 deletions

View File

@@ -1,56 +1,93 @@
package com.github.gotify
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.graphics.Color
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.preference.PreferenceManager
import com.github.gotify.client.model.Application
import com.github.gotify.log.Log
internal object NotificationSupport {
@RequiresApi(Build.VERSION_CODES.O)
fun createChannels(notificationManager: NotificationManager) {
try {
fun createForegroundChannel(context: Context, notificationManager: NotificationManager) {
// Low importance so that persistent notification can be sorted towards bottom of
// notification shade. Also prevents vibrations caused by persistent notification
val foreground = NotificationChannel(
Channel.FOREGROUND,
"Gotify foreground notification",
context.getString(R.string.notification_channel_title_foreground),
NotificationManager.IMPORTANCE_LOW
)
foreground.setShowBadge(false)
).apply {
setShowBadge(false)
}
notificationManager.createNotificationChannel(foreground)
}
@RequiresApi(Build.VERSION_CODES.O)
fun createChannels(
context: Context,
notificationManager: NotificationManager,
applications: List<Application>
) {
if (areAppChannelsRequested(context)) {
notificationManager.notificationChannels.forEach { channel ->
if (channel.id != Channel.FOREGROUND) {
notificationManager.deleteNotificationChannel(channel.id)
}
}
applications.forEach { app ->
createAppChannels(context, notificationManager, app.id.toString(), app.name)
}
} else {
notificationManager.notificationChannelGroups.forEach { group ->
notificationManager.deleteNotificationChannelGroup(group.id)
}
createGeneralChannels(context, notificationManager)
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createGeneralChannels(
context: Context,
notificationManager: NotificationManager
) {
try {
val messagesImportanceMin = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_MIN,
"Min priority messages (<1)",
context.getString(R.string.notification_channel_title_min),
NotificationManager.IMPORTANCE_MIN
)
val messagesImportanceLow = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_LOW,
"Low priority messages (1-3)",
context.getString(R.string.notification_channel_title_low),
NotificationManager.IMPORTANCE_LOW
)
val messagesImportanceDefault = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_DEFAULT,
"Normal priority messages (4-7)",
context.getString(R.string.notification_channel_title_normal),
NotificationManager.IMPORTANCE_DEFAULT
)
messagesImportanceDefault.enableLights(true)
messagesImportanceDefault.lightColor = Color.CYAN
messagesImportanceDefault.enableVibration(true)
).apply {
enableLights(true)
lightColor = Color.CYAN
enableVibration(true)
}
val messagesImportanceHigh = NotificationChannel(
Channel.MESSAGES_IMPORTANCE_HIGH,
"High priority messages (>7)",
context.getString(R.string.notification_channel_title_high),
NotificationManager.IMPORTANCE_HIGH
)
messagesImportanceHigh.enableLights(true)
messagesImportanceHigh.lightColor = Color.CYAN
messagesImportanceHigh.enableVibration(true)
).apply {
enableLights(true)
lightColor = Color.CYAN
enableVibration(true)
}
notificationManager.createNotificationChannel(foreground)
notificationManager.createNotificationChannel(messagesImportanceMin)
notificationManager.createNotificationChannel(messagesImportanceLow)
notificationManager.createNotificationChannel(messagesImportanceDefault)
@@ -60,6 +97,88 @@ internal object NotificationSupport {
}
}
@RequiresApi(api = Build.VERSION_CODES.O)
fun createChannelIfNonexistent(
context: Context,
groupId: String,
channelId: String
) {
if (!doesNotificationChannelExist(context, channelId)) {
val notificationManager = (context as Service)
.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
createAppChannels(context, notificationManager, groupId, groupId)
}
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createAppChannels(
context: Context,
notificationManager: NotificationManager,
groupId: String,
groupName: String
) {
try {
notificationManager.createNotificationChannelGroup(
NotificationChannelGroup(
groupId,
groupName
)
)
val messagesImportanceMin = NotificationChannel(
getChannelID(Channel.MESSAGES_IMPORTANCE_MIN, groupId),
context.getString(R.string.notification_channel_title_min),
NotificationManager.IMPORTANCE_MIN
).apply {
group = groupId
}
val messagesImportanceLow = NotificationChannel(
getChannelID(Channel.MESSAGES_IMPORTANCE_LOW, groupId),
context.getString(R.string.notification_channel_title_low),
NotificationManager.IMPORTANCE_LOW
).apply {
group = groupId
}
val messagesImportanceDefault = NotificationChannel(
getChannelID(Channel.MESSAGES_IMPORTANCE_DEFAULT, groupId),
context.getString(R.string.notification_channel_title_normal),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
enableLights(true)
lightColor = Color.CYAN
enableVibration(true)
group = groupId
}
val messagesImportanceHigh = NotificationChannel(
getChannelID(Channel.MESSAGES_IMPORTANCE_HIGH, groupId),
context.getString(R.string.notification_channel_title_high),
NotificationManager.IMPORTANCE_HIGH
).apply {
enableLights(true)
lightColor = Color.CYAN
enableVibration(true)
group = groupId
}
notificationManager.createNotificationChannel(messagesImportanceMin)
notificationManager.createNotificationChannel(messagesImportanceLow)
notificationManager.createNotificationChannel(messagesImportanceDefault)
notificationManager.createNotificationChannel(messagesImportanceHigh)
} catch (e: Exception) {
Log.e("Could not create channel", e)
}
}
@RequiresApi(api = Build.VERSION_CODES.O)
private fun doesNotificationChannelExist(context: Context, channelId: String): Boolean {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = manager.getNotificationChannel(channelId)
return channel != null
}
/**
* Map {@link com.github.gotify.client.model.Message#getPriority() Gotify message priorities to
* Android channels.
@@ -87,6 +206,21 @@ internal object NotificationSupport {
}
}
private fun getChannelID(importance: String, groupId: String): String {
return "$groupId::$importance"
}
fun getChannelID(priority: Long, groupId: String): String {
return getChannelID(convertPriorityToChannel(priority), groupId)
}
fun areAppChannelsRequested(context: Context): Boolean {
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
context.getString(R.string.setting_key_notification_channels),
context.resources.getBoolean(R.bool.notification_channels)
)
}
object Group {
const val MESSAGES = "GOTIFY_GROUP_MESSAGES"
}

View File

@@ -2,6 +2,7 @@ package com.github.gotify.init
import android.Manifest
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
@@ -41,8 +42,9 @@ internal class InitializationActivity : AppCompatActivity() {
ThemeHelper.setTheme(this, theme)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationSupport.createChannels(
this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
NotificationSupport.createForegroundChannel(
this,
(this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
)
}
UncaughtExceptionHandler.registerCurrentThread()

View File

@@ -9,7 +9,7 @@ internal object MessageImageCombiner {
return messages.map { MessageWithImage(message = it, image = appIdToImage[it.appid]) }
}
fun appIdToImage(applications: List<Application>): Map<Long, String> {
private fun appIdToImage(applications: List<Application>): Map<Long, String> {
val map = mutableMapOf<Long, String>()
applications.forEach {
map[it.id] = it.image

View File

@@ -6,19 +6,15 @@ import android.graphics.BitmapFactory
import com.github.gotify.R
import com.github.gotify.Settings
import com.github.gotify.Utils
import com.github.gotify.api.Callback
import com.github.gotify.api.CertUtils
import com.github.gotify.api.ClientFactory
import com.github.gotify.client.api.ApplicationApi
import com.github.gotify.client.model.Application
import com.github.gotify.log.Log
import com.github.gotify.messages.provider.MessageImageCombiner
import com.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
internal class PicassoHandler(private val context: Context, private val settings: Settings) {
companion object {
@@ -32,7 +28,6 @@ internal class PicassoHandler(private val context: Context, private val settings
)
private val picasso = makePicasso()
private val appIdToAppImage = ConcurrentHashMap<Long, String>()
private fun makePicasso(): Picasso {
val builder = OkHttpClient.Builder()
@@ -48,13 +43,13 @@ internal class PicassoHandler(private val context: Context, private val settings
@Throws(IOException::class)
fun getImageFromUrl(url: String?): Bitmap = picasso.load(url).get()
fun getIcon(appId: Long): Bitmap {
if (appId == -1L) {
fun getIcon(app: Application?): Bitmap {
if (app == null) {
return BitmapFactory.decodeResource(context.resources, R.drawable.gotify)
}
try {
return getImageFromUrl(
Utils.resolveAbsoluteUrl("${settings.url}/", appIdToAppImage[appId])
Utils.resolveAbsoluteUrl("${settings.url}/", app.image)
)
} catch (e: IOException) {
Log.e("Could not load image for notification", e)
@@ -62,21 +57,6 @@ internal class PicassoHandler(private val context: Context, private val settings
return BitmapFactory.decodeResource(context.resources, R.drawable.gotify)
}
fun updateAppIds() {
ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token)
.createService(ApplicationApi::class.java)
.apps
.enqueue(
Callback.call(
onSuccess = Callback.SuccessBody { apps ->
appIdToAppImage.clear()
appIdToAppImage.putAll(MessageImageCombiner.appIdToImage(apps))
},
onError = { appIdToAppImage.clear() }
)
)
}
fun get() = picasso
@Throws(IOException::class)

View File

@@ -6,7 +6,6 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.Uri
@@ -23,7 +22,9 @@ import com.github.gotify.Settings
import com.github.gotify.Utils
import com.github.gotify.api.Callback
import com.github.gotify.api.ClientFactory
import com.github.gotify.client.api.ApplicationApi
import com.github.gotify.client.api.MessageApi
import com.github.gotify.client.model.Application
import com.github.gotify.client.model.Message
import com.github.gotify.log.Log
import com.github.gotify.log.UncaughtExceptionHandler
@@ -31,6 +32,7 @@ import com.github.gotify.messages.Extras
import com.github.gotify.messages.MessagesActivity
import com.github.gotify.picasso.PicassoHandler
import io.noties.markwon.Markwon
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
internal class WebSocketService : Service() {
@@ -41,6 +43,7 @@ internal class WebSocketService : Service() {
private lateinit var settings: Settings
private var connection: WebSocketConnection? = null
private val appIdToApp = ConcurrentHashMap<Long, Application>()
private val lastReceivedMessage = AtomicLong(NOT_LOADED)
private lateinit var missingMessageUtil: MissedMessageUtil
@@ -108,10 +111,29 @@ internal class WebSocketService : Service() {
.onReconnected { notifyMissedNotifications() }
.start()
val intentFilter = IntentFilter()
intentFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION)
fetchApps()
}
picassoHandler.updateAppIds()
private fun fetchApps() {
ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token)
.createService(ApplicationApi::class.java)
.apps
.enqueue(
Callback.call(
onSuccess = Callback.SuccessBody { apps ->
appIdToApp.clear()
appIdToApp.putAll(apps.associateBy { it.id })
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationSupport.createChannels(
this,
(this.getSystemService(NOTIFICATION_SERVICE) as NotificationManager),
apps
)
}
},
onError = { appIdToApp.clear() }
)
)
}
private fun onClose() {
@@ -312,20 +334,31 @@ internal class WebSocketService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val b = NotificationCompat.Builder(
val channelId: String
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
NotificationSupport.areAppChannelsRequested(this)
) {
channelId = NotificationSupport.getChannelID(priority, appId.toString())
NotificationSupport.createChannelIfNonexistent(
this,
NotificationSupport.convertPriorityToChannel(priority)
appId.toString(),
channelId
)
} else {
channelId = NotificationSupport.convertPriorityToChannel(priority)
}
val b = NotificationCompat.Builder(this, channelId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
showNotificationGroup(priority)
showNotificationGroup(channelId)
}
b.setAutoCancel(true)
.setDefaults(Notification.DEFAULT_ALL)
.setWhen(System.currentTimeMillis())
.setSmallIcon(R.drawable.ic_gotify)
.setLargeIcon(picassoHandler.getIcon(appId))
.setLargeIcon(picassoHandler.getIcon(appIdToApp[appId]))
.setTicker("${getString(R.string.app_name)} - $title")
.setGroup(NotificationSupport.Group.MESSAGES)
.setContentTitle(title)
@@ -365,7 +398,7 @@ internal class WebSocketService : Service() {
}
@RequiresApi(Build.VERSION_CODES.N)
fun showNotificationGroup(priority: Long) {
fun showNotificationGroup(channelId: String) {
val intent = Intent(this, MessagesActivity::class.java)
val contentIntent = PendingIntent.getActivity(
this,
@@ -376,7 +409,7 @@ internal class WebSocketService : Service() {
val builder = NotificationCompat.Builder(
this,
NotificationSupport.convertPriorityToChannel(priority)
channelId
)
builder.setAutoCancel(true)

View File

@@ -5,6 +5,7 @@ import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.view.View
@@ -14,6 +15,7 @@ import androidx.preference.ListPreferenceDialogFragmentCompat
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import androidx.preference.SwitchPreferenceCompat
import com.github.gotify.R
import com.github.gotify.databinding.SettingsActivityBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -48,38 +50,43 @@ internal class SettingsActivity : AppCompatActivity(), OnSharedPreferenceChangeL
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
if (getString(R.string.setting_key_theme) == key) {
when (key) {
getString(R.string.setting_key_theme) -> {
ThemeHelper.setTheme(
this,
sharedPreferences.getString(key, getString(R.string.theme_default))!!
)
}
}
}
class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
findPreference<SwitchPreferenceCompat>(
getString(R.string.setting_key_notification_channels)
)?.isEnabled = true
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val messageLayout: ListPreference? =
findPreference(getString(R.string.setting_key_message_layout))
val notificationChannels: SwitchPreferenceCompat? =
findPreference(getString(R.string.setting_key_notification_channels))
messageLayout?.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ ->
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.setting_message_layout_dialog_title)
.setMessage(R.string.setting_message_layout_dialog_message)
.setPositiveButton(
getString(R.string.setting_message_layout_dialog_button1)
) { _, _ ->
restartApp()
showRestartDialog()
true
}
.setNegativeButton(
getString(R.string.setting_message_layout_dialog_button2),
null
)
.show()
notificationChannels?.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ ->
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return@OnPreferenceChangeListener false
}
showRestartDialog()
true
}
}
@@ -102,6 +109,17 @@ internal class SettingsActivity : AppCompatActivity(), OnSharedPreferenceChangeL
)
}
private fun showRestartDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.setting_restart_dialog_title)
.setMessage(R.string.setting_restart_dialog_message)
.setPositiveButton(getString(R.string.setting_restart_dialog_button1)) { _, _ ->
restartApp()
}
.setNegativeButton(getString(R.string.setting_restart_dialog_button2), null)
.show()
}
private fun restartApp() {
val packageManager = requireContext().packageManager
val packageName = requireContext().packageName

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.materialswitch.MaterialSwitch
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/switchWidget"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

View File

@@ -34,4 +34,5 @@
</string-array>
<string name="time_format_value_absolute">time_format_absolute</string>
<string name="time_format_value_relative">time_format_relative</string>
<bool name="notification_channels">false</bool>
</resources>

View File

@@ -74,12 +74,15 @@
<string name="setting_key_theme">theme</string>
<string name="setting_message_layout">Message layout</string>
<string name="setting_key_message_layout">message_layout</string>
<string name="setting_message_layout_dialog_title">Restart App?</string>
<string name="setting_message_layout_dialog_message">The change will be effective on next app start.\n\nDo you want to restart now?</string>
<string name="setting_message_layout_dialog_button1">Restart</string>
<string name="setting_message_layout_dialog_button2">Later</string>
<string name="setting_restart_dialog_title">Restart App?</string>
<string name="setting_restart_dialog_message">The change will be effective on next app start.\n\nDo you want to restart now?</string>
<string name="setting_restart_dialog_button1">Restart</string>
<string name="setting_restart_dialog_button2">Later</string>
<string name="setting_time_format">Time format</string>
<string name="setting_key_time_format">time_format</string>
<string name="setting_notifications">Notifications</string>
<string name="setting_notification_channels">Separate app notification channels</string>
<string name="setting_key_notification_channels">notification_channels</string>
<string name="push_message">Push message</string>
<string name="appListDescription">App:</string>
<string name="priorityDescription">Priority:</string>
@@ -97,4 +100,10 @@
<item quantity="one">in %d minute</item>
<item quantity="other">in %d minutes</item>
</plurals>
<string name="notification_channel_title_foreground">Gotify foreground notification</string>
<string name="notification_channel_title_min">Min priority messages (&lt;1)</string>
<string name="notification_channel_title_low">Low priority messages (13)</string>
<string name="notification_channel_title_normal">Normal priority messages (47)</string>
<string name="notification_channel_title_high">High priority messages (>7)</string>
</resources>

View File

@@ -1,4 +1,4 @@
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.Material3.DayNight">
@@ -25,4 +25,8 @@
<style name="AppTheme.PopupOverlay" parent="AppTheme" />
<style name="Preference.SwitchPreferenceCompat" parent="@style/Preference.SwitchPreferenceCompat.Material" tools:ignore="ResourceCycle">
<item name="widgetLayout">@layout/preference_switch</item>
</style>
</resources>

View File

@@ -24,4 +24,13 @@
android:title="@string/setting_time_format" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/setting_notifications" >
<SwitchPreferenceCompat
android:enabled="false"
android:defaultValue="@bool/notification_channels"
android:key="@string/setting_key_notification_channels"
android:title="@string/setting_notification_channels"
app:singleLineTitle="false" />
</PreferenceCategory>
</PreferenceScreen>