diff --git a/app/src/main/kotlin/com/github/gotify/NotificationSupport.kt b/app/src/main/kotlin/com/github/gotify/NotificationSupport.kt index 46a5287..dd27c22 100644 --- a/app/src/main/kotlin/com/github/gotify/NotificationSupport.kt +++ b/app/src/main/kotlin/com/github/gotify/NotificationSupport.kt @@ -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 { - // 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", - NotificationManager.IMPORTANCE_LOW - ) - foreground.setShowBadge(false) + 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, + context.getString(R.string.notification_channel_title_foreground), + NotificationManager.IMPORTANCE_LOW + ).apply { + setShowBadge(false) + } + notificationManager.createNotificationChannel(foreground) + } + @RequiresApi(Build.VERSION_CODES.O) + fun createChannels( + context: Context, + notificationManager: NotificationManager, + applications: List + ) { + 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" } diff --git a/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt b/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt index 49e6d1f..c9d68be 100644 --- a/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt +++ b/app/src/main/kotlin/com/github/gotify/init/InitializationActivity.kt @@ -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() diff --git a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt index b5d2750..ae2b8bf 100644 --- a/app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt +++ b/app/src/main/kotlin/com/github/gotify/messages/provider/MessageImageCombiner.kt @@ -9,7 +9,7 @@ internal object MessageImageCombiner { return messages.map { MessageWithImage(message = it, image = appIdToImage[it.appid]) } } - fun appIdToImage(applications: List): Map { + private fun appIdToImage(applications: List): Map { val map = mutableMapOf() applications.forEach { map[it.id] = it.image diff --git a/app/src/main/kotlin/com/github/gotify/picasso/PicassoHandler.kt b/app/src/main/kotlin/com/github/gotify/picasso/PicassoHandler.kt index 2ece693..bddc3a4 100644 --- a/app/src/main/kotlin/com/github/gotify/picasso/PicassoHandler.kt +++ b/app/src/main/kotlin/com/github/gotify/picasso/PicassoHandler.kt @@ -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() 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) diff --git a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt index a0c20c9..eb331b4 100644 --- a/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt +++ b/app/src/main/kotlin/com/github/gotify/service/WebSocketService.kt @@ -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() 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( - this, - NotificationSupport.convertPriorityToChannel(priority) - ) + val channelId: String + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + NotificationSupport.areAppChannelsRequested(this) + ) { + channelId = NotificationSupport.getChannelID(priority, appId.toString()) + NotificationSupport.createChannelIfNonexistent( + this, + 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) diff --git a/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt b/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt index bb4a825..eebe0d9 100644 --- a/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/com/github/gotify/settings/SettingsActivity.kt @@ -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) { - ThemeHelper.setTheme( - this, - sharedPreferences.getString(key, getString(R.string.theme_default))!! - ) + 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( + 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() - } - .setNegativeButton( - getString(R.string.setting_message_layout_dialog_button2), - null - ) - .show() + showRestartDialog() + true + } + 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 diff --git a/app/src/main/res/layout/preference_switch.xml b/app/src/main/res/layout/preference_switch.xml new file mode 100644 index 0000000..a9b967f --- /dev/null +++ b/app/src/main/res/layout/preference_switch.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index a4ef6f8..92ca38b 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -34,4 +34,5 @@ time_format_absolute time_format_relative + false diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7310c44..d539e49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,12 +74,15 @@ theme Message layout message_layout - Restart App? - The change will be effective on next app start.\n\nDo you want to restart now? - Restart - Later + Restart App? + The change will be effective on next app start.\n\nDo you want to restart now? + Restart + Later Time format time_format + Notifications + Separate app notification channels + notification_channels Push message App: Priority: @@ -97,4 +100,10 @@ in %d minute in %d minutes + + Gotify foreground notification + Min priority messages (<1) + Low priority messages (1–3) + Normal priority messages (4–7) + High priority messages (>7) diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index ac2ad10..b034e1e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,4 +1,4 @@ - + + diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml index 0971135..add33e0 100644 --- a/app/src/main/res/xml/root_preferences.xml +++ b/app/src/main/res/xml/root_preferences.xml @@ -24,4 +24,13 @@ android:title="@string/setting_time_format" /> + + + +