WIP: Icon

This commit is contained in:
Philipp Heckel 2022-05-06 21:03:15 -04:00
parent 3d5b723072
commit 2909d877f7
13 changed files with 148 additions and 44 deletions

View file

@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "9a26b356f0d51f2c63fd3e4570b7e645",
"identityHash": "31f8e6a2032d1d404fad4307abf23e1b",
"entities": [
{
"tableName": "Subscription",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@ -50,6 +50,12 @@
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "upAppId",
"columnName": "upAppId",
@ -308,7 +314,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a26b356f0d51f2c63fd3e4570b7e645')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '31f8e6a2032d1d404fad4307abf23e1b')"
]
}
}

View file

@ -97,6 +97,7 @@ class Backuper(val context: Context) {
mutedUntil = s.mutedUntil,
minPriority = s.minPriority ?: Repository.MIN_PRIORITY_USE_GLOBAL,
autoDelete = s.autoDelete ?: Repository.AUTO_DELETE_USE_GLOBAL,
icon = s.icon,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken
))
@ -219,6 +220,7 @@ class Backuper(val context: Context) {
mutedUntil = s.mutedUntil,
minPriority = s.minPriority,
autoDelete = s.autoDelete,
icon = s.icon,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken
)
@ -324,6 +326,7 @@ data class Subscription(
val mutedUntil: Long,
val minPriority: Int?,
val autoDelete: Long?,
val icon: String?,
val upAppId: String?,
val upConnectorToken: String?
)

View file

@ -18,6 +18,7 @@ data class Subscription(
@ColumnInfo(name = "mutedUntil") val mutedUntil: Long,
@ColumnInfo(name = "minPriority") val minPriority: Int,
@ColumnInfo(name = "autoDelete") val autoDelete: Long, // Seconds
@ColumnInfo(name = "icon") val icon: String?, // content://-URI (or later other identifier)
@ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name
@ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token
@Ignore val totalCount: Int = 0, // Total notifications
@ -25,8 +26,8 @@ data class Subscription(
@Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
) {
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, minPriority: Int, autoDelete: Long, upAppId: String, upConnectorToken: String) :
this(id, baseUrl, topic, instant, mutedUntil, minPriority, autoDelete, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, minPriority: Int, autoDelete: Long, icon: String, upAppId: String, upConnectorToken: String) :
this(id, baseUrl, topic, instant, mutedUntil, minPriority, autoDelete, icon, upAppId, upConnectorToken, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
}
enum class ConnectionState {
@ -41,6 +42,7 @@ data class SubscriptionWithMetadata(
val mutedUntil: Long,
val autoDelete: Long,
val minPriority: Int,
val icon: String?,
val upAppId: String?,
val upConnectorToken: String?,
val totalCount: Int,
@ -254,6 +256,7 @@ abstract class Database : RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE Subscription ADD COLUMN minPriority INT NOT NULL DEFAULT (0)") // = Repository.MIN_PRIORITY_USE_GLOBAL
db.execSQL("ALTER TABLE Subscription ADD COLUMN autoDelete INT NOT NULL DEFAULT (-1)") // = Repository.AUTO_DELETE_USE_GLOBAL
db.execSQL("ALTER TABLE Subscription ADD COLUMN icon TEXT")
}
}
}
@ -263,7 +266,7 @@ abstract class Database : RoomDatabase() {
interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.upAppId, s.upConnectorToken,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.icon, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -276,7 +279,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.upAppId, s.upConnectorToken,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.icon, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -289,7 +292,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.upAppId, s.upConnectorToken,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.icon, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -302,7 +305,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.upAppId, s.upConnectorToken,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.icon, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive
@ -315,7 +318,7 @@ interface SubscriptionDao {
@Query("""
SELECT
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.upAppId, s.upConnectorToken,
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.icon, s.upAppId, s.upConnectorToken,
COUNT(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive

View file

@ -379,6 +379,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
mutedUntil = s.mutedUntil,
minPriority = s.minPriority,
autoDelete = s.autoDelete,
icon = s.icon,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount,
@ -401,6 +402,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
mutedUntil = s.mutedUntil,
minPriority = s.minPriority,
autoDelete = s.autoDelete,
icon = s.icon,
upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken,
totalCount = s.totalCount,

View file

@ -72,6 +72,7 @@ class NotificationService(val context: Context) {
.setAutoCancel(true) // Cancel when notification is clicked
setStyleAndText(builder, notification) // Preview picture or big text style
setClickAction(builder, subscription, notification)
maybeSetIcon(builder, subscription)
maybeSetSound(builder, update)
maybeSetProgress(builder, notification)
maybeAddOpenAction(builder, notification)
@ -84,6 +85,18 @@ class NotificationService(val context: Context) {
notificationManager.notify(notification.notificationId, builder.build())
}
private fun maybeSetIcon(builder: NotificationCompat.Builder, subscription: Subscription) {
val icon = subscription.icon ?: return
try {
val resolver = context.applicationContext.contentResolver
val bitmapStream = resolver.openInputStream(Uri.parse(icon))
val bitmap = BitmapFactory.decodeStream(bitmapStream)
builder.setLargeIcon(bitmap)
} catch (e: Exception) {
Log.w(TAG, "Cannot load subscription icon", e)
}
}
private fun maybeSetSound(builder: NotificationCompat.Builder, update: Boolean) {
if (!update) {
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)

View file

@ -112,6 +112,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
mutedUntil = 0,
minPriority = Repository.MIN_PRIORITY_USE_GLOBAL,
autoDelete = Repository.AUTO_DELETE_USE_GLOBAL,
icon = null,
upAppId = null,
upConnectorToken = null,
totalCount = 0,

View file

@ -1,16 +1,25 @@
package io.heckel.ntfy.ui
import android.net.Uri
import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import androidx.preference.*
import io.heckel.ntfy.BuildConfig
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.msg.DownloadWorker
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.*
import kotlinx.coroutines.*
import okio.source
import java.io.File
import java.io.IOException
import java.util.*
/**
@ -61,6 +70,7 @@ class DetailSettingsActivity : AppCompatActivity() {
private lateinit var repository: Repository
private lateinit var serviceManager: SubscriberServiceManager
private lateinit var subscription: Subscription
private lateinit var pickIconLauncher: ActivityResultLauncher<String>
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.detail_preferences, rootKey)
@ -69,6 +79,9 @@ class DetailSettingsActivity : AppCompatActivity() {
repository = Repository.getInstance(requireActivity())
serviceManager = SubscriberServiceManager(requireActivity())
// Create result launcher for custom icon (must be created in onCreatePreferences() directly)
pickIconLauncher = createCustomIconPickLauncher()
// Load subscription and users
val subscriptionId = arguments?.getLong(DetailActivity.EXTRA_SUBSCRIPTION_ID) ?: return
runBlocking {
@ -82,13 +95,20 @@ class DetailSettingsActivity : AppCompatActivity() {
}
private fun loadView() {
// Instant delivery
loadInstantPref()
loadMutedUntilPref()
loadMinPriorityPref()
loadAutoDeletePref()
loadCustomIconsPref()
}
private fun loadInstantPref() {
val appBaseUrl = getString(R.string.app_base_url)
val instantEnabledPrefId = context?.getString(R.string.detail_settings_notifications_instant_key) ?: return
val instantEnabled: SwitchPreference? = findPreference(instantEnabledPrefId)
instantEnabled?.isVisible = BuildConfig.FIREBASE_AVAILABLE && subscription.baseUrl == appBaseUrl
instantEnabled?.isChecked = subscription.instant
instantEnabled?.preferenceDataStore = object : PreferenceDataStore() {
val prefId = context?.getString(R.string.detail_settings_notifications_instant_key) ?: return
val pref: SwitchPreference? = findPreference(prefId)
pref?.isVisible = BuildConfig.FIREBASE_AVAILABLE && subscription.baseUrl == appBaseUrl
pref?.isChecked = subscription.instant
pref?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
save(subscription.copy(instant = value), refresh = true)
}
@ -96,20 +116,21 @@ class DetailSettingsActivity : AppCompatActivity() {
return subscription.instant
}
}
instantEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
pref?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { preference ->
if (preference.isChecked) {
getString(R.string.detail_settings_notifications_instant_summary_on)
} else {
getString(R.string.detail_settings_notifications_instant_summary_off)
}
}
}
// Notifications muted until
val mutedUntilPrefId = context?.getString(R.string.detail_settings_notifications_muted_until_key) ?: return
val mutedUntil: ListPreference? = findPreference(mutedUntilPrefId)
mutedUntil?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
mutedUntil?.value = subscription.mutedUntil.toString()
mutedUntil?.preferenceDataStore = object : PreferenceDataStore() {
private fun loadMutedUntilPref() {
val prefId = context?.getString(R.string.detail_settings_notifications_muted_until_key) ?: return
val pref: ListPreference? = findPreference(prefId)
pref?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
pref?.value = subscription.mutedUntil.toString()
pref?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val mutedUntilValue = value?.toLongOrNull() ?:return
when (mutedUntilValue) {
@ -134,7 +155,7 @@ class DetailSettingsActivity : AppCompatActivity() {
return subscription.mutedUntil.toString()
}
}
mutedUntil?.summaryProvider = Preference.SummaryProvider<ListPreference> { _ ->
pref?.summaryProvider = Preference.SummaryProvider<ListPreference> { _ ->
val mutedUntilValue = subscription.mutedUntil
when (mutedUntilValue) {
Repository.MUTED_UNTIL_SHOW_ALL -> getString(R.string.settings_notifications_muted_until_show_all)
@ -145,13 +166,14 @@ class DetailSettingsActivity : AppCompatActivity() {
}
}
}
}
// Minimum priority
val minPriorityPrefId = context?.getString(R.string.detail_settings_notifications_min_priority_key) ?: return
val minPriority: ListPreference? = findPreference(minPriorityPrefId)
minPriority?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
minPriority?.value = subscription.minPriority.toString()
minPriority?.preferenceDataStore = object : PreferenceDataStore() {
private fun loadMinPriorityPref() {
val prefId = context?.getString(R.string.detail_settings_notifications_min_priority_key) ?: return
val pref: ListPreference? = findPreference(prefId)
pref?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
pref?.value = subscription.minPriority.toString()
pref?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val minPriorityValue = value?.toIntOrNull() ?:return
save(subscription.copy(minPriority = minPriorityValue))
@ -160,8 +182,8 @@ class DetailSettingsActivity : AppCompatActivity() {
return subscription.minPriority.toString()
}
}
minPriority?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
var value = pref.value.toIntOrNull() ?: Repository.MIN_PRIORITY_USE_GLOBAL
pref?.summaryProvider = Preference.SummaryProvider<ListPreference> { preference ->
var value = preference.value.toIntOrNull() ?: Repository.MIN_PRIORITY_USE_GLOBAL
val global = value == Repository.MIN_PRIORITY_USE_GLOBAL
if (value == Repository.MIN_PRIORITY_USE_GLOBAL) {
value = repository.getMinPriority()
@ -176,13 +198,14 @@ class DetailSettingsActivity : AppCompatActivity() {
}
maybeAppendGlobal(summary, global)
}
}
// Auto delete
val autoDeletePrefId = context?.getString(R.string.detail_settings_notifications_auto_delete_key) ?: return
val autoDelete: ListPreference? = findPreference(autoDeletePrefId)
autoDelete?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
autoDelete?.value = subscription.autoDelete.toString()
autoDelete?.preferenceDataStore = object : PreferenceDataStore() {
private fun loadAutoDeletePref() {
val prefId = context?.getString(R.string.detail_settings_notifications_auto_delete_key) ?: return
val pref: ListPreference? = findPreference(prefId)
pref?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
pref?.value = subscription.autoDelete.toString()
pref?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val seconds = value?.toLongOrNull() ?:return
save(subscription.copy(autoDelete = seconds))
@ -191,8 +214,8 @@ class DetailSettingsActivity : AppCompatActivity() {
return subscription.autoDelete.toString()
}
}
autoDelete?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
var seconds = pref.value.toLongOrNull() ?: Repository.AUTO_DELETE_USE_GLOBAL
pref?.summaryProvider = Preference.SummaryProvider<ListPreference> { preference ->
var seconds = preference.value.toLongOrNull() ?: Repository.AUTO_DELETE_USE_GLOBAL
val global = seconds == Repository.AUTO_DELETE_USE_GLOBAL
if (seconds == Repository.AUTO_DELETE_USE_GLOBAL) {
seconds = repository.getAutoDeleteSeconds()
@ -210,6 +233,50 @@ class DetailSettingsActivity : AppCompatActivity() {
}
}
private fun loadCustomIconsPref() {
val prefId = context?.getString(R.string.detail_settings_general_icon_key) ?: return
val pref: Preference? = findPreference(prefId)
pref?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
pref?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
pref?.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ ->
pickIconLauncher.launch("image/*")
false
}
}
private fun createCustomIconPickLauncher(): ActivityResultLauncher<String> {
return registerForActivityResult(ActivityResultContracts.GetContent()) { inputUri ->
if (inputUri == null) {
return@registerForActivityResult
}
lifecycleScope.launch(Dispatchers.IO) {
try {
val resolver = requireContext().applicationContext.contentResolver
val inputStream = resolver.openInputStream(inputUri) ?: throw IOException("Couldn't open content URI for reading")
val outputUri = createUri()
val outputStream = resolver.openOutputStream(outputUri) ?: throw IOException("Couldn't open content URI for writing")
inputStream.copyTo(outputStream)
save(subscription.copy(icon = outputUri.toString()))
} catch (e: Exception) {
Log.w(TAG, "Saving icon failed", e)
requireActivity().runOnUiThread {
// FIXME
}
}
}
}
}
private fun createUri(): Uri {
val dir = File(requireContext().cacheDir, SUBSCRIPTION_ICONS)
if (!dir.exists() && !dir.mkdirs()) {
throw Exception("Cannot create cache directory for attachments: $dir")
}
val file = File(dir, subscription.id.toString())
return FileProvider.getUriForFile(requireContext(), DownloadWorker.FILE_PROVIDER_AUTHORITY, file)
}
private fun save(newSubscription: Subscription, refresh: Boolean = false) {
subscription = newSubscription
lifecycleScope.launch(Dispatchers.IO) {
@ -231,5 +298,6 @@ class DetailSettingsActivity : AppCompatActivity() {
companion object {
private const val TAG = "NtfyDetailSettingsActiv"
private const val SUBSCRIPTION_ICONS = "subscriptionIcons"
}
}

View file

@ -427,6 +427,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
mutedUntil = 0,
minPriority = Repository.MIN_PRIORITY_USE_GLOBAL,
autoDelete = Repository.AUTO_DELETE_USE_GLOBAL,
icon = null,
upAppId = null,
upConnectorToken = null,
totalCount = 0,

View file

@ -78,6 +78,7 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
mutedUntil = 0,
minPriority = Repository.MIN_PRIORITY_USE_GLOBAL,
autoDelete = Repository.AUTO_DELETE_USE_GLOBAL,
icon = null,
upAppId = appId,
upConnectorToken = connectorToken,
totalCount = 0,

View file

@ -35,9 +35,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.*
import java.security.SecureRandom
import java.text.DateFormat
import java.text.StringCharacterIterator

View file

@ -339,6 +339,7 @@
<string name="detail_settings_notifications_instant_title">Instant delivery</string>
<string name="detail_settings_notifications_instant_summary_on">Notifications are delivered instantly. Requires a foreground service and consumes more battery.</string>
<string name="detail_settings_notifications_instant_summary_off">Notifications are delivered using Firebase. Delivery may be delayed, but consumes less battery.</string>
<string name="detail_settings_general_icon_title">Custom icon</string>
<string name="detail_settings_global_setting_title">Use global setting</string>
<string name="detail_settings_global_setting_suffix">global</string>

View file

@ -35,6 +35,7 @@
<string name="detail_settings_notifications_muted_until_key" translatable="false">SubscriptionMutedUntil</string>
<string name="detail_settings_notifications_min_priority_key" translatable="false">SubscriptionMinPriority</string>
<string name="detail_settings_notifications_auto_delete_key" translatable="false">SubscriptionAutoDelete</string>
<string name="detail_settings_general_icon_key" translatable="false">SubscriptionIcon</string>
<!-- Main settings -->
<string-array name="settings_notifications_muted_until_entries">

View file

@ -27,4 +27,10 @@
app:defaultValue="-1"
app:isPreferenceVisible="false"/> <!-- Same as Repository.AUTO_DELETE_USE_GLOBAL -->
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_general_header">
<Preference
app:key="@string/detail_settings_general_icon_key"
app:title="@string/detail_settings_general_icon_title"
app:isPreferenceVisible="false"/>
</PreferenceCategory>
</PreferenceScreen>