Muted until feature

This commit is contained in:
Philipp Heckel 2021-11-22 15:45:43 -05:00
parent 71b5d56f6a
commit 8db05d7c88
46 changed files with 702 additions and 345 deletions

View file

@ -58,7 +58,6 @@ dependencies {
// WorkManager // WorkManager
implementation "androidx.work:work-runtime-ktx:2.6.0" implementation "androidx.work:work-runtime-ktx:2.6.0"
implementation 'androidx.preference:preference:1.1.1'
// Room (SQLite) // Room (SQLite)
def roomVersion = "2.3.0" def roomVersion = "2.3.0"

View file

@ -21,8 +21,6 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
<activity android:name=".ui.SubscriptionSettingsActivity">
</activity>
<!-- Main activity --> <!-- Main activity -->
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
@ -32,22 +30,30 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> <!-- Detail activity --> </activity>
<!-- Detail activity -->
<activity <activity
android:name=".ui.DetailActivity" android:name=".ui.DetailActivity"
android:parentActivityName=".ui.MainActivity"> android:parentActivityName=".ui.MainActivity">
<meta-data <meta-data
android:name="android.support.PARENT_ACTIVITY" android:name="android.support.PARENT_ACTIVITY"
android:value=".ui.MainActivity"/> android:value=".ui.MainActivity"/>
</activity> <!-- Subscriber foreground service for hosts other than ntfy.sh --> </activity>
<service android:name=".msg.SubscriberService"/> <!-- Subscriber service restart on reboot -->
<!-- Subscriber foreground service for hosts other than ntfy.sh -->
<service android:name=".msg.SubscriberService"/>
<!-- Subscriber service restart on reboot -->
<receiver <receiver
android:name=".msg.SubscriberService$StartReceiver" android:name=".msg.SubscriberService$StartReceiver"
android:enabled="true"> android:enabled="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/> <action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter> </intent-filter>
</receiver> <!-- Firebase messaging --> </receiver>
<!-- Firebase messaging -->
<service <service
android:name=".msg.FirebaseService" android:name=".msg.FirebaseService"
android:exported="false"> android:exported="false">

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.app package io.heckel.ntfy.app
import android.app.Application import android.app.Application
import android.content.Context
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import io.heckel.ntfy.data.Database import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Repository
@ -8,5 +9,8 @@ import io.heckel.ntfy.msg.ApiService
class Application : Application() { class Application : Application() {
private val database by lazy { Database.getInstance(this) } private val database by lazy { Database.getInstance(this) }
val repository by lazy { Repository.getInstance(database.subscriptionDao(), database.notificationDao()) } val repository by lazy {
val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
}
} }

View file

@ -13,9 +13,7 @@ data class Subscription(
@ColumnInfo(name = "baseUrl") val baseUrl: String, @ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "instant") val instant: Boolean, @ColumnInfo(name = "instant") val instant: Boolean,
@ColumnInfo(name = "mutedUntil") val mutedUntil: Long, @ColumnInfo(name = "mutedUntil") val mutedUntil: Long, // TODO notificationSound, notificationSchedule
//val notificationSchedule: String,
//val notificationSound: String,
@Ignore val totalCount: Int = 0, // Total notifications @Ignore val totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications @Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp @Ignore val lastActive: Long = 0, // Unix timestamp
@ -161,7 +159,7 @@ interface SubscriptionDao {
@Dao @Dao
interface NotificationDao { interface NotificationDao {
@Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC") @Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId AND deleted != 1 ORDER BY timestamp DESC")
fun list(subscriptionId: Long): Flow<List<Notification>> fun listFlow(subscriptionId: Long): Flow<List<Notification>>
@Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") // Includes deleted @Query("SELECT id FROM notification WHERE subscriptionId = :subscriptionId") // Includes deleted
fun listIds(subscriptionId: Long): List<String> fun listIds(subscriptionId: Long): List<String>
@ -172,6 +170,9 @@ interface NotificationDao {
@Query("SELECT * FROM notification WHERE id = :notificationId") @Query("SELECT * FROM notification WHERE id = :notificationId")
fun get(notificationId: String): Notification? fun get(notificationId: String): Notification?
@Query("UPDATE notification SET notificationId = 0 WHERE subscriptionId = :subscriptionId")
fun clearAllNotificationIds(subscriptionId: Long)
@Update @Update
fun update(notification: Notification) fun update(notification: Notification)

View file

@ -1,12 +1,13 @@
package io.heckel.ntfy.data package io.heckel.ntfy.data
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.* import androidx.lifecycle.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) { class Repository(private val sharedPrefs: SharedPreferences, private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
private val connectionStates = ConcurrentHashMap<Long, ConnectionState>() private val connectionStates = ConcurrentHashMap<Long, ConnectionState>()
private val connectionStatesLiveData = MutableLiveData(connectionStates) private val connectionStatesLiveData = MutableLiveData(connectionStates)
val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ... val detailViewSubscriptionId = AtomicLong(0L) // Omg, what a hack ...
@ -66,7 +67,11 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
} }
fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> { fun getNotificationsLiveData(subscriptionId: Long): LiveData<List<Notification>> {
return notificationDao.list(subscriptionId).asLiveData() return notificationDao.listFlow(subscriptionId).asLiveData()
}
fun clearAllNotificationIds(subscriptionId: Long) {
return notificationDao.clearAllNotificationIds(subscriptionId)
} }
fun getNotification(notificationId: String): Notification? { fun getNotification(notificationId: String): Notification? {
@ -84,11 +89,17 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
val maybeExistingNotification = notificationDao.get(notification.id) val maybeExistingNotification = notificationDao.get(notification.id)
if (maybeExistingNotification == null) { if (maybeExistingNotification == null) {
notificationDao.add(notification) notificationDao.add(notification)
return true return shouldNotify(notification)
} }
return false return false
} }
private suspend fun shouldNotify(notification: Notification): Boolean {
val detailViewOpen = detailViewSubscriptionId.get() == notification.subscriptionId
val muted = isMuted(notification.subscriptionId)
return !detailViewOpen && !muted
}
fun updateNotification(notification: Notification) { fun updateNotification(notification: Notification) {
notificationDao.update(notification) notificationDao.update(notification)
} }
@ -105,6 +116,51 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
notificationDao.removeAll(subscriptionId) notificationDao.removeAll(subscriptionId)
} }
fun getPollWorkerVersion(): Int {
return sharedPrefs.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0)
}
fun setPollWorkerVersion(version: Int) {
sharedPrefs.edit()
.putInt(SHARED_PREFS_POLL_WORKER_VERSION, version)
.apply()
}
private suspend fun isMuted(subscriptionId: Long): Boolean {
if (isGlobalMuted()) {
return true
}
val s = getSubscription(subscriptionId) ?: return true
return s.mutedUntil == 1L || (s.mutedUntil > 1L && s.mutedUntil > System.currentTimeMillis()/1000)
}
private fun isGlobalMuted(): Boolean {
val mutedUntil = getGlobalMutedUntil()
return mutedUntil == 1L || (mutedUntil > 1L && mutedUntil > System.currentTimeMillis()/1000)
}
fun getGlobalMutedUntil(): Long {
return sharedPrefs.getLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L)
}
fun setGlobalMutedUntil(mutedUntilTimestamp: Long) {
sharedPrefs.edit()
.putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, mutedUntilTimestamp)
.apply()
}
fun checkGlobalMutedUntil(): Boolean {
val mutedUntil = sharedPrefs.getLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L)
val expired = mutedUntil > 1L && System.currentTimeMillis()/1000 > mutedUntil
if (expired) {
sharedPrefs.edit()
.putLong(SHARED_PREFS_MUTED_UNTIL_TIMESTAMP, 0L)
.apply()
return true
}
return false
}
private fun toSubscriptionList(list: List<SubscriptionWithMetadata>): List<Subscription> { private fun toSubscriptionList(list: List<SubscriptionWithMetadata>): List<Subscription> {
return list.map { s -> return list.map { s ->
val connectionState = connectionStates.getOrElse(s.id) { ConnectionState.NOT_APPLICABLE } val connectionState = connectionStates.getOrElse(s.id) { ConnectionState.NOT_APPLICABLE }
@ -162,12 +218,16 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif
} }
companion object { companion object {
const val SHARED_PREFS_ID = "MainPreferences"
const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
const val SHARED_PREFS_MUTED_UNTIL_TIMESTAMP = "MutedUntil"
private const val TAG = "NtfyRepository" private const val TAG = "NtfyRepository"
private var instance: Repository? = null private var instance: Repository? = null
fun getInstance(subscriptionDao: SubscriptionDao, notificationDao: NotificationDao): Repository { fun getInstance(sharedPrefs: SharedPreferences, subscriptionDao: SubscriptionDao, notificationDao: NotificationDao): Repository {
return synchronized(Repository::class) { return synchronized(Repository::class) {
val newInstance = instance ?: Repository(subscriptionDao, notificationDao) val newInstance = instance ?: Repository(sharedPrefs, subscriptionDao, notificationDao)
instance = newInstance instance = newInstance
newInstance newInstance
} }

View file

@ -7,3 +7,4 @@ fun topicShortUrl(baseUrl: String, topic: String) =
topicUrl(baseUrl, topic) topicUrl(baseUrl, topic)
.replace("http://", "") .replace("http://", "")
.replace("https://", "") .replace("https://", "")

View file

@ -48,11 +48,10 @@ class FirebaseService : FirebaseMessagingService() {
notificationId = Random.nextInt(), notificationId = Random.nextInt(),
deleted = false deleted = false
) )
val added = repository.addNotification(notification) val shouldNotify = repository.addNotification(notification)
val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id
// Send notification (only if it's not already known) // Send notification (only if it's not already known)
if (added && !detailViewOpen) { if (shouldNotify) {
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}") Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")
notifier.send(subscription, notification) notifier.send(subscription, notification)
} }

View file

@ -29,6 +29,7 @@ class NotificationService(val context: Context) {
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscription.instant) intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil)
val pendingIntent: PendingIntent? = TaskStackBuilder.create(context).run { val pendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent) // Add the intent, which inflates the back stack addNextIntentWithParentStack(intent) // Add the intent, which inflates the back stack
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) // Get the PendingIntent containing the entire back stack getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) // Get the PendingIntent containing the entire back stack

View file

@ -174,10 +174,8 @@ class SubscriberService : Service() {
val url = topicUrl(subscription.baseUrl, subscription.topic) val url = topicUrl(subscription.baseUrl, subscription.topic)
Log.d(TAG, "[$url] Received notification: $n") Log.d(TAG, "[$url] Received notification: $n")
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
val added = repository.addNotification(n) val shouldNotify = repository.addNotification(n)
val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id if (shouldNotify) {
if (added && !detailViewOpen) {
Log.d(TAG, "[$url] Showing notification: $n") Log.d(TAG, "[$url] Showing notification: $n")
notifier.send(subscription, n) notifier.send(subscription, n)
} }

View file

@ -6,7 +6,6 @@ import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.util.Log
import android.view.View import android.view.View
import android.widget.Button import android.widget.Button
import android.widget.CheckBox import android.widget.CheckBox
@ -47,11 +46,12 @@ class AddFragment : DialogFragment() {
} }
// Dependencies // Dependencies
val database = Database.getInstance(activity!!.applicationContext) val database = Database.getInstance(requireActivity().applicationContext)
repository = Repository.getInstance(database.subscriptionDao(), database.notificationDao()) val sharedPrefs = requireActivity().getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
// Build root view // Build root view
val view = requireActivity().layoutInflater.inflate(R.layout.add_dialog_fragment, null) val view = requireActivity().layoutInflater.inflate(R.layout.fragment_add_dialog, null)
topicNameText = view.findViewById(R.id.add_dialog_topic_text) as TextInputEditText topicNameText = view.findViewById(R.id.add_dialog_topic_text) as TextInputEditText
baseUrlText = view.findViewById(R.id.add_dialog_base_url_text) as TextInputEditText baseUrlText = view.findViewById(R.id.add_dialog_base_url_text) as TextInputEditText
instantDeliveryBox = view.findViewById(R.id.add_dialog_instant_delivery_box) instantDeliveryBox = view.findViewById(R.id.add_dialog_instant_delivery_box)

View file

@ -27,11 +27,9 @@ import io.heckel.ntfy.data.topicShortUrl
import io.heckel.ntfy.data.topicUrl import io.heckel.ntfy.data.topicUrl
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.delay import java.text.DateFormat
import kotlinx.coroutines.launch
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicLong
class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFragment.NotificationSettingsListener { class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFragment.NotificationSettingsListener {
private val viewModel by viewModels<DetailViewModel> { private val viewModel by viewModels<DetailViewModel> {
@ -47,6 +45,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
private var subscriptionBaseUrl: String = "" // Set in onCreate() private var subscriptionBaseUrl: String = "" // Set in onCreate()
private var subscriptionTopic: String = "" // Set in onCreate() private var subscriptionTopic: String = "" // Set in onCreate()
private var subscriptionInstant: Boolean = false // Set in onCreate() & updated by options menu! private var subscriptionInstant: Boolean = false // Set in onCreate() & updated by options menu!
private var subscriptionMutedUntil: Long = 0L // Set in onCreate() & updated by options menu!
// UI elements // UI elements
private lateinit var adapter: DetailAdapter private lateinit var adapter: DetailAdapter
@ -59,7 +58,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.detail_activity) setContentView(R.layout.activity_detail)
Log.d(MainActivity.TAG, "Create $this") Log.d(MainActivity.TAG, "Create $this")
@ -75,6 +74,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return
subscriptionTopic = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC) ?: return subscriptionTopic = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC) ?: return
subscriptionInstant = intent.getBooleanExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, false) subscriptionInstant = intent.getBooleanExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, false)
subscriptionMutedUntil = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, 0L)
// Set title // Set title
val subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return val subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return
@ -152,43 +152,76 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
Log.d(TAG, "onResume hook: Marking subscription $subscriptionId as 'not open'") Log.d(TAG, "onPause hook: Removing 'notificationId' from all notifications for $subscriptionId")
GlobalScope.launch(Dispatchers.IO) {
// Note: This is here and not in onDestroy/onStop, because we want to clear notifications as early
// as possible, so that we don't see the "new" bubble in the main list anymore.
repository.clearAllNotificationIds(subscriptionId)
}
Log.d(TAG, "onPause hook: Marking subscription $subscriptionId as 'not open'")
repository.detailViewSubscriptionId.set(0) // Mark as closed repository.detailViewSubscriptionId.set(0) // Mark as closed
} }
override fun onDestroy() {
repository.detailViewSubscriptionId.set(0) // Mark as closed
Log.d(TAG, "onDestroy hook: Marking subscription $subscriptionId as 'not open'")
super.onDestroy()
}
private fun maybeCancelNotificationPopups(notifications: List<Notification>) { private fun maybeCancelNotificationPopups(notifications: List<Notification>) {
val notificationsWithPopups = notifications.filter { notification -> notification.notificationId != 0 } val notificationsWithPopups = notifications.filter { notification -> notification.notificationId != 0 }
if (notificationsWithPopups.isNotEmpty()) { if (notificationsWithPopups.isNotEmpty()) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
notificationsWithPopups.forEach { notification -> notificationsWithPopups.forEach { notification ->
notifier?.cancel(notification) notifier?.cancel(notification)
repository.updateNotification(notification.copy(notificationId = 0)) // Do NOT remove the notificationId here, we need that for the UI indicators; we'll remove it in onPause()
} }
} }
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.detail_action_bar_menu, menu) menuInflater.inflate(R.menu.menu_detail_action_bar, menu)
this.menu = menu this.menu = menu
// Show and hide buttons
showHideInstantMenuItems(subscriptionInstant) showHideInstantMenuItems(subscriptionInstant)
showHideNotificationMenuItems(subscriptionMutedUntil)
// Regularly check if "notification muted" time has passed
// NOTE: This is done here, because then we know that we've initialized the menu items.
startNotificationMutedChecker()
return true return true
} }
private fun startNotificationMutedChecker() {
lifecycleScope.launch(Dispatchers.IO) {
delay(1000) // Just to be sure we've initialized all the things, we wait a bit ...
while (isActive) {
Log.d(TAG, "Checking 'muted until' timestamp for subscription $subscriptionId")
val subscription = repository.getSubscription(subscriptionId) ?: return@launch
val mutedUntilExpired = subscription.mutedUntil > 1L && System.currentTimeMillis()/1000 > subscription.mutedUntil
if (mutedUntilExpired) {
val newSubscription = subscription.copy(mutedUntil = 0L)
repository.updateSubscription(newSubscription)
showHideNotificationMenuItems(0L)
}
delay(60_000)
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.detail_menu_test -> { R.id.detail_menu_test -> {
onTestClick() onTestClick()
true true
} }
R.id.detail_menu_notification -> { R.id.detail_menu_notifications_enabled -> {
onNotificationSettingsClick() onNotificationSettingsClick(enable = false)
true
}
R.id.detail_menu_notifications_disabled_until -> {
onNotificationSettingsClick(enable = true)
true
}
R.id.detail_menu_notifications_disabled_forever -> {
onNotificationSettingsClick(enable = true)
true true
} }
R.id.detail_menu_enable_instant -> { R.id.detail_menu_enable_instant -> {
@ -232,36 +265,35 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
} }
} }
private fun onNotificationSettingsClick() { private fun onNotificationSettingsClick(enable: Boolean) {
Log.d(TAG, "Showing notification settings dialog for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") if (!enable) {
val intent = Intent(this, SubscriptionSettingsActivity::class.java) Log.d(TAG, "Showing notification settings dialog for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
startActivityForResult(intent, /*XXXXXX*/MainActivity.REQUEST_CODE_DELETE_SUBSCRIPTION) val notificationFragment = NotificationFragment()
/* notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)
val notificationFragment = NotificationFragment() } else {
notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)*/ Log.d(TAG, "Re-enabling notifications ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
onNotificationMutedUntilChanged(0L)
}
} }
override fun onNotificationSettingsChanged(mutedUntil: Long) { override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val subscription = repository.getSubscription(subscriptionId) val subscription = repository.getSubscription(subscriptionId)
val newSubscription = subscription?.copy(mutedUntil = mutedUntil) val newSubscription = subscription?.copy(mutedUntil = mutedUntilTimestamp)
newSubscription?.let { repository.updateSubscription(newSubscription) } newSubscription?.let { repository.updateSubscription(newSubscription) }
subscriptionMutedUntil = mutedUntilTimestamp
showHideNotificationMenuItems(mutedUntilTimestamp)
runOnUiThread { runOnUiThread {
when (mutedUntil) { when (mutedUntilTimestamp) {
0L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_SHORT).show() 0L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show()
1L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_SHORT).show() 1L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show()
else -> { else -> {
val mutedUntilDate = Date(mutedUntil).toString() val formattedDate = formatDateShort(mutedUntilTimestamp)
Toast.makeText( Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_until_toast_message, formattedDate), Toast.LENGTH_LONG).show()
this@DetailActivity,
getString(R.string.notification_dialog_muted_until_toast_message, mutedUntilDate),
Toast.LENGTH_SHORT
).show()
} }
} }
} }
} }
} }
private fun onCopyUrlClick() { private fun onCopyUrlClick() {
@ -352,6 +384,23 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
} }
} }
private fun showHideNotificationMenuItems(mutedUntilTimestamp: Long) {
subscriptionMutedUntil = mutedUntilTimestamp
runOnUiThread {
val notificationsEnabledItem = menu.findItem(R.id.detail_menu_notifications_enabled)
val notificationsDisabledUntilItem = menu.findItem(R.id.detail_menu_notifications_disabled_until)
val notificationsDisabledForeverItem = menu.findItem(R.id.detail_menu_notifications_disabled_forever)
notificationsEnabledItem?.isVisible = subscriptionMutedUntil == 0L
notificationsDisabledForeverItem?.isVisible = subscriptionMutedUntil == 1L
notificationsDisabledUntilItem?.isVisible = subscriptionMutedUntil > 1L
if (subscriptionMutedUntil > 1L) {
val formattedDate = formatDateShort(subscriptionMutedUntil)
notificationsDisabledUntilItem?.title = getString(R.string.detail_menu_notifications_disabled_until, formattedDate)
}
}
}
private fun onDeleteClick() { private fun onDeleteClick() {
Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
@ -365,6 +414,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscriptionBaseUrl) .putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscriptionBaseUrl)
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscriptionTopic) .putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscriptionTopic)
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscriptionInstant) .putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscriptionInstant)
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscriptionMutedUntil)
setResult(RESULT_OK, result) setResult(RESULT_OK, result)
finish() finish()
@ -414,7 +464,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
this.actionMode = mode this.actionMode = mode
if (mode != null) { if (mode != null) {
mode.menuInflater.inflate(R.menu.detail_action_mode_menu, menu) mode.menuInflater.inflate(R.menu.menu_detail_action_mode, menu)
mode.title = "1" // One item selected mode.title = "1" // One item selected
} }
return true return true

View file

@ -18,7 +18,7 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
/* Creates and inflates view and return TopicViewHolder. */ /* Creates and inflates view and return TopicViewHolder. */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)
.inflate(R.layout.detail_fragment_item, parent, false) .inflate(R.layout.fragment_detail_item, parent, false)
return DetailViewHolder(view, selected, onClick, onLongClick) return DetailViewHolder(view, selected, onClick, onLongClick)
} }
@ -41,11 +41,13 @@ class DetailAdapter(private val onClick: (Notification) -> Unit, private val onL
private var notification: Notification? = null private var notification: Notification? = null
private val dateView: TextView = itemView.findViewById(R.id.detail_item_date_text) private val dateView: TextView = itemView.findViewById(R.id.detail_item_date_text)
private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_text) private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_text)
private val newImageView: View = itemView.findViewById(R.id.detail_item_new)
fun bind(notification: Notification) { fun bind(notification: Notification) {
this.notification = notification this.notification = notification
dateView.text = Date(notification.timestamp * 1000).toString() dateView.text = Date(notification.timestamp * 1000).toString()
messageView.text = notification.message messageView.text = notification.message
newImageView.visibility = if (notification.notificationId == 0) View.GONE else View.VISIBLE
itemView.setOnClickListener { onClick(notification) } itemView.setOnClickListener { onClick(notification) }
itemView.setOnLongClickListener { onLongClick(notification); true } itemView.setOnLongClickListener { onLongClick(notification); true }
if (selected.contains(notification.id)) { if (selected.contains(notification.id)) {

View file

@ -3,7 +3,6 @@ package io.heckel.ntfy.ui
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@ -29,23 +28,28 @@ import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.work.PollWorker import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.random.Random import kotlin.random.Random
class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.SubscribeListener, NotificationFragment.NotificationSettingsListener {
class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.SubscribeListener {
private val viewModel by viewModels<SubscriptionsViewModel> { private val viewModel by viewModels<SubscriptionsViewModel> {
SubscriptionsViewModelFactory((application as Application).repository) SubscriptionsViewModelFactory((application as Application).repository)
} }
private val repository by lazy { (application as Application).repository } private val repository by lazy { (application as Application).repository }
private val api = ApiService() private val api = ApiService()
// UI elements
private lateinit var menu: Menu
private lateinit var mainList: RecyclerView private lateinit var mainList: RecyclerView
private lateinit var mainListContainer: SwipeRefreshLayout private lateinit var mainListContainer: SwipeRefreshLayout
private lateinit var adapter: MainAdapter private lateinit var adapter: MainAdapter
private lateinit var fab: View private lateinit var fab: View
// Other stuff
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var workManager: WorkManager? = null // Context-dependent private var workManager: WorkManager? = null // Context-dependent
private var notifier: NotificationService? = null // Context-dependent private var notifier: NotificationService? = null // Context-dependent
@ -54,7 +58,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity) setContentView(R.layout.activity_main)
Log.d(TAG, "Create $this") Log.d(TAG, "Create $this")
@ -110,15 +114,13 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
} }
private fun startPeriodicWorker() { private fun startPeriodicWorker() {
val sharedPrefs = getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) val pollWorkerVersion = repository.getPollWorkerVersion()
val workPolicy = if (sharedPrefs.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0) == PollWorker.VERSION) { val workPolicy = if (pollWorkerVersion == PollWorker.VERSION) {
Log.d(TAG, "Poll worker version matches: choosing KEEP as existing work policy") Log.d(TAG, "Poll worker version matches: choosing KEEP as existing work policy")
ExistingPeriodicWorkPolicy.KEEP ExistingPeriodicWorkPolicy.KEEP
} else { } else {
Log.d(TAG, "Poll worker version DOES NOT MATCH: choosing REPLACE as existing work policy") Log.d(TAG, "Poll worker version DOES NOT MATCH: choosing REPLACE as existing work policy")
sharedPrefs.edit() repository.setPollWorkerVersion(PollWorker.VERSION)
.putInt(SHARED_PREFS_POLL_WORKER_VERSION, PollWorker.VERSION)
.apply()
ExistingPeriodicWorkPolicy.REPLACE ExistingPeriodicWorkPolicy.REPLACE
} }
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
@ -133,12 +135,76 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.main_action_bar_menu, menu) menuInflater.inflate(R.menu.menu_main_action_bar, menu)
this.menu = menu
showHideNotificationMenuItems()
startNotificationMutedChecker() // This is done here, because then we know that we've initialized the menu
return true return true
} }
private fun startNotificationMutedChecker() {
lifecycleScope.launch(Dispatchers.IO) {
delay(1000) // Just to be sure we've initialized all the things, we wait a bit ...
while (isActive) {
Log.d(DetailActivity.TAG, "Checking global and subscription-specific 'muted until' timestamp")
// Check global
val changed = repository.checkGlobalMutedUntil()
if (changed) {
Log.d(TAG, "Global muted until timestamp expired; updating prefs")
showHideNotificationMenuItems()
}
// Check subscriptions
var rerenderList = false
repository.getSubscriptions().forEach { subscription ->
val mutedUntilExpired = subscription.mutedUntil > 1L && System.currentTimeMillis()/1000 > subscription.mutedUntil
if (mutedUntilExpired) {
Log.d(TAG, "Subscription ${subscription.id}: Muted until timestamp expired, updating subscription")
val newSubscription = subscription.copy(mutedUntil = 0L)
repository.updateSubscription(newSubscription)
rerenderList = true
}
}
if (rerenderList) {
redrawList()
}
delay(60_000)
}
}
}
private fun showHideNotificationMenuItems() {
val mutedUntilSeconds = repository.getGlobalMutedUntil()
runOnUiThread {
val notificationsEnabledItem = menu.findItem(R.id.main_menu_notifications_enabled)
val notificationsDisabledUntilItem = menu.findItem(R.id.main_menu_notifications_disabled_until)
val notificationsDisabledForeverItem = menu.findItem(R.id.main_menu_notifications_disabled_forever)
notificationsEnabledItem?.isVisible = mutedUntilSeconds == 0L
notificationsDisabledForeverItem?.isVisible = mutedUntilSeconds == 1L
notificationsDisabledUntilItem?.isVisible = mutedUntilSeconds > 1L
if (mutedUntilSeconds > 1L) {
val formattedDate = formatDateShort(mutedUntilSeconds)
notificationsDisabledUntilItem?.title = getString(R.string.main_menu_notifications_disabled_until, formattedDate)
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.main_menu_notifications_enabled -> {
onNotificationSettingsClick(enable = false)
true
}
R.id.main_menu_notifications_disabled_forever -> {
onNotificationSettingsClick(enable = true)
true
}
R.id.main_menu_notifications_disabled_until -> {
onNotificationSettingsClick(enable = true)
true
}
R.id.main_menu_source -> { R.id.main_menu_source -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url)))) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url))))
true true
@ -151,6 +217,32 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
} }
} }
private fun onNotificationSettingsClick(enable: Boolean) {
if (!enable) {
Log.d(TAG, "Showing global notification settings dialog")
val notificationFragment = NotificationFragment()
notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)
} else {
Log.d(TAG, "Re-enabling global notifications")
onNotificationMutedUntilChanged(0L)
}
}
override fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long) {
repository.setGlobalMutedUntil(mutedUntilTimestamp)
showHideNotificationMenuItems()
runOnUiThread {
when (mutedUntilTimestamp) {
0L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_LONG).show()
1L -> Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_LONG).show()
else -> {
val formattedDate = formatDateShort(mutedUntilTimestamp)
Toast.makeText(this@MainActivity, getString(R.string.notification_dialog_muted_until_toast_message, formattedDate), Toast.LENGTH_LONG).show()
}
}
}
}
private fun onSubscribeButtonClick() { private fun onSubscribeButtonClick() {
val newFragment = AddFragment() val newFragment = AddFragment()
newFragment.show(supportFragmentManager, AddFragment.TAG) newFragment.show(supportFragmentManager, AddFragment.TAG)
@ -223,10 +315,12 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic) val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic)
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications) val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification -> newNotifications.forEach { notification ->
val notificationWithId = notification.copy(notificationId = Random.nextInt())
repository.addNotification(notificationWithId)
notifier?.send(subscription, notificationWithId)
newNotificationsCount++ newNotificationsCount++
val notificationWithId = notification.copy(notificationId = Random.nextInt())
val shouldNotify = repository.addNotification(notificationWithId)
if (shouldNotify) {
notifier?.send(subscription, notificationWithId)
}
} }
} }
val toastMessage = if (newNotificationsCount == 0) { val toastMessage = if (newNotificationsCount == 0) {
@ -257,6 +351,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl) intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic) intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
intent.putExtra(EXTRA_SUBSCRIPTION_INSTANT, subscription.instant) intent.putExtra(EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
intent.putExtra(EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil)
startActivityForResult(intent, REQUEST_CODE_DELETE_SUBSCRIPTION) startActivityForResult(intent, REQUEST_CODE_DELETE_SUBSCRIPTION)
} }
@ -293,7 +388,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
this.actionMode = mode this.actionMode = mode
if (mode != null) { if (mode != null) {
mode.menuInflater.inflate(R.menu.main_action_mode_menu, menu) mode.menuInflater.inflate(R.menu.menu_main_action_mode, menu)
mode.title = "1" // One item selected mode.title = "1" // One item selected
} }
return true return true
@ -387,7 +482,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
} }
private fun redrawList() { private fun redrawList() {
mainList.adapter = adapter // Oh, what a hack ... runOnUiThread {
mainList.adapter = adapter // Oh, what a hack ...
}
} }
companion object { companion object {
@ -396,9 +493,8 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl" const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl"
const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic" const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic"
const val EXTRA_SUBSCRIPTION_INSTANT = "subscriptionInstant" const val EXTRA_SUBSCRIPTION_INSTANT = "subscriptionInstant"
const val EXTRA_SUBSCRIPTION_MUTED_UNTIL = "subscriptionMutedUntil"
const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1 const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1
const val ANIMATION_DURATION = 80L const val ANIMATION_DURATION = 80L
const val SHARED_PREFS_ID = "MainPreferences"
const val SHARED_PREFS_POLL_WORKER_VERSION = "PollWorkerVersion"
} }
} }

View file

@ -23,7 +23,7 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
/* Creates and inflates view and return TopicViewHolder. */ /* Creates and inflates view and return TopicViewHolder. */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context)
.inflate(R.layout.main_fragment_item, parent, false) .inflate(R.layout.fragment_main_item, parent, false)
return SubscriptionViewHolder(view, selected, onClick, onLongClick) return SubscriptionViewHolder(view, selected, onClick, onLongClick)
} }
@ -49,6 +49,8 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
private val nameView: TextView = itemView.findViewById(R.id.main_item_text) private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
private val statusView: TextView = itemView.findViewById(R.id.main_item_status) private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
private val dateView: TextView = itemView.findViewById(R.id.main_item_date) private val dateView: TextView = itemView.findViewById(R.id.main_item_date)
private val notificationDisabledUntilImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_until_image)
private val notificationDisabledForeverImageView: View = itemView.findViewById(R.id.main_item_notification_disabled_forever_image)
private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image) private val instantImageView: View = itemView.findViewById(R.id.main_item_instant_image)
private val newItemsView: TextView = itemView.findViewById(R.id.main_item_new) private val newItemsView: TextView = itemView.findViewById(R.id.main_item_new)
@ -78,11 +80,9 @@ class MainAdapter(private val onClick: (Subscription) -> Unit, private val onLon
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic) nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
statusView.text = statusMessage statusView.text = statusMessage
dateView.text = dateText dateView.text = dateText
if (subscription.instant) { notificationDisabledUntilImageView.visibility = if (subscription.mutedUntil > 1L) View.VISIBLE else View.GONE
instantImageView.visibility = View.VISIBLE notificationDisabledForeverImageView.visibility = if (subscription.mutedUntil == 1L) View.VISIBLE else View.GONE
} else { instantImageView.visibility = if (subscription.instant) View.VISIBLE else View.GONE
instantImageView.visibility = View.GONE
}
if (subscription.newCount > 0) { if (subscription.newCount > 0) {
newItemsView.visibility = View.VISIBLE newItemsView.visibility = View.VISIBLE
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+" newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"

View file

@ -4,27 +4,29 @@ import android.app.AlertDialog
import android.app.Dialog import android.app.Dialog
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.widget.RadioButton
import android.text.TextWatcher
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.CheckBox
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputEditText
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.Database import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Repository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.*
class NotificationFragment : DialogFragment() { class NotificationFragment : DialogFragment() {
private lateinit var repository: Repository private lateinit var repository: Repository
private lateinit var settingsListener: NotificationSettingsListener private lateinit var settingsListener: NotificationSettingsListener
private lateinit var muteFor30minButton: RadioButton
private lateinit var muteFor1hButton: RadioButton
private lateinit var muteFor2hButton: RadioButton
private lateinit var muteFor8hButton: RadioButton
private lateinit var muteUntilTomorrowButton: RadioButton
private lateinit var muteForeverButton: RadioButton
interface NotificationSettingsListener { interface NotificationSettingsListener {
fun onNotificationSettingsChanged(mutedUntil: Long) fun onNotificationMutedUntilChanged(mutedUntilTimestamp: Long)
} }
override fun onAttach(context: Context) { override fun onAttach(context: Context) {
@ -38,34 +40,55 @@ class NotificationFragment : DialogFragment() {
} }
// Dependencies // Dependencies
val database = Database.getInstance(activity!!.applicationContext) val database = Database.getInstance(requireActivity().applicationContext)
repository = Repository.getInstance(database.subscriptionDao(), database.notificationDao()) val sharedPrefs = requireActivity().getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
// Build root view // Build root view
val view = requireActivity().layoutInflater.inflate(R.layout.notification_dialog_fragment, null) val view = requireActivity().layoutInflater.inflate(R.layout.fragment_notification_dialog, null)
// topicNameText = view.findViewById(R.id.add_dialog_topic_text) as TextInputEditText
// Build dialog muteFor30minButton = view.findViewById(R.id.notification_dialog_30min)
val alert = AlertDialog.Builder(activity) muteFor30minButton.setOnClickListener { onClickMinutes(30) }
.setView(view)
.setPositiveButton(R.string.notification_dialog_save) { _, _ ->
///
settingsListener.onNotificationSettingsChanged(0L)
}
.setNegativeButton(R.string.notification_dialog_cancel) { _, _ ->
dialog?.cancel()
}
.create()
// Add logic to disable "Subscribe" button on invalid input muteFor1hButton = view.findViewById(R.id.notification_dialog_1h)
alert.setOnShowListener { muteFor1hButton.setOnClickListener { onClickMinutes(60) }
val dialog = it as AlertDialog
/// muteFor2hButton = view.findViewById(R.id.notification_dialog_2h)
muteFor2hButton.setOnClickListener { onClickMinutes(2 * 60) }
muteFor8hButton = view.findViewById(R.id.notification_dialog_8h)
muteFor8hButton.setOnClickListener{ onClickMinutes(8 * 60) }
muteUntilTomorrowButton = view.findViewById(R.id.notification_dialog_tomorrow)
muteUntilTomorrowButton.setOnClickListener {
val date = Calendar.getInstance()
date.add(Calendar.DAY_OF_MONTH, 1)
date.set(Calendar.HOUR_OF_DAY, 8)
date.set(Calendar.MINUTE, 30)
date.set(Calendar.SECOND, 0)
date.set(Calendar.MILLISECOND, 0)
onClick(date.timeInMillis/1000)
} }
return alert muteForeverButton = view.findViewById(R.id.notification_dialog_forever)
muteForeverButton.setOnClickListener{ onClick(1) }
return AlertDialog.Builder(activity)
.setView(view)
.create()
} }
private fun onClickMinutes(minutes: Int) {
onClick(System.currentTimeMillis()/1000 + minutes * 60)
}
private fun onClick(mutedUntilTimestamp: Long) {
lifecycleScope.launch(Dispatchers.Main) {
delay(150) // Another hack: Let the animation finish before dismissing the window
settingsListener.onNotificationMutedUntilChanged(mutedUntilTimestamp)
dismiss()
}
}
companion object { companion object {
const val TAG = "NtfyNotificationFragment" const val TAG = "NtfyNotificationFragment"

View file

@ -1,5 +0,0 @@
package io.heckel.ntfy.ui
import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat
import io.heckel.ntfy.R

View file

@ -1,25 +0,0 @@
package io.heckel.ntfy.ui
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.preference.PreferenceFragmentCompat
import io.heckel.ntfy.R
class SubscriptionSettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_subscription_settings)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true)
supportFragmentManager
.beginTransaction()
.replace(R.id.subscription_settings_content, SubscriptionSettingsFragment())
.commit()
}
class SubscriptionSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey)
}
}
}

View file

@ -3,6 +3,8 @@ package io.heckel.ntfy.ui
import android.animation.ArgbEvaluator import android.animation.ArgbEvaluator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.view.Window import android.view.Window
import java.text.DateFormat
import java.util.*
// Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785 // Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785
fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) { fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
@ -13,3 +15,8 @@ fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
} }
statusBarColorAnimation.start() statusBarColorAnimation.start()
} }
fun formatDateShort(timestampSecs: Long): String {
val mutedUntilDate = Date(timestampSecs*1000)
return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(mutedUntilDate)
}

View file

@ -14,14 +14,16 @@ import kotlinx.coroutines.withContext
import kotlin.random.Random import kotlin.random.Random
class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
// Every time the worker is changed, the periodic work has to be REPLACEd. // IMPORTANT WARNING:
// This is facilitated in the MainActivity using the VERSION below. // Every time the worker is changed, the periodic work has to be REPLACEd.
// This is facilitated in the MainActivity using the VERSION below.
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
Log.d(TAG, "Polling for new notifications") Log.d(TAG, "Polling for new notifications")
val database = Database.getInstance(applicationContext) val database = Database.getInstance(applicationContext)
val repository = Repository.getInstance(database.subscriptionDao(), database.notificationDao()) val sharedPrefs = applicationContext.getSharedPreferences(Repository.SHARED_PREFS_ID, Context.MODE_PRIVATE)
val repository = Repository.getInstance(sharedPrefs, database.subscriptionDao(), database.notificationDao())
val notifier = NotificationService(applicationContext) val notifier = NotificationService(applicationContext)
val api = ApiService() val api = ApiService()
@ -32,10 +34,8 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
.onlyNewNotifications(subscription.id, notifications) .onlyNewNotifications(subscription.id, notifications)
.map { it.copy(notificationId = Random.nextInt()) } .map { it.copy(notificationId = Random.nextInt()) }
newNotifications.forEach { notification -> newNotifications.forEach { notification ->
val added = repository.addNotification(notification) val shouldNotify = repository.addNotification(notification)
val detailViewOpen = repository.detailViewSubscriptionId.get() == subscription.id if (shouldNotify) {
if (added && !detailViewOpen) {
notifier.send(subscription, notification) notifier.send(subscription, notification)
} }
} }

View file

@ -5,5 +5,5 @@
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:pathData="M11,21h-1l1,-7H7.5c-0.88,0 -0.33,-0.75 -0.31,-0.78C8.48,10.94 10.42,7.54 13.01,3h1l-1,7h3.51c0.4,0 0.62,0.19 0.4,0.66C12.97,17.55 11,21 11,21z" android:pathData="M11,21h-1l1,-7H7.5c-0.88,0 -0.33,-0.75 -0.31,-0.78C8.48,10.94 10.42,7.54 13.01,3h1l-1,7h3.51c0.4,0 0.62,0.19 0.4,0.66C12.97,17.55 11,21 11,21z"
android:fillColor="#000000"/> android:fillColor="#555555"/>
</vector> </vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,2.5C11.17,2.5 10.5,3.17 10.5,4L10.5,4.6797C9.556,4.9041 8.749,5.3367 8.0859,5.9219L9.5273,7.3672C10.1748,6.8243 11.0077,6.5 12,6.5C14.49,6.5 16,8.52 16,11L16,13.8555L18,15.8613L18,11C18,7.93 16.37,5.3597 13.5,4.6797L13.5,4C13.5,3.17 12.83,2.5 12,2.5zM6.7715,7.6289C6.2688,8.6106 6,9.762 6,11L6,16L4,18L4,19L18.1172,19L16,16.8789L16,17L8,17L8,11C8,10.3476 8.1073,9.7283 8.3066,9.168L6.7715,7.6289zM10,20C10,21.1 10.9,22 12,22C13.1,22 14,21.1 14,20L10,20z"
android:fillColor="#555555"/>
<path
android:pathData="M3.543,3.3965 L2.0313,4.9043 19.5234,22.4395 21.0352,20.9316Z"
android:fillColor="#555555"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,2.5C11.17,2.5 10.5,3.17 10.5,4L10.5,4.6797C9.556,4.9041 8.749,5.3367 8.0859,5.9219L9.5273,7.3672C10.1748,6.8243 11.0077,6.5 12,6.5C14.1766,6.5 15.6028,8.0429 15.9277,10.0879A6.6092,6.6092 0,0 1,16.502 10.0371A6.6092,6.6092 0,0 1,17.9609 10.2031C17.7024,7.4927 16.1179,5.2999 13.5,4.6797L13.5,4C13.5,3.17 12.83,2.5 12,2.5zM3.543,3.3965L2.0313,4.9043L6.2266,9.1094C6.0793,9.7072 6,10.3404 6,11L6,16L4,18L4,19L10.334,19A6.6092,6.6092 0,0 1,9.9238 17L8,17L8,11C8,10.9637 8.0032,10.9287 8.0039,10.8926L10.6738,13.5684A6.6092,6.6092 0,0 1,11.9824 11.8555L3.543,3.3965z"
android:fillColor="#555555"/>
<path
android:pathData="m16.8553,10.7743c-3.3109,0 -6.002,2.6955 -6.002,6.0059 0,3.3104 2.6911,6.0078 6.002,6.0078 3.316,0 6.0117,-2.6969 6.0117,-6.0078 0,-3.3109 -2.6957,-6.0059 -6.0117,-6.0059zM16.8592,12.7861c2.2124,0 3.9941,1.7818 3.9941,3.9941 0,2.2124 -1.7818,3.9941 -3.9941,3.9941 -2.2124,0 -3.9941,-1.7818 -3.9941,-3.9941 0,-2.2124 1.7818,-3.9941 3.9941,-3.9941z"
android:fillColor="#555555"/>
<path
android:pathData="m15.6308,13.426v4.041l3.5195,2.1113 0.8887,-1.4551 -2.6719,-1.5859v-3.1113h-0.4512z"
android:fillColor="#555555"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,2.5C11.17,2.5 10.5,3.17 10.5,4L10.5,4.6797C9.556,4.9041 8.749,5.3367 8.0859,5.9219L9.5273,7.3672C10.1748,6.8243 11.0077,6.5 12,6.5C14.1766,6.5 15.6028,8.0429 15.9277,10.0879A6.6092,6.6092 0,0 1,16.502 10.0371A6.6092,6.6092 0,0 1,17.9609 10.2031C17.7024,7.4927 16.1179,5.2999 13.5,4.6797L13.5,4C13.5,3.17 12.83,2.5 12,2.5zM3.543,3.3965L2.0313,4.9043L6.2266,9.1094C6.0793,9.7072 6,10.3404 6,11L6,16L4,18L4,19L10.334,19A6.6092,6.6092 0,0 1,9.9238 17L8,17L8,11C8,10.9637 8.0032,10.9287 8.0039,10.8926L10.6738,13.5684A6.6092,6.6092 0,0 1,11.9824 11.8555L3.543,3.3965z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="m16.8553,10.7743c-3.3109,0 -6.002,2.6955 -6.002,6.0059 0,3.3104 2.6911,6.0078 6.002,6.0078 3.316,0 6.0117,-2.6969 6.0117,-6.0078 0,-3.3109 -2.6957,-6.0059 -6.0117,-6.0059zM16.8592,12.7861c2.2124,0 3.9941,1.7818 3.9941,3.9941 0,2.2124 -1.7818,3.9941 -3.9941,3.9941 -2.2124,0 -3.9941,-1.7818 -3.9941,-3.9941 0,-2.2124 1.7818,-3.9941 3.9941,-3.9941z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="m15.6308,13.426v4.041l3.5195,2.1113 0.8887,-1.4551 -2.6719,-1.5859v-3.1113h-0.4512z"
android:fillColor="#FFFFFF"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,2.5C11.17,2.5 10.5,3.17 10.5,4L10.5,4.6797C9.556,4.9041 8.749,5.3367 8.0859,5.9219L9.5273,7.3672C10.1748,6.8243 11.0077,6.5 12,6.5C14.49,6.5 16,8.52 16,11L16,13.8555L18,15.8613L18,11C18,7.93 16.37,5.3597 13.5,4.6797L13.5,4C13.5,3.17 12.83,2.5 12,2.5zM6.7715,7.6289C6.2688,8.6106 6,9.762 6,11L6,16L4,18L4,19L18.1172,19L16,16.8789L16,17L8,17L8,11C8,10.3476 8.1073,9.7283 8.3066,9.168L6.7715,7.6289zM10,20C10,21.1 10.9,22 12,22C13.1,22 14,21.1 14,20L10,20z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M3.543,3.3965 L2.0313,4.9043 19.5234,22.4395 21.0352,20.9316Z"
android:fillColor="#FFFFFF"/>
</vector>

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.SubscriptionSettingsActivity">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/subscription_settings_content" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:orientation="vertical" android:clickable="true" android:focusable="true">
<TextView
android:text="Sun, October 31, 2021, 10:43:12"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/detail_item_date_text"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="10dp"
android:textAppearance="@style/TextAppearance.AppCompat.Small"/>
<TextView
android:text="This is a very very very long message. It could be as long as 1024 charaters, which is a lot more than you'd think. No, really so far this message is barely 180 characters long. I can't believe how long 1024 bytes are. This is outrageous. Oh you know what, I think I won't type the whole thing. This seems a little too long for a sample text. Well, anyway, it was nice chatting. So far this message is about 400 bytes long. So maybe just double what you see and that's that."
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/detail_item_message_text"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="10dp"
android:textColor="@color/primaryTextColor"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
</LinearLayout>

View file

@ -54,7 +54,7 @@
android:layout_marginTop="-8dp" android:layout_marginBottom="-5dp" android:layout_marginStart="-3dp"/> android:layout_marginTop="-8dp" android:layout_marginBottom="-5dp" android:layout_marginStart="-3dp"/>
<ImageView <ImageView
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_bolt_black_24dp" android:layout_height="24dp" app:srcCompat="@drawable/ic_bolt_gray_24dp"
android:id="@+id/add_dialog_instant_image" android:id="@+id/add_dialog_instant_image"
app:layout_constraintTop_toTopOf="@+id/main_item_text" app:layout_constraintTop_toTopOf="@+id/main_item_text"
app:layout_constraintEnd_toStartOf="@+id/main_item_date" android:paddingTop="3dp" app:layout_constraintEnd_toStartOf="@+id/main_item_date" android:paddingTop="3dp"

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:orientation="horizontal" android:clickable="true"
android:focusable="true" android:paddingBottom="10dp"
android:paddingTop="10dp" android:paddingStart="16dp"
android:paddingEnd="10dp">
<TextView
android:text="Sun, October 31, 2021, 10:43:12"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/detail_item_date_text"
android:textAppearance="@style/TextAppearance.AppCompat.Small" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<TextView
android:layout_width="10dp"
android:layout_height="10dp" android:id="@+id/detail_item_new"
android:layout_gravity="center"
android:background="@drawable/ic_circle"
android:gravity="center"
app:layout_constraintTop_toTopOf="@+id/detail_item_date_text"
app:layout_constraintBottom_toBottomOf="@+id/detail_item_date_text"
android:layout_marginTop="1dp" app:layout_constraintStart_toEndOf="@+id/detail_item_date_text"
android:layout_marginStart="5dp"/>
<TextView
android:text="This is a very very very long message. It could be as long as 1024 charaters, which is a lot more than you'd think. No, really so far this message is barely 180 characters long. I can't believe how long 1024 bytes are. This is outrageous. Oh you know what, I think I won't type the whole thing. This seems a little too long for a sample text. Well, anyway, it was nice chatting. So far this message is about 400 bytes long. So maybe just double what you see and that's that."
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/detail_item_message_text"
android:textColor="@color/primaryTextColor"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintTop_toBottomOf="@+id/detail_item_date_text"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -32,9 +32,23 @@
android:layout_marginBottom="10dp"/> android:layout_marginBottom="10dp"/>
<ImageView <ImageView
android:layout_width="20dp" android:layout_width="20dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_bolt_black_24dp" android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_time_gray_outline_24dp"
android:id="@+id/main_item_instant_image" android:id="@+id/main_item_notification_disabled_until_image"
app:layout_constraintTop_toTopOf="@+id/main_item_text" app:layout_constraintTop_toTopOf="@+id/main_item_text"
app:layout_constraintEnd_toStartOf="@+id/main_item_notification_disabled_forever_image"
android:paddingTop="3dp" android:layout_marginEnd="3dp"/>
<ImageView
android:layout_width="20dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_notifications_off_gray_outline_24dp"
android:id="@+id/main_item_notification_disabled_forever_image"
app:layout_constraintTop_toTopOf="@+id/main_item_notification_disabled_until_image"
app:layout_constraintEnd_toStartOf="@+id/main_item_instant_image" android:paddingTop="3dp"
android:layout_marginEnd="3dp"/>
<ImageView
android:layout_width="20dp"
android:layout_height="24dp" app:srcCompat="@drawable/ic_bolt_gray_24dp"
android:id="@+id/main_item_instant_image"
app:layout_constraintTop_toTopOf="@+id/main_item_notification_disabled_forever_image"
app:layout_constraintEnd_toStartOf="@+id/main_item_date" android:paddingTop="3dp"/> app:layout_constraintEnd_toStartOf="@+id/main_item_date" android:paddingTop="3dp"/>
<TextView <TextView
android:text="10:13" android:text="10:13"

View file

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingRight="16dp">
<TextView
android:id="@+id/notification_dialog_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="8dp"
android:text="@string/notification_dialog_title"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" android:paddingStart="5dp" android:paddingEnd="5dp"
android:textColor="@color/primaryTextColor"/>
<RadioGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/notification_dialog_title"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent" android:layout_marginBottom="10dp">
<RadioButton
android:text="@string/notification_dialog_30min"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/notification_dialog_30min"
android:background="?attr/selectableItemBackgroundBorderless"
android:layout_marginTop="-3dp" android:layout_marginBottom="-3dp"
/>
<RadioButton
android:text="@string/notification_dialog_1h"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/notification_dialog_1h"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:layout_marginTop="-3dp" android:layout_marginBottom="-3dp"
/>
<RadioButton
android:text="@string/notification_dialog_2h"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/notification_dialog_2h"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:layout_marginTop="-3dp" android:layout_marginBottom="-3dp"
/>
<RadioButton
android:text="@string/notification_dialog_8h"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/notification_dialog_8h"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:layout_marginTop="-3dp" android:layout_marginBottom="-3dp"
/>
<RadioButton
android:text="@string/notification_dialog_tomorrow"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/notification_dialog_tomorrow"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:layout_marginTop="-3dp" android:layout_marginBottom="-3dp"
/>
<RadioButton
android:text="@string/notification_dialog_forever"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/notification_dialog_forever"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:layout_marginTop="-3dp" android:layout_marginBottom="-3dp"
/>
</RadioGroup>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="16dp"
android:paddingRight="16dp">
<TextView
android:id="@+id/add_dialog_title_text2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:paddingBottom="3dp"
android:text="@string/notification_dialog_title"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Large" android:paddingStart="4dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
android:layout_marginStart="16dp" android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"/>
<TextView
android:text="Pause notifications"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:id="@+id/textView"
app:layout_constraintTop_toBottomOf="@+id/add_dialog_title_text2"
android:layout_marginTop="20dp" app:layout_constraintStart_toStartOf="@+id/add_dialog_title_text2"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
<RadioGroup
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@+id/textView" app:layout_constraintTop_toBottomOf="@+id/textView"
android:layout_marginTop="10dp">
<RadioButton
android:text="Pause forever"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/radioButton"
/>
<RadioButton
android:text="Pause for 30 minutes"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/radioButton4"
/>
<RadioButton
android:text="Pause for 1 hour"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/radioButton5"
/>
<RadioButton
android:text="Pause for 2 hours"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/radioButton6"
/>
<RadioButton
android:text="Pause until tomorrow"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/radioButton3"
/>
</RadioGroup>
<Switch
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:id="@+id/switch1"
app:layout_constraintStart_toEndOf="@+id/textView" app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="19dp" app:layout_constraintTop_toBottomOf="@+id/add_dialog_title_text2"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,4 +0,0 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/main_menu_source" android:title="@string/main_menu_source_title"/>
<item android:id="@+id/main_menu_website" android:title="@string/main_menu_website_title"/>
</menu>

View file

@ -1,6 +1,10 @@
<menu xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:android="http://schemas.android.com/apk/res/android" > <menu xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/detail_menu_notification" android:title="@string/detail_menu_enable_instant" <item android:id="@+id/detail_menu_notifications_enabled" android:title="@string/detail_menu_notifications_enabled"
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_white_24dp"/> app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_white_24dp"/>
<item android:id="@+id/detail_menu_notifications_disabled_until" android:title="@string/detail_menu_notifications_disabled_forever"
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_time_white_outline_24dp"/>
<item android:id="@+id/detail_menu_notifications_disabled_forever" android:title="@string/detail_menu_notifications_disabled_forever"
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_white_outline_24dp"/>
<item android:id="@+id/detail_menu_enable_instant" android:title="@string/detail_menu_enable_instant" <item android:id="@+id/detail_menu_enable_instant" android:title="@string/detail_menu_enable_instant"
app:showAsAction="ifRoom" android:icon="@drawable/ic_bolt_outline_white_24dp"/> app:showAsAction="ifRoom" android:icon="@drawable/ic_bolt_outline_white_24dp"/>
<item android:id="@+id/detail_menu_disable_instant" android:title="@string/detail_menu_disable_instant" <item android:id="@+id/detail_menu_disable_instant" android:title="@string/detail_menu_disable_instant"

View file

@ -0,0 +1,10 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/main_menu_notifications_enabled" android:title="@string/main_menu_notifications_enabled"
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_white_24dp"/>
<item android:id="@+id/main_menu_notifications_disabled_until" android:title="@string/main_menu_notifications_disabled_forever"
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_time_white_outline_24dp"/>
<item android:id="@+id/main_menu_notifications_disabled_forever" android:title="@string/detail_menu_notifications_disabled_forever"
app:showAsAction="ifRoom" android:icon="@drawable/ic_notifications_off_white_outline_24dp"/>
<item android:id="@+id/main_menu_source" android:title="@string/main_menu_source_title"/>
<item android:id="@+id/main_menu_website" android:title="@string/main_menu_website_title"/>
</menu>

View file

@ -1,16 +0,0 @@
<resources>
<!-- Reply Preference -->
<string-array name="reply_entries">
<item>Forever</item>
<item>30 minutes</item>
<item>1 hour</item>
<item>2 hours</item>
</string-array>
<string-array name="reply_values">
<item>forever</item>
<item>30min</item>
<item>1hr</item>
<item>2h</item>
</string-array>
</resources>

View file

@ -10,8 +10,7 @@
<string name="channel_subscriber_notification_text">You are subscribed to instant delivery topics</string> <string name="channel_subscriber_notification_text">You are subscribed to instant delivery topics</string>
<string name="channel_subscriber_notification_text_one">You are subscribed to one instant delivery topic</string> <string name="channel_subscriber_notification_text_one">You are subscribed to one instant delivery topic</string>
<string name="channel_subscriber_notification_text_two">You are subscribed to two instant delivery topics</string> <string name="channel_subscriber_notification_text_two">You are subscribed to two instant delivery topics</string>
<string name="channel_subscriber_notification_text_three">You are subscribed to three instant delivery topics <string name="channel_subscriber_notification_text_three">You are subscribed to three instant delivery topics</string>
</string>
<string name="channel_subscriber_notification_text_four">You are subscribed to four instant delivery topics</string> <string name="channel_subscriber_notification_text_four">You are subscribed to four instant delivery topics</string>
<string name="channel_subscriber_notification_text_more">You are subscribed to %1$d instant delivery topics</string> <string name="channel_subscriber_notification_text_more">You are subscribed to %1$d instant delivery topics</string>
@ -22,13 +21,17 @@
<!-- Main activity: Action bar --> <!-- Main activity: Action bar -->
<string name="main_action_bar_title">Subscribed topics</string> <string name="main_action_bar_title">Subscribed topics</string>
<string name="main_menu_notifications_enabled">Notifications enabled</string>
<string name="main_menu_notifications_disabled_forever">Notifications disabled</string>
<string name="main_menu_notifications_disabled_until">Notifications disabled until %1$s</string>
<string name="main_menu_source_title">Report a bug</string> <string name="main_menu_source_title">Report a bug</string>
<string name="main_menu_source_url">https://heckel.io/ntfy-android</string> <string name="main_menu_source_url">https://heckel.io/ntfy-android</string>
<string name="main_menu_website_title">Visit ntfy.sh</string> <string name="main_menu_website_title">Visit ntfy.sh</string>
<!-- Main activity: Action mode --> <!-- Main activity: Action mode -->
<string name="main_action_mode_menu_unsubscribe">Unsubscribe</string> <string name="main_action_mode_menu_unsubscribe">Unsubscribe</string>
<string name="main_action_mode_delete_dialog_message">Do you really want to unsubscribe from selected topic(s) and <string name="main_action_mode_delete_dialog_message">
Do you really want to unsubscribe from selected topic(s) and
permanently delete all the messages you received? permanently delete all the messages you received?
</string> </string>
<string name="main_action_mode_delete_dialog_permanently_delete">Permanently delete</string> <string name="main_action_mode_delete_dialog_permanently_delete">Permanently delete</string>
@ -41,7 +44,8 @@
<string name="main_item_date_yesterday">Yesterday</string> <string name="main_item_date_yesterday">Yesterday</string>
<string name="main_add_button_description">Add subscription</string> <string name="main_add_button_description">Add subscription</string>
<string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string> <string name="main_no_subscriptions_text">It looks like you don\'t have any subscriptions yet.</string>
<string name="main_how_to_intro">Click the button below to create or subscribe to a topic. After that, you can send <string name="main_how_to_intro">
Click the button below to create or subscribe to a topic. After that, you can send
messages via PUT or POST and you\'ll receive notifications on your phone. messages via PUT or POST and you\'ll receive notifications on your phone.
</string> </string>
<string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation. <string name="main_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.
@ -49,7 +53,8 @@
<!-- Add dialog --> <!-- Add dialog -->
<string name="add_dialog_title">Subscribe to topic</string> <string name="add_dialog_title">Subscribe to topic</string>
<string name="add_dialog_description_below">Topics are not password-protected, so choose a name that\'s not easy to <string name="add_dialog_description_below">
Topics are not password-protected, so choose a name that\'s not easy to
guess. Once subscribed, you can PUT/POST to receive notifications on your phone. guess. Once subscribed, you can PUT/POST to receive notifications on your phone.
</string> </string>
<string name="add_dialog_topic_name_hint">Topic name, e.g. phils_alerts</string> <string name="add_dialog_topic_name_hint">Topic name, e.g. phils_alerts</string>
@ -68,18 +73,15 @@
<!-- Detail activity --> <!-- Detail activity -->
<string name="detail_no_notifications_text">You haven\'t received any notifications for this topic yet.</string> <string name="detail_no_notifications_text">You haven\'t received any notifications for this topic yet.</string>
<string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL. <string name="detail_how_to_intro">To send notifications to this topic, simply PUT or POST to the topic URL.</string>
</string>
<string name="detail_how_to_example"><![CDATA[ Example (using curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt> ]]></string> <string name="detail_how_to_example"><![CDATA[ Example (using curl):<br/><tt>$ curl -d \"Hi\" %1$s</tt> ]]></string>
<string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation. <string name="detail_how_to_link">For more detailed instructions, check out the ntfy.sh website and documentation.</string>
</string>
<string name="detail_delete_dialog_message">Do you really want to unsubscribe from this topic and delete all of the <string name="detail_delete_dialog_message">Do you really want to unsubscribe from this topic and delete all of the
messages you received? messages you received?
</string> </string>
<string name="detail_delete_dialog_permanently_delete">Permanently delete</string> <string name="detail_delete_dialog_permanently_delete">Permanently delete</string>
<string name="detail_delete_dialog_cancel">Cancel</string> <string name="detail_delete_dialog_cancel">Cancel</string>
<string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s. <string name="detail_test_message">This is a test notification from the Ntfy Android app. It was sent at %1$s.</string>
</string>
<string name="detail_test_message_error">Could not send test message: %1$s</string> <string name="detail_test_message_error">Could not send test message: %1$s</string>
<string name="detail_copied_to_clipboard_message">Copied to clipboard</string> <string name="detail_copied_to_clipboard_message">Copied to clipboard</string>
<string name="detail_instant_delivery_enabled">Instant delivery enabled</string> <string name="detail_instant_delivery_enabled">Instant delivery enabled</string>
@ -87,7 +89,9 @@
<string name="detail_instant_info">Instant delivery cannot be disabled for subscriptions from other servers</string> <string name="detail_instant_info">Instant delivery cannot be disabled for subscriptions from other servers</string>
<!-- Detail activity: Action bar --> <!-- Detail activity: Action bar -->
<string name="detail_menu_notification">Notification</string> <string name="detail_menu_notifications_enabled">Notifications enabled</string>
<string name="detail_menu_notifications_disabled_forever">Notifications disabled</string>
<string name="detail_menu_notifications_disabled_until">Notifications disabled until %1$s</string>
<string name="detail_menu_enable_instant">Enable instant delivery</string> <string name="detail_menu_enable_instant">Enable instant delivery</string>
<string name="detail_menu_disable_instant">Disable instant delivery</string> <string name="detail_menu_disable_instant">Disable instant delivery</string>
<string name="detail_menu_test">Send test notification</string> <string name="detail_menu_test">Send test notification</string>
@ -98,33 +102,22 @@
<!-- Detail activity: Action mode --> <!-- Detail activity: Action mode -->
<string name="detail_action_mode_menu_copy">Copy</string> <string name="detail_action_mode_menu_copy">Copy</string>
<string name="detail_action_mode_menu_delete">Delete</string> <string name="detail_action_mode_menu_delete">Delete</string>
<string name="detail_action_mode_delete_dialog_message">Do you really want to permanently delete the selected <string name="detail_action_mode_delete_dialog_message">Do you really want to permanently delete the selected message(s)?
message(s)?
</string> </string>
<string name="detail_action_mode_delete_dialog_permanently_delete">Permanently delete</string> <string name="detail_action_mode_delete_dialog_permanently_delete">Permanently delete</string>
<string name="detail_action_mode_delete_dialog_cancel">Cancel</string> <string name="detail_action_mode_delete_dialog_cancel">Cancel</string>
<!-- Notification dialog --> <!-- Notification dialog -->
<string name="notification_dialog_title">Notification settings</string> <string name="notification_dialog_title">Pause notifications</string>
<string name="notification_dialog_cancel">Cancel</string> <string name="notification_dialog_cancel">Cancel</string>
<string name="notification_dialog_save">Save</string> <string name="notification_dialog_save">Save</string>
<string name="notification_dialog_enabled_toast_message">Notifications re-enabled</string> <string name="notification_dialog_enabled_toast_message">Notifications re-enabled</string>
<string name="notification_dialog_muted_forever_toast_message">Notifications are now paused</string> <string name="notification_dialog_muted_forever_toast_message">Notifications are now paused</string>
<string name="notification_dialog_muted_until_toast_message">Notifications are now paused until %s</string> <string name="notification_dialog_muted_until_toast_message">Notifications are now paused until %1$s</string>
<string name="notification_dialog_30min">30 minutes</string>
<string name="notification_dialog_1h">1 hour</string>
<!-- Preference Titles --> <string name="notification_dialog_2h">2 hours</string>
<string name="subscription_settings_notifications_header">Notifications</string> <string name="notification_dialog_8h">8 hours</string>
<string name="subscription_settings_pause_title">Pause notifications</string> <string name="notification_dialog_tomorrow">Until tomorrow</string>
<string name="subscription_settings_pause_for_title">Until …</string> <string name="notification_dialog_forever">Forever</string>
<string name="sync_header">Sync</string>
<!-- Messages Preferences -->
<string name="signature_title">Your signature</string>
<!-- Sync Preferences -->
<string name="attachment_title">Download incoming attachments</string>
<string name="attachment_summary_on">Automatically download attachments for incoming emails</string>
<string name="attachment_summary_off">Only download attachments when manually requested</string>
</resources> </resources>

View file

@ -1,23 +0,0 @@
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
app:title="@string/subscription_settings_notifications_header">
<SwitchPreferenceCompat
app:key="pause"
app:title="@string/subscription_settings_pause_title"/>
<ListPreference
app:key="reply"
app:title="@string/subscription_settings_pause_for_title"
app:entries="@array/reply_entries"
app:entryValues="@array/reply_values"
app:defaultValue="false"
app:useSimpleSummaryProvider="true"
app:dependency="pause"
/>
</PreferenceCategory>
</PreferenceScreen>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 280 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/></svg>

After

Width:  |  Height:  |  Size: 366 B

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="#000000"
version="1.1"
id="svg870"
sodipodi:docname="notifications_off_black_outline_24dp.svg"
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs874" />
<sodipodi:namedview
id="namedview872"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="19.563079"
inkscape:cx="11.884632"
inkscape:cy="18.606478"
inkscape:window-width="1863"
inkscape:window-height="1025"
inkscape:window-x="57"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg870" />
<path
d="M0 0h24v24H0V0z"
fill="none"
id="path866" />
<path
id="path868"
d="M 12 2.5 C 11.17 2.5 10.5 3.17 10.5 4 L 10.5 4.6796875 C 9.5560271 4.9041286 8.7489952 5.336721 8.0859375 5.921875 L 9.5273438 7.3671875 C 10.17483 6.8242961 11.007683 6.5 12 6.5 C 14.49 6.5 16 8.52 16 11 L 16 13.855469 L 18 15.861328 L 18 11 C 18 7.93 16.37 5.3596875 13.5 4.6796875 L 13.5 4 C 13.5 3.17 12.83 2.5 12 2.5 z M 6.7714844 7.6289062 C 6.2688257 8.6105696 6 9.7619884 6 11 L 6 16 L 4 18 L 4 19 L 18.117188 19 L 16 16.878906 L 16 17 L 8 17 L 8 11 C 8 10.347635 8.1073172 9.7282789 8.3066406 9.1679688 L 6.7714844 7.6289062 z M 10 20 C 10 21.1 10.9 22 12 22 C 13.1 22 14 21.1 14 20 L 10 20 z " />
<path
style="color:#000000;fill:#000000;-inkscape-stroke:none"
d="M 3.5429688,3.3964844 2.03125,4.9042969 19.523437,22.439453 21.035156,20.931641 Z"
id="path989" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="#000000"
version="1.1"
id="svg870"
sodipodi:docname="notifications_off_time_black_outline_24dp.svg"
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs874" />
<sodipodi:namedview
id="namedview872"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="17.160655"
inkscape:cx="17.977169"
inkscape:cy="25.989683"
inkscape:window-width="1863"
inkscape:window-height="1025"
inkscape:window-x="57"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg870" />
<path
d="M0 0h24v24H0V0z"
fill="none"
id="path866" />
<path
id="path868"
style="stroke:none;stroke-opacity:1;fill:#000000;fill-opacity:1"
d="M 12 2.5 C 11.17 2.5 10.5 3.17 10.5 4 L 10.5 4.6796875 C 9.5560271 4.9041286 8.7489952 5.336721 8.0859375 5.921875 L 9.5273438 7.3671875 C 10.17483 6.8242961 11.007683 6.5 12 6.5 C 14.176573 6.5 15.602785 8.0428914 15.927734 10.087891 A 6.6092234 6.6092234 0 0 1 16.501953 10.037109 A 6.6092234 6.6092234 0 0 1 17.960938 10.203125 C 17.70244 7.4927147 16.117858 5.2999464 13.5 4.6796875 L 13.5 4 C 13.5 3.17 12.83 2.5 12 2.5 z M 3.5429688 3.3964844 L 2.03125 4.9042969 L 6.2265625 9.109375 C 6.0792507 9.7072066 6 10.340437 6 11 L 6 16 L 4 18 L 4 19 L 10.333984 19 A 6.6092234 6.6092234 0 0 1 9.9238281 17 L 8 17 L 8 11 C 8 10.96369 8.0032442 10.928679 8.0039062 10.892578 L 10.673828 13.568359 A 6.6092234 6.6092234 0 0 1 11.982422 11.855469 L 3.5429688 3.3964844 z " />
<path
id="path2323"
style="color:#000000;fill:#000000;stroke-width:0.901647;-inkscape-stroke:none"
d="m 16.85531,10.774346 c -3.310895,0 -6.001953,2.69551 -6.001953,6.005859 0,3.310352 2.691058,6.007813 6.001953,6.007813 3.316009,0 6.011719,-2.696918 6.011719,-6.007813 0,-3.310893 -2.69571,-6.005859 -6.011719,-6.005859 z m 0.0039,2.011719 c 2.212368,0 3.99414,1.781774 3.99414,3.99414 0,2.212369 -1.781772,3.994141 -3.99414,3.994141 -2.212368,0 -3.994141,-1.781772 -3.994141,-3.994141 0,-2.212367 1.781773,-3.99414 3.994141,-3.99414 z" />
<g
id="g2329"
transform="translate(0.08197986,0.02172169)"
style="fill:#000000">
<path
style="color:#000000;fill:#000000;stroke-width:0.901647;-inkscape-stroke:none"
d="M 16.833417,13.855469 H 16 v 3.333667 l 2.916958,1.750175 0.416708,-0.683401 -2.50025,-1.483483 z"
id="path2325" />
<path
style="color:#000000;fill:#000000;-inkscape-stroke:none"
d="m 15.548828,13.404297 v 4.041015 l 3.519531,2.111329 0.888672,-1.455079 -2.671875,-1.585937 v -3.111328 h -0.451172 z"
id="path2327" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/><path d="M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>

After

Width:  |  Height:  |  Size: 352 B