diff --git a/app/build.gradle b/app/build.gradle
index a2d4e1a0..0526456b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -18,8 +18,8 @@ android {
minSdkVersion 21
targetSdkVersion 35
- versionCode 41
- versionName "1.17.8"
+ versionCode 48
+ versionName "1.19.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -53,10 +53,12 @@ android {
play {
buildConfigField 'boolean', 'FIREBASE_AVAILABLE', 'true'
buildConfigField 'boolean', 'RATE_APP_AVAILABLE', 'true'
+ buildConfigField 'boolean', 'PAYMENT_LINKS_AVAILABLE', 'false' // Google Play Payments Policy, see #1463
}
fdroid {
buildConfigField 'boolean', 'FIREBASE_AVAILABLE', 'false'
buildConfigField 'boolean', 'RATE_APP_AVAILABLE', 'false'
+ buildConfigField 'boolean', 'PAYMENT_LINKS_AVAILABLE', 'true'
}
}
@@ -112,7 +114,7 @@ dependencies {
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
// Material design
- implementation "com.google.android.material:material:1.9.0"
+ implementation "com.google.android.material:material:1.13.0"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.1"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1a11b210..46b5eab3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -87,6 +87,15 @@
android:exported="false">
+
+
+
+
+
+
+
+
+
+ if (menuItem.itemId == R.id.add_dialog_action_button) {
+ onActionButtonClick()
+ true
+ } else {
+ false
+ }
+ }
+ actionMenuItem = toolbar.menu.findItem(R.id.add_dialog_action_button)
+
// Main "pages"
subscribeView = view.findViewById(R.id.add_dialog_subscribe_view)
subscribeView.visibility = View.VISIBLE
@@ -136,6 +153,19 @@ class AddFragment : DialogFragment() {
}
}
+ // Subscribe view validation
+ val subscribeTextWatcher = AfterChangedTextWatcher {
+ validateInputSubscribeView()
+ }
+ subscribeTopicText.addTextChangedListener(subscribeTextWatcher)
+ subscribeBaseUrlText.addTextChangedListener(subscribeTextWatcher)
+ subscribeInstantDeliveryCheckbox.setOnCheckedChangeListener { _, _ ->
+ validateInputSubscribeView()
+ }
+ subscribeUseAnotherServerCheckbox.setOnCheckedChangeListener { _, _ ->
+ validateInputSubscribeView()
+ }
+
// Username/password validation on type
val loginTextWatcher = AfterChangedTextWatcher {
validateInputLoginView()
@@ -144,51 +174,36 @@ class AddFragment : DialogFragment() {
loginPasswordText.addTextChangedListener(loginTextWatcher)
// Build dialog
- val dialog = AlertDialog.Builder(activity)
- .setView(view)
- .setPositiveButton(R.string.add_dialog_button_subscribe) { _, _ ->
- // This will be overridden below to avoid closing the dialog immediately
- }
- .setNegativeButton(R.string.add_dialog_button_cancel) { _, _ ->
- // This will be overridden below
- }
- .create()
+ val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog)
+ dialog.setContentView(view)
- // Show keyboard when the dialog is shown (see https://stackoverflow.com/a/19573049/1440785)
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
-
- // Add logic to disable "Subscribe" button on invalid input
- dialog.setOnShowListener {
- positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
- positiveButton.isEnabled = false
- positiveButton.setOnClickListener {
- positiveButtonClick()
- }
- negativeButton = dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
- negativeButton.setOnClickListener {
- negativeButtonClick()
- }
- val subscribeTextWatcher = AfterChangedTextWatcher {
- validateInputSubscribeView()
- }
- subscribeTopicText.addTextChangedListener(subscribeTextWatcher)
- subscribeBaseUrlText.addTextChangedListener(subscribeTextWatcher)
- subscribeInstantDeliveryCheckbox.setOnCheckedChangeListener { _, _ ->
- validateInputSubscribeView()
- }
- subscribeUseAnotherServerCheckbox.setOnCheckedChangeListener { _, _ ->
- validateInputSubscribeView()
- }
- validateInputSubscribeView()
-
- // Focus topic text (keyboard is shown too, see above)
- subscribeTopicText.requestFocus()
- }
+ // Initial validation
+ validateInputSubscribeView()
return dialog
}
- private fun positiveButtonClick() {
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.apply {
+ setLayout(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Show keyboard after the dialog is fully visible
+ subscribeTopicText.postDelayed({
+ subscribeTopicText.requestFocus()
+ val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+ imm?.showSoftInput(subscribeTopicText, InputMethodManager.SHOW_FORCED)
+ }, 200)
+ }
+
+ private fun onActionButtonClick() {
val topic = subscribeTopicText.text.toString()
val baseUrl = getBaseUrl()
if (subscribeView.visibility == View.VISIBLE) {
@@ -280,16 +295,8 @@ class AddFragment : DialogFragment() {
}
}
- private fun negativeButtonClick() {
- if (subscribeView.visibility == View.VISIBLE) {
- dialog?.cancel()
- } else if (loginView.visibility == View.VISIBLE) {
- showSubscribeView()
- }
- }
-
private fun validateInputSubscribeView() {
- if (!this::positiveButton.isInitialized) return // As per crash seen in Google Play
+ if (!this::actionMenuItem.isInitialized) return // As per crash seen in Google Play
// Show/hide things: This logic is intentionally kept simple. Do not simplify "just because it's pretty".
val instantToggleAllowed = if (!BuildConfig.FIREBASE_AVAILABLE) {
@@ -327,11 +334,11 @@ class AddFragment : DialogFragment() {
activity?.let {
it.runOnUiThread {
if (subscription != null || DISALLOWED_TOPICS.contains(topic)) {
- positiveButton.isEnabled = false
+ actionMenuItem.isEnabled = false
} else if (subscribeUseAnotherServerCheckbox.isChecked) {
- positiveButton.isEnabled = validTopic(topic) && validUrl(baseUrl)
+ actionMenuItem.isEnabled = validTopic(topic) && validUrl(baseUrl)
} else {
- positiveButton.isEnabled = validTopic(topic)
+ actionMenuItem.isEnabled = validTopic(topic)
}
}
}
@@ -339,13 +346,13 @@ class AddFragment : DialogFragment() {
}
private fun validateInputLoginView() {
- if (!this::positiveButton.isInitialized || !this::loginUsernameText.isInitialized || !this::loginPasswordText.isInitialized) {
+ if (!this::actionMenuItem.isInitialized || !this::loginUsernameText.isInitialized || !this::loginPasswordText.isInitialized) {
return // As per crash seen in Google Play
}
if (loginUsernameText.visibility == View.GONE) {
- positiveButton.isEnabled = true
+ actionMenuItem.isEnabled = true
} else {
- positiveButton.isEnabled = (loginUsernameText.text?.isNotEmpty() ?: false)
+ actionMenuItem.isEnabled = (loginUsernameText.text?.isNotEmpty() ?: false)
&& (loginPasswordText.text?.isNotEmpty() ?: false)
}
}
@@ -372,8 +379,11 @@ class AddFragment : DialogFragment() {
private fun showSubscribeView() {
resetSubscribeView()
- positiveButton.text = getString(R.string.add_dialog_button_subscribe)
- negativeButton.text = getString(R.string.add_dialog_button_cancel)
+ toolbar.setTitle(R.string.add_dialog_title)
+ actionMenuItem.setTitle(R.string.add_dialog_button_subscribe)
+ toolbar.setNavigationOnClickListener {
+ dismiss()
+ }
loginView.visibility = View.GONE
subscribeView.visibility = View.VISIBLE
if (subscribeTopicText.requestFocus()) {
@@ -385,8 +395,11 @@ class AddFragment : DialogFragment() {
private fun showLoginView(activity: Activity) {
resetLoginView()
loginProgress.visibility = View.INVISIBLE
- positiveButton.text = getString(R.string.add_dialog_button_login)
- negativeButton.text = getString(R.string.add_dialog_button_back)
+ toolbar.setTitle(R.string.add_dialog_login_title)
+ actionMenuItem.setTitle(R.string.add_dialog_button_login)
+ toolbar.setNavigationOnClickListener {
+ showSubscribeView()
+ }
subscribeView.visibility = View.GONE
loginView.visibility = View.VISIBLE
if (loginUsernameText.requestFocus()) {
@@ -400,7 +413,7 @@ class AddFragment : DialogFragment() {
subscribeBaseUrlText.isEnabled = enable
subscribeInstantDeliveryCheckbox.isEnabled = enable
subscribeUseAnotherServerCheckbox.isEnabled = enable
- positiveButton.isEnabled = enable
+ actionMenuItem.isEnabled = enable
}
private fun resetSubscribeView() {
@@ -413,7 +426,7 @@ class AddFragment : DialogFragment() {
private fun enableLoginView(enable: Boolean) {
loginUsernameText.isEnabled = enable
loginPasswordText.isEnabled = enable
- positiveButton.isEnabled = enable
+ actionMenuItem.isEnabled = enable
if (enable && loginUsernameText.requestFocus()) {
val imm = activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(loginUsernameText, InputMethodManager.SHOW_IMPLICIT)
diff --git a/app/src/main/java/io/heckel/ntfy/ui/BasePreferenceFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/BasePreferenceFragment.kt
new file mode 100644
index 00000000..730827f5
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/ui/BasePreferenceFragment.kt
@@ -0,0 +1,62 @@
+package io.heckel.ntfy.ui
+
+import android.widget.TextView
+import androidx.preference.EditTextPreference
+import androidx.preference.ListPreference
+import androidx.preference.Preference
+import androidx.preference.PreferenceFragmentCompat
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.textfield.TextInputEditText
+import io.heckel.ntfy.R
+
+abstract class BasePreferenceFragment : PreferenceFragmentCompat() {
+ /**
+ * Show [ListPreference] and [EditTextPreference] dialog by [MaterialAlertDialogBuilder]
+ */
+ override fun onDisplayPreferenceDialog(preference: Preference) {
+ when (preference) {
+ is ListPreference -> {
+ val prefIndex = preference.entryValues.indexOf(preference.value)
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(preference.title)
+ .setSingleChoiceItems(preference.entries, prefIndex) { dialog, index ->
+ val newValue = preference.entryValues[index].toString()
+ if (preference.callChangeListener(newValue)) {
+ preference.value = newValue
+ }
+ dialog.dismiss()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+ is EditTextPreference -> {
+ val view = layoutInflater.inflate(R.layout.preference_dialog_edittext_edited, null)
+ var message = ""
+ var hint = ""
+ if (preference.extras.getString("message") != null) {
+ message = preference.extras.getString("message")!!
+ }
+ if (preference.extras.getString("hint") != null) {
+ hint = preference.extras.getString("hint")!!
+ }
+ val messageView = view.findViewById(android.R.id.message)
+ messageView.text = message
+ val editText = view.findViewById(android.R.id.edit)
+ editText.setText(preference.text.toString())
+ editText.hint = hint
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(preference.title)
+ .setView(view)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ val newValue = editText.text.toString()
+ if (preference.callChangeListener(newValue)) {
+ preference.text = newValue
+ }
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+ else -> super.onDisplayPreferenceDialog(preference)
+ }
+ }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/Colors.kt b/app/src/main/java/io/heckel/ntfy/ui/Colors.kt
index 47e8e164..8363360e 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/Colors.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/Colors.kt
@@ -1,48 +1,93 @@
package io.heckel.ntfy.ui
import android.content.Context
+import android.graphics.Color
+import android.os.Build
import androidx.core.content.ContextCompat
+import com.google.android.material.color.MaterialColors
import io.heckel.ntfy.R
import io.heckel.ntfy.util.isDarkThemeOn
class Colors {
companion object {
- val refreshProgressIndicator = R.color.teal
+ fun primary(context: Context): Int {
+ return MaterialColors.getColor(context, R.attr.colorPrimary, Color.GREEN)
+ }
+
+ fun onPrimary(context: Context): Int {
+ return MaterialColors.getColor(context, R.attr.colorOnPrimary, Color.GREEN)
+ }
fun notificationIcon(context: Context): Int {
- return if (isDarkThemeOn(context)) R.color.teal_light else R.color.teal
+ return MaterialColors.getColor(context, R.attr.colorPrimary, Color.GREEN)
+ }
+
+ fun linkColor(context: Context): Int {
+ return MaterialColors.getColor(context, R.attr.colorPrimary, Color.GREEN)
}
fun itemSelectedBackground(context: Context): Int {
- return if (isDarkThemeOn(context)) R.color.black_800b else R.color.gray_400
- }
-
- fun cardBackground(context: Context): Int {
- return if (isDarkThemeOn(context)) R.color.black_800b else R.color.white
- }
-
- fun cardSelectedBackground(context: Context): Int {
- return if (isDarkThemeOn(context)) R.color.black_700b else R.color.gray_500
+ return ContextCompat.getColor(context, R.color.md_theme_surfaceContainerHigh)
}
fun cardBackgroundColor(context: Context): Int {
- return ContextCompat.getColor(context, cardBackground(context))
+ return if (isDarkThemeOn(context)) {
+ MaterialColors.getColor(context, R.attr.colorSurfaceContainer, Color.GRAY)
+ } else {
+ MaterialColors.getColor(context, R.attr.colorSurface, Color.WHITE)
+ }
}
fun cardSelectedBackgroundColor(context: Context): Int {
- return ContextCompat.getColor(context, cardSelectedBackground(context))
+ return if (isDarkThemeOn(context)) {
+ MaterialColors.getColor(context, R.attr.colorSurfaceContainerHigh, Color.GRAY)
+ } else {
+ MaterialColors.getColor(context, R.attr.colorSurfaceContainerHighest, Color.GRAY)
+ }
}
- fun statusBarNormal(context: Context): Int {
- return if (isDarkThemeOn(context)) R.color.black_900 else R.color.teal
+ fun statusBarNormal(context: Context, dynamicColors: Boolean, darkMode: Boolean): Int {
+ val default = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ context.resources.getColor(R.color.action_bar, null)
+ } else {
+ @Suppress("DEPRECATION")
+ context.resources.getColor(R.color.action_bar)
+ }
+ return if (dynamicColors) {
+ // Use colorSurface for both light and dark mode when dynamic colors are enabled
+ MaterialColors.getColor(context, R.attr.colorSurface, default)
+ } else {
+ default
+ }
}
- fun statusBarActionMode(context: Context): Int {
- return if (isDarkThemeOn(context)) R.color.black_900 else R.color.teal_dark
+ fun shouldUseLightStatusBar(dynamicColors: Boolean, darkMode: Boolean): Boolean {
+ // Use light status bar (dark icons) when dynamic colors are enabled in light mode
+ return dynamicColors && !darkMode
+ }
+
+ fun toolbarTextColor(context: Context, dynamicColors: Boolean, darkMode: Boolean): Int {
+ return if (dynamicColors) {
+ // Use colorOnSurface (dark on light, light on dark) when dynamic colors are enabled
+ MaterialColors.getColor(context, R.attr.colorOnSurface, Color.BLACK)
+ } else {
+ if (darkMode) {
+ // In dark mode, toolbar is gray (surfaceContainer), so use light text
+ MaterialColors.getColor(context, R.attr.colorOnSurface, Color.WHITE)
+ } else {
+ // In light mode, toolbar is teal (primary), so use white text
+ MaterialColors.getColor(context, R.attr.colorOnPrimary, Color.WHITE)
+ }
+ }
}
fun dangerText(context: Context): Int {
- return if (isDarkThemeOn(context)) R.color.red_light else R.color.red_dark
+ return MaterialColors.getColor(context, R.attr.colorError, Color.RED)
+ }
+
+ fun swipeToRefreshColor(context: Context): Int {
+ return MaterialColors.getColor(context, R.attr.colorPrimary, Color.GREEN)
}
}
}
+
diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
index bf27fcb9..9655de35 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
@@ -10,7 +10,6 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.Html
-import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.View
@@ -18,11 +17,15 @@ import android.widget.TextView
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.view.ActionMode
import androidx.core.content.ContextCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
@@ -31,17 +34,30 @@ import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.Subscription
import io.heckel.ntfy.firebase.FirebaseMessenger
-import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationService
import io.heckel.ntfy.service.SubscriberServiceManager
-import io.heckel.ntfy.util.*
-import kotlinx.coroutines.*
-import java.util.*
+import io.heckel.ntfy.util.Log
+import io.heckel.ntfy.util.copyToClipboard
+import io.heckel.ntfy.util.dangerButton
+import io.heckel.ntfy.util.decodeMessage
+import io.heckel.ntfy.util.displayName
+import io.heckel.ntfy.util.formatDateShort
+import io.heckel.ntfy.util.isDarkThemeOn
+import io.heckel.ntfy.util.randomSubscriptionId
+import io.heckel.ntfy.util.topicShortUrl
+import io.heckel.ntfy.util.topicUrl
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.util.Date
import kotlin.random.Random
+import androidx.core.view.size
+import androidx.core.view.get
-
-class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFragment.NotificationSettingsListener {
+class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSettingsListener {
private val viewModel by viewModels {
DetailViewModelFactory((application as Application).repository)
}
@@ -67,6 +83,36 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
// Action mode stuff
private var actionMode: ActionMode? = null
+ private val actionModeCallback = object : ActionMode.Callback {
+ override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
+ actionMode = mode
+ if (mode != null) {
+ mode.menuInflater.inflate(R.menu.menu_detail_action_mode, menu)
+ mode.title = "1" // One item selected
+ }
+ return true
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
+
+ override fun onActionItemClicked(mode: ActionMode?, item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.detail_action_mode_copy -> {
+ onMultiCopyClick()
+ true
+ }
+ R.id.detail_action_mode_delete -> {
+ onMultiDeleteClick()
+ true
+ }
+ else -> false
+ }
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode?) {
+ endActionModeAndRedraw()
+ }
+ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -78,9 +124,47 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
notifier = NotificationService(this)
appBaseUrl = getString(R.string.app_base_url)
+ val toolbarLayout = findViewById(R.id.app_bar_drawer)
+ val dynamicColors = repository.getDynamicColorsEnabled()
+ val darkMode = isDarkThemeOn(this)
+ val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode)
+ val toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode)
+ toolbarLayout.setBackgroundColor(statusBarColor)
+
+ val toolbar = toolbarLayout.findViewById(R.id.toolbar)
+ toolbar.setTitleTextColor(toolbarTextColor)
+ toolbar.setNavigationIconTint(toolbarTextColor)
+ toolbar.overflowIcon?.setTint(toolbarTextColor)
+ setSupportActionBar(toolbar)
+
+ // Set system status bar color and appearance
+ window.statusBarColor = statusBarColor
+ WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars =
+ Colors.shouldUseLightStatusBar(dynamicColors, darkMode)
+
+ // Set detail activity background: use theme background for dynamic colors, static gray for non-dynamic
+ val detailContentLayout = findViewById(R.id.detail_content_layout)
+ if (repository.getDynamicColorsEnabled()) {
+ detailContentLayout.setBackgroundColor(
+ com.google.android.material.color.MaterialColors.getColor(
+ this,
+ android.R.attr.colorBackground,
+ ContextCompat.getColor(this, R.color.detail_activity_background)
+ )
+ )
+ } else {
+ detailContentLayout.setBackgroundColor(
+ ContextCompat.getColor(this, R.color.detail_activity_background)
+ )
+ }
+
// Show 'Back' button
supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ // Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463
+ val howToLink = findViewById(R.id.detail_how_to_link)
+ howToLink.isVisible = BuildConfig.PAYMENT_LINKS_AVAILABLE
+
// Handle direct deep links to topic "ntfy://..."
val url = intent?.data
if (intent?.action == ACTION_VIEW && url != null) {
@@ -152,7 +236,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
- intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(subscription))
+ intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(appBaseUrl, subscription))
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
intent.putExtra(MainActivity.EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil)
@@ -190,7 +274,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
// Swipe to refresh
mainListContainer = findViewById(R.id.detail_notification_list_container)
mainListContainer.setOnRefreshListener { refresh() }
- mainListContainer.setColorSchemeResources(Colors.refreshProgressIndicator)
+ mainListContainer.setColorSchemeColors(Colors.swipeToRefreshColor(this))
// Update main list based on viewModel (& its datasource/livedata)
val noEntriesText: View = findViewById(R.id.detail_no_notifications)
@@ -277,10 +361,11 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
val subscription = repository.getSubscription(subscriptionId) ?: return@launch
subscriptionInstant = subscription.instant
subscriptionMutedUntil = subscription.mutedUntil
- subscriptionDisplayName = displayName(subscription)
+ subscriptionDisplayName = displayName(appBaseUrl, subscription)
showHideInstantMenuItems(subscriptionInstant)
showHideMutedUntilMenuItems(subscriptionMutedUntil)
+ showHideCopyMenuItems(subscription.baseUrl)
updateTitle(subscriptionDisplayName)
}
}
@@ -312,10 +397,17 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_detail_action_bar, menu)
this.menu = menu
+
+ // Tint menu icons based on theme
+ val toolbarTextColor = Colors.toolbarTextColor(this, repository.getDynamicColorsEnabled(), isDarkThemeOn(this))
+ for (i in 0 until menu.size) {
+ menu[i].icon?.setTint(toolbarTextColor)
+ }
// Show and hide buttons
showHideInstantMenuItems(subscriptionInstant)
showHideMutedUntilMenuItems(subscriptionMutedUntil)
+ showHideCopyMenuItems(subscriptionBaseUrl)
// Regularly check if "notification muted" time has passed
// NOTE: This is done here, because then we know that we've initialized the menu items.
@@ -559,6 +651,18 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
}
}
+
+ private fun showHideCopyMenuItems(subscriptionBaseUrl: String) {
+ if (!this::menu.isInitialized) {
+ return
+ }
+ runOnUiThread {
+ // Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463
+ val copyUrlItem = menu.findItem(R.id.detail_menu_copy_url)
+ copyUrlItem?.isVisible = appBaseUrl != subscriptionBaseUrl || BuildConfig.PAYMENT_LINKS_AVAILABLE
+ }
+ }
+
private fun updateTitle(subscriptionDisplayName: String) {
runOnUiThread {
title = subscriptionDisplayName
@@ -568,8 +672,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
private fun onClearClick() {
Log.d(TAG, "Clearing all notifications for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
- val builder = AlertDialog.Builder(this)
- val dialog = builder
+ val dialog = MaterialAlertDialogBuilder(this)
.setMessage(R.string.detail_clear_dialog_message)
.setPositiveButton(R.string.detail_clear_dialog_permanently_delete) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
@@ -600,8 +703,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
private fun onDeleteClick() {
Log.d(TAG, "Deleting subscription ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}")
- val builder = AlertDialog.Builder(this)
- val dialog = builder
+ val dialog = MaterialAlertDialogBuilder(this)
.setMessage(R.string.detail_delete_dialog_message)
.setPositiveButton(R.string.detail_delete_dialog_permanently_delete) { _, _ ->
Log.d(TAG, "Deleting subscription with subscription ID $subscriptionId (topic: $subscriptionTopic)")
@@ -664,33 +766,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
}
}
- override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
- this.actionMode = mode
- if (mode != null) {
- mode.menuInflater.inflate(R.menu.menu_detail_action_mode, menu)
- mode.title = "1" // One item selected
- }
- return true
- }
-
- override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
- return false
- }
-
- override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
- return when (item?.itemId) {
- R.id.detail_action_mode_copy -> {
- onMultiCopyClick()
- true
- }
- R.id.detail_action_mode_delete -> {
- onMultiDeleteClick()
- true
- }
- else -> false
- }
- }
-
private fun onMultiCopyClick() {
Log.d(TAG, "Copying multiple notifications to clipboard")
@@ -716,8 +791,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
private fun onMultiDeleteClick() {
Log.d(TAG, "Showing multi-delete dialog for selected items")
- val builder = AlertDialog.Builder(this)
- val dialog = builder
+ val dialog = MaterialAlertDialogBuilder(this)
.setMessage(R.string.detail_action_mode_delete_dialog_message)
.setPositiveButton(R.string.detail_action_mode_delete_dialog_permanently_delete) { _, _ ->
adapter.selected.map { notificationId -> viewModel.markAsDeleted(notificationId) }
@@ -735,18 +809,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
dialog.show()
}
- override fun onDestroyActionMode(mode: ActionMode?) {
- endActionModeAndRedraw()
- }
-
private fun beginActionMode(notification: Notification) {
- actionMode = startActionMode(this)
+ actionMode = startSupportActionMode(actionModeCallback)
adapter.toggleSelection(notification.id)
-
- // Fade status bar color
- val fromColor = ContextCompat.getColor(this, Colors.statusBarNormal(this))
- val toColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this))
- fadeStatusBarColor(window, fromColor, toColor)
}
private fun finishActionMode() {
@@ -758,11 +823,6 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
actionMode = null
adapter.selected.clear()
adapter.notifyItemRangeChanged(0, adapter.currentList.size)
-
- // Fade status bar color
- val fromColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this))
- val toColor = ContextCompat.getColor(this, Colors.statusBarNormal(this))
- fadeStatusBarColor(window, fromColor, toColor)
}
companion object {
diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt
index ccc0191d..e012ba04 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt
@@ -15,9 +15,11 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toDrawable
+import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import androidx.preference.*
import androidx.preference.Preference.OnPreferenceClickListener
+import com.google.android.material.appbar.AppBarLayout
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository
@@ -63,6 +65,24 @@ class DetailSettingsActivity : AppCompatActivity() {
.commit()
}
+ val toolbarLayout = findViewById(R.id.app_bar_drawer)
+ val dynamicColors = repository.getDynamicColorsEnabled()
+ val darkMode = isDarkThemeOn(this)
+ val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode)
+ val toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode)
+ toolbarLayout.setBackgroundColor(statusBarColor)
+
+ val toolbar = toolbarLayout.findViewById(R.id.toolbar)
+ toolbar.setTitleTextColor(toolbarTextColor)
+ toolbar.setNavigationIconTint(toolbarTextColor)
+ toolbar.overflowIcon?.setTint(toolbarTextColor)
+ setSupportActionBar(toolbar)
+
+ // Set system status bar color and appearance
+ window.statusBarColor = statusBarColor
+ WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars =
+ Colors.shouldUseLightStatusBar(dynamicColors, darkMode)
+
// Title
val displayName = intent.getStringExtra(DetailActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME) ?: return
title = displayName
@@ -87,6 +107,7 @@ class DetailSettingsActivity : AppCompatActivity() {
private lateinit var openChannelsPref: Preference
private lateinit var iconSetLauncher: ActivityResultLauncher
private lateinit var iconRemovePref: Preference
+ private lateinit var appBaseUrl: String
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.detail_preferences, rootKey)
@@ -96,6 +117,7 @@ class DetailSettingsActivity : AppCompatActivity() {
serviceManager = SubscriberServiceManager(requireActivity())
notificationService = NotificationService(requireActivity())
resolver = requireContext().applicationContext.contentResolver
+ appBaseUrl = requireContext().getString(R.string.app_base_url)
// Create result launcher for custom icon (must be created in onCreatePreferences() directly)
iconSetLauncher = createIconPickLauncher()
@@ -137,7 +159,7 @@ class DetailSettingsActivity : AppCompatActivity() {
private fun loadInstantPref() {
val appBaseUrl = getString(R.string.app_base_url)
val prefId = context?.getString(R.string.detail_settings_notifications_instant_key) ?: return
- val pref: SwitchPreference? = findPreference(prefId)
+ val pref: SwitchPreferenceCompat? = findPreference(prefId)
pref?.isVisible = BuildConfig.FIREBASE_AVAILABLE && subscription.baseUrl == appBaseUrl
pref?.isChecked = subscription.instant
pref?.preferenceDataStore = object : PreferenceDataStore() {
@@ -148,7 +170,7 @@ class DetailSettingsActivity : AppCompatActivity() {
return subscription.instant
}
}
- pref?.summaryProvider = Preference.SummaryProvider { preference ->
+ pref?.summaryProvider = Preference.SummaryProvider { preference ->
if (preference.isChecked) {
getString(R.string.detail_settings_notifications_instant_summary_on)
} else {
@@ -159,7 +181,7 @@ class DetailSettingsActivity : AppCompatActivity() {
private fun loadDedicatedChannelsPrefs() {
val prefId = context?.getString(R.string.detail_settings_notifications_dedicated_channels_key) ?: return
- val pref: SwitchPreference? = findPreference(prefId)
+ val pref: SwitchPreferenceCompat? = findPreference(prefId)
pref?.isVisible = true
pref?.isChecked = subscription.dedicatedChannels
pref?.preferenceDataStore = object : PreferenceDataStore() {
@@ -176,7 +198,7 @@ class DetailSettingsActivity : AppCompatActivity() {
return subscription.dedicatedChannels
}
}
- pref?.summaryProvider = Preference.SummaryProvider { preference ->
+ pref?.summaryProvider = Preference.SummaryProvider { preference ->
if (preference.isChecked) {
getString(R.string.detail_settings_notifications_dedicated_channels_summary_on)
} else {
@@ -381,7 +403,7 @@ class DetailSettingsActivity : AppCompatActivity() {
save(newSubscription)
// Update activity title
activity?.runOnUiThread {
- activity?.title = displayName(newSubscription)
+ activity?.title = displayName(appBaseUrl, newSubscription)
}
// Update dedicated notification channel
if (newSubscription.dedicatedChannels) {
@@ -394,9 +416,10 @@ class DetailSettingsActivity : AppCompatActivity() {
}
pref?.summaryProvider = Preference.SummaryProvider { provider ->
if (TextUtils.isEmpty(provider.text)) {
+ val appBaseUrl = context?.getString(R.string.app_base_url)
getString(
R.string.detail_settings_appearance_display_name_default_summary,
- displayName(subscription)
+ displayName(appBaseUrl, subscription)
)
} else {
provider.text
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
index 14ffa80a..6d12f424 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt
@@ -14,7 +14,6 @@ import android.os.Bundle
import android.provider.Settings
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.text.method.LinkMovementMethod
-import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.View
@@ -24,12 +23,25 @@ import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
+import androidx.appcompat.view.ActionMode
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
+import androidx.core.content.res.ResourcesCompat
+import androidx.core.text.HtmlCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
-import androidx.work.*
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.floatingactionbutton.FloatingActionButton
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
@@ -43,17 +55,28 @@ import io.heckel.ntfy.msg.DownloadType
import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.service.SubscriberServiceManager
-import io.heckel.ntfy.util.*
+import io.heckel.ntfy.util.Log
+import io.heckel.ntfy.util.dangerButton
+import io.heckel.ntfy.util.displayName
+import io.heckel.ntfy.util.formatDateShort
+import io.heckel.ntfy.util.isDarkThemeOn
+import io.heckel.ntfy.util.isIgnoringBatteryOptimizations
+import io.heckel.ntfy.util.maybeSplitTopicUrl
+import io.heckel.ntfy.util.randomSubscriptionId
+import io.heckel.ntfy.util.shortUrl
+import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.work.DeleteWorker
import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
-import java.util.*
+import java.util.Date
import java.util.concurrent.TimeUnit
import kotlin.random.Random
+import androidx.core.view.size
+import androidx.core.view.get
-class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.SubscribeListener, NotificationFragment.NotificationSettingsListener {
+class MainActivity : AppCompatActivity(), AddFragment.SubscribeListener, NotificationFragment.NotificationSettingsListener {
private val viewModel by viewModels {
SubscriptionsViewModelFactory((application as Application).repository)
}
@@ -69,11 +92,39 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
private lateinit var fab: FloatingActionButton
// Other stuff
- private var actionMode: ActionMode? = null
private var workManager: WorkManager? = null // Context-dependent
private var dispatcher: NotificationDispatcher? = null // Context-dependent
private var appBaseUrl: String? = null // Context-dependent
+ // Action mode stuff
+ private var actionMode: ActionMode? = null
+ private val actionModeCallback = object : ActionMode.Callback {
+ override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
+ actionMode = mode
+ if (mode != null) {
+ mode.menuInflater.inflate(R.menu.menu_main_action_mode, menu)
+ mode.title = "1" // One item selected
+ }
+ return true
+ }
+
+ override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
+
+ override fun onActionItemClicked(mode: ActionMode?, item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.main_action_mode_delete -> {
+ onMultiDeleteClick()
+ true
+ }
+ else -> false
+ }
+ }
+
+ override fun onDestroyActionMode(mode: ActionMode?) {
+ endActionModeAndRedraw()
+ }
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
@@ -87,18 +138,44 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
appBaseUrl = getString(R.string.app_base_url)
// Action bar
+ val toolbarLayout = findViewById(R.id.app_bar_drawer)
+ val dynamicColors = repository.getDynamicColorsEnabled()
+ val darkMode = isDarkThemeOn(this)
+ val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode)
+ val toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode)
+ toolbarLayout.setBackgroundColor(statusBarColor)
+
+ val toolbar = toolbarLayout.findViewById(R.id.toolbar)
+ toolbar.setTitleTextColor(toolbarTextColor)
+ toolbar.setNavigationIconTint(toolbarTextColor)
+ toolbar.overflowIcon?.setTint(toolbarTextColor)
+ setSupportActionBar(toolbar)
title = getString(R.string.main_action_bar_title)
+
+ // Set system status bar color and appearance
+ window.statusBarColor = statusBarColor
+ WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars =
+ Colors.shouldUseLightStatusBar(dynamicColors, darkMode)
// Floating action button ("+")
fab = findViewById(R.id.fab)
fab.setOnClickListener {
onSubscribeButtonClick()
}
+
+ // Add bottom padding to FAB to account for navigation bar
+ ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets ->
+ val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val layoutParams = view.layoutParams as androidx.constraintlayout.widget.ConstraintLayout.LayoutParams
+ layoutParams.bottomMargin = systemBars.bottom
+ view.layoutParams = layoutParams
+ insets
+ }
// Swipe to refresh
mainListContainer = findViewById(R.id.main_subscriptions_list_container)
mainListContainer.setOnRefreshListener { refreshAllSubscriptions() }
- mainListContainer.setColorSchemeResources(Colors.refreshProgressIndicator)
+ mainListContainer.setColorSchemeColors(Colors.swipeToRefreshColor(this))
// Update main list based on viewModel (& its datasource/livedata)
val noEntries: View = findViewById(R.id.main_no_subscriptions)
@@ -106,7 +183,15 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
val onSubscriptionLongClick = { s: Subscription -> onSubscriptionItemLongClick(s) }
mainList = findViewById(R.id.main_subscriptions_list)
- adapter = MainAdapter(repository, onSubscriptionClick, onSubscriptionLongClick)
+ adapter = MainAdapter(
+ repository,
+ onSubscriptionClick,
+ onSubscriptionLongClick,
+ ResourcesCompat.getDrawable(resources, R.drawable.ic_circle, theme)!!.apply {
+ setTint(Colors.primary(this@MainActivity))
+ },
+ Colors.onPrimary(this)
+ )
mainList.adapter = adapter
viewModel.list().observe(this) {
@@ -244,6 +329,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
}
+ // Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463
+ val howToLink = findViewById(R.id.main_how_to_link)
+ howToLink.isVisible = BuildConfig.PAYMENT_LINKS_AVAILABLE
+
// Create notification channels right away, so we can configure them immediately after installing the app
dispatcher?.init()
@@ -293,7 +382,19 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
val wsRemindTimeReached = repository.getWebSocketRemindTime() < System.currentTimeMillis()
val showBanner = hasSelfHostedSubscriptions && wsRemindTimeReached && !usingWebSockets
val wsBanner = findViewById(R.id.main_banner_websocket)
- wsBanner.visibility = if (showBanner) View.VISIBLE else View.GONE
+ if (showBanner) {
+ wsBanner.visibility = View.VISIBLE
+ if (!BuildConfig.PAYMENT_LINKS_AVAILABLE) {
+ // Hide links that lead to payments, see https://github.com/binwiederhier/ntfy/issues/1463
+ // This is a big fat hack, but I have to release this quickly ...
+ val wsBannerMainText = findViewById(R.id.main_banner_websocket_text)
+ val raw = getString(R.string.main_banner_websocket_text)
+ val unlinked = raw.replace(Regex("?a[^>]*>"), "")
+ wsBannerMainText.text = HtmlCompat.fromHtml(unlinked, HtmlCompat.FROM_HTML_MODE_LEGACY)
+ }
+ } else {
+ wsBanner.visibility = View.GONE
+ }
}
private fun showHideWebSocketReconnectBanner(subscriptions: List) {
@@ -372,6 +473,13 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main_action_bar, menu)
this.menu = menu
+
+ // Tint menu icons based on theme
+ val toolbarTextColor = Colors.toolbarTextColor(this, repository.getDynamicColorsEnabled(), isDarkThemeOn(this))
+ for (i in 0 until menu.size) {
+ menu[i].icon?.setTint(toolbarTextColor)
+ }
+
showHideNotificationMenuItems()
checkSubscriptionsMuted() // This is done here, because then we know that we've initialized the menu
return true
@@ -412,9 +520,13 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
val mutedUntilSeconds = repository.getGlobalMutedUntil()
runOnUiThread {
- // Show/hide in-app rate widget
+ // Show/hide menu items based on build config
val rateAppItem = menu.findItem(R.id.main_menu_rate)
+ val docsItem = menu.findItem(R.id.main_menu_docs)
+ val reportBugItem = menu.findItem(R.id.main_menu_report_bug)
rateAppItem.isVisible = BuildConfig.RATE_APP_AVAILABLE
+ docsItem.isVisible = BuildConfig.PAYMENT_LINKS_AVAILABLE // Google Payments Policy, see https://github.com/binwiederhier/ntfy/issues/1463
+ reportBugItem.isVisible = BuildConfig.PAYMENT_LINKS_AVAILABLE // Google Payments Policy, see https://github.com/binwiederhier/ntfy/issues/1463
// Pause notification icons
val notificationsEnabledItem = menu.findItem(R.id.main_menu_notifications_enabled)
@@ -460,10 +572,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
true
}
- R.id.main_menu_donate -> {
- startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_donate_url))))
- true
- }
R.id.main_menu_docs -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_docs_url))))
true
@@ -591,7 +699,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
}
} catch (e: Exception) {
- val topic = displayName(subscription)
+ val topic = displayName(appBaseUrl, subscription)
if (errorMessage == "") errorMessage = "$topic: ${e.message}"
errors++
}
@@ -618,7 +726,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
intent.putExtra(EXTRA_SUBSCRIPTION_ID, subscription.id)
intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
- intent.putExtra(EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(subscription))
+ intent.putExtra(EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(appBaseUrl, subscription))
intent.putExtra(EXTRA_SUBSCRIPTION_INSTANT, subscription.instant)
intent.putExtra(EXTRA_SUBSCRIPTION_MUTED_UNTIL, subscription.mutedUntil)
startActivity(intent)
@@ -631,11 +739,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_ID, subscription.id)
intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
- intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(subscription))
+ intent.putExtra(DetailActivity.EXTRA_SUBSCRIPTION_DISPLAY_NAME, displayName(appBaseUrl, subscription))
startActivity(intent)
}
-
private fun handleActionModeClick(subscription: Subscription) {
adapter.toggleSelection(subscription.id)
if (adapter.selected.size == 0) {
@@ -645,34 +752,10 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
}
}
- override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
- this.actionMode = mode
- if (mode != null) {
- mode.menuInflater.inflate(R.menu.menu_main_action_mode, menu)
- mode.title = "1" // One item selected
- }
- return true
- }
-
- override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
- return false
- }
-
- override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
- return when (item?.itemId) {
- R.id.main_action_mode_delete -> {
- onMultiDeleteClick()
- true
- }
- else -> false
- }
- }
-
private fun onMultiDeleteClick() {
Log.d(DetailActivity.TAG, "Showing multi-delete dialog for selected items")
- val builder = AlertDialog.Builder(this)
- val dialog = builder
+ val dialog = MaterialAlertDialogBuilder(this)
.setMessage(R.string.main_action_mode_delete_dialog_message)
.setPositiveButton(R.string.main_action_mode_delete_dialog_permanently_delete) { _, _ ->
adapter.selected.map { subscriptionId -> viewModel.remove(this, subscriptionId) }
@@ -690,15 +773,11 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
dialog.show()
}
- override fun onDestroyActionMode(mode: ActionMode?) {
- endActionModeAndRedraw()
- }
-
private fun beginActionMode(subscription: Subscription) {
- actionMode = startActionMode(this)
+ actionMode = startSupportActionMode(actionModeCallback)
adapter.toggleSelection(subscription.id)
- // Fade out FAB
+ // Fade out FAB
fab.alpha = 1f
fab
.animate()
@@ -709,11 +788,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
fab.visibility = View.GONE
}
})
-
- // Fade status bar color
- val fromColor = ContextCompat.getColor(this, Colors.statusBarNormal(this))
- val toColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this))
- fadeStatusBarColor(window, fromColor, toColor)
}
private fun finishActionMode() {
@@ -738,11 +812,6 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
fab.visibility = View.VISIBLE // Required to replace the old listener
}
})
-
- // Fade status bar color
- val fromColor = ContextCompat.getColor(this, Colors.statusBarActionMode(this))
- val toColor = ContextCompat.getColor(this, Colors.statusBarNormal(this))
- fadeStatusBarColor(window, fromColor, toColor)
}
private fun redrawList() {
diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
index ed0d2bd0..d9d08434 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/MainAdapter.kt
@@ -2,6 +2,7 @@ package io.heckel.ntfy.ui
import android.content.Context
import android.graphics.Color
+import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -20,7 +21,13 @@ import io.heckel.ntfy.util.readBitmapFromUriOrNull
import java.text.DateFormat
import java.util.*
-class MainAdapter(private val repository: Repository, private val onClick: (Subscription) -> Unit, private val onLongClick: (Subscription) -> Unit) :
+class MainAdapter(
+ private val repository: Repository,
+ private val onClick: (Subscription) -> Unit,
+ private val onLongClick: (Subscription) -> Unit,
+ private val countDrawable: Drawable,
+ private val onPrimaryColor: Int
+) :
ListAdapter(TopicDiffCallback) {
val selected = mutableSetOf() // Subscription IDs
@@ -28,7 +35,7 @@ class MainAdapter(private val repository: Repository, private val onClick: (Subs
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.fragment_main_item, parent, false)
- return SubscriptionViewHolder(view, repository, selected, onClick, onLongClick)
+ return SubscriptionViewHolder(view, repository, selected, onClick, onLongClick, countDrawable, onPrimaryColor)
}
/* Gets current topic and uses it to bind view. */
@@ -52,7 +59,15 @@ class MainAdapter(private val repository: Repository, private val onClick: (Subs
}
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
- class SubscriptionViewHolder(itemView: View, private val repository: Repository, private val selected: Set, val onClick: (Subscription) -> Unit, val onLongClick: (Subscription) -> Unit) :
+ class SubscriptionViewHolder(
+ itemView: View,
+ private val repository: Repository,
+ private val selected: Set,
+ val onClick: (Subscription) -> Unit,
+ val onLongClick: (Subscription) -> Unit,
+ private val countDrawable: Drawable,
+ private val onPrimaryColor: Int
+ ) :
RecyclerView.ViewHolder(itemView) {
private var subscription: Subscription? = null
private val context: Context = itemView.context
@@ -64,6 +79,7 @@ class MainAdapter(private val repository: Repository, private val onClick: (Subs
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 newItemsView: TextView = itemView.findViewById(R.id.main_item_new)
+ private val appBaseUrl = context.getString(R.string.app_base_url)
fun bind(subscription: Subscription) {
this.subscription = subscription
@@ -99,7 +115,7 @@ class MainAdapter(private val repository: Repository, private val onClick: (Subs
} else {
imageView.setImageResource(R.drawable.ic_sms_gray_24dp)
}
- nameView.text = displayName(subscription)
+ nameView.text = displayName(appBaseUrl, subscription)
statusView.text = statusMessage
dateView.text = dateText
dateView.visibility = if (isUnifiedPush) View.GONE else View.VISIBLE
@@ -111,11 +127,13 @@ class MainAdapter(private val repository: Repository, private val onClick: (Subs
} else {
newItemsView.visibility = View.VISIBLE
newItemsView.text = if (subscription.newCount <= 99) subscription.newCount.toString() else "99+"
+ newItemsView.setTextColor(onPrimaryColor)
+ newItemsView.background = countDrawable
}
itemView.setOnClickListener { onClick(subscription) }
itemView.setOnLongClickListener { onLongClick(subscription); true }
if (selected.contains(subscription.id)) {
- itemView.setBackgroundResource(Colors.itemSelectedBackground(context))
+ itemView.setBackgroundColor(Colors.itemSelectedBackground(context))
} else {
itemView.setBackgroundColor(Color.TRANSPARENT)
}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt
index 2d84879c..920d1bd4 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt
@@ -7,6 +7,7 @@ import android.os.Bundle
import android.widget.RadioButton
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository
import kotlinx.coroutines.Dispatchers
@@ -74,7 +75,7 @@ class NotificationFragment : DialogFragment() {
muteForeverButton = view.findViewById(R.id.notification_dialog_forever)
muteForeverButton.setOnClickListener{ onClick(Repository.MUTED_UNTIL_FOREVER) }
- return AlertDialog.Builder(activity)
+ return MaterialAlertDialogBuilder(requireContext())
.setView(view)
.create()
}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
index 2c73ef27..170fede3 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
@@ -2,7 +2,6 @@ package io.heckel.ntfy.ui
import android.Manifest
import android.app.AlarmManager
-import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -13,6 +12,7 @@ import android.os.Bundle
import android.provider.Settings
import android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM
import android.text.TextUtils
+import android.view.View
import android.widget.Button
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
@@ -21,10 +21,12 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
+import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.preference.*
import androidx.preference.Preference.OnPreferenceClickListener
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.gson.Gson
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
@@ -65,6 +67,28 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
repository = Repository.getInstance(this)
serviceManager = SubscriberServiceManager(this)
+ val toolbarLayout = findViewById(R.id.app_bar_drawer)
+ val dynamicColors = repository.getDynamicColorsEnabled()
+ val darkMode = isDarkThemeOn(this)
+ val statusBarColor = Colors.statusBarNormal(
+ this,
+ dynamicColors,
+ darkMode
+ )
+ val toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode)
+ toolbarLayout.setBackgroundColor(statusBarColor)
+
+ val toolbar = toolbarLayout.findViewById(R.id.toolbar)
+ toolbar.setTitleTextColor(toolbarTextColor)
+ toolbar.setNavigationIconTint(toolbarTextColor)
+ toolbar.overflowIcon?.setTint(toolbarTextColor)
+ setSupportActionBar(toolbar)
+
+ // Set system status bar color and appearance
+ window.statusBarColor = statusBarColor
+ WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars =
+ Colors.shouldUseLightStatusBar(dynamicColors, darkMode)
+
if (savedInstanceState == null) {
settingsFragment = SettingsFragment() // Empty constructor!
supportFragmentManager
@@ -128,7 +152,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
}
}
- class SettingsFragment : PreferenceFragmentCompat() {
+ class SettingsFragment : BasePreferenceFragment() {
private lateinit var repository: Repository
private lateinit var serviceManager: SubscriberServiceManager
private var autoDownloadSelection = AUTO_DOWNLOAD_SELECTION_NOT_SET
@@ -211,7 +235,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
// Keep alerting for max priority
val insistentMaxPriorityPrefId = context?.getString(R.string.settings_notifications_insistent_max_priority_key) ?: return
- val insistentMaxPriority: SwitchPreference? = findPreference(insistentMaxPriorityPrefId)
+ val insistentMaxPriority: SwitchPreferenceCompat? = findPreference(insistentMaxPriorityPrefId)
insistentMaxPriority?.isChecked = repository.getInsistentMaxPriorityEnabled()
insistentMaxPriority?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
@@ -221,7 +245,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
return repository.getInsistentMaxPriorityEnabled()
}
}
- insistentMaxPriority?.summaryProvider = Preference.SummaryProvider { pref ->
+ insistentMaxPriority?.summaryProvider = Preference.SummaryProvider { pref ->
if (pref.isChecked) {
getString(R.string.settings_notifications_insistent_max_priority_summary_enabled)
} else {
@@ -324,11 +348,45 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
}
}
+ // Dynamic colors
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val dynamicColorsEnabledPrefId = context?.getString(R.string.settings_general_dynamic_colors_key) ?: return
+ val dynamicColorsEnabled: SwitchPreferenceCompat? = findPreference(dynamicColorsEnabledPrefId)
+ dynamicColorsEnabled?.isChecked = repository.getDynamicColorsEnabled()
+ dynamicColorsEnabled?.preferenceDataStore = object : PreferenceDataStore() {
+ override fun putBoolean(key: String?, value: Boolean) {
+ repository.setDynamicColorsEnabled(value)
+
+ // Restart app
+ val packageManager = requireContext().packageManager
+ val packageName = requireContext().packageName
+ val intent = packageManager.getLaunchIntentForPackage(packageName)
+ val componentName = intent!!.component
+ val mainIntent = Intent.makeRestartActivityTask(componentName)
+ startActivity(mainIntent)
+ Runtime.getRuntime().exit(0)
+ }
+ override fun getBoolean(key: String?, defValue: Boolean): Boolean {
+ return repository.getDynamicColorsEnabled()
+ }
+ }
+ dynamicColorsEnabled?.summaryProvider = Preference.SummaryProvider { pref ->
+ if (pref.isChecked) {
+ getString(R.string.settings_general_dynamic_colors_summary_enabled)
+ } else {
+ getString(R.string.settings_general_dynamic_colors_summary_disabled)
+ }
+ }
+ dynamicColorsEnabled?.isVisible = true
+ }
+
// Default Base URL
val appBaseUrl = getString(R.string.app_base_url)
val defaultBaseUrlPrefId = context?.getString(R.string.settings_general_default_base_url_key) ?: return
val defaultBaseUrl: EditTextPreference? = findPreference(defaultBaseUrlPrefId)
defaultBaseUrl?.text = repository.getDefaultBaseUrl() ?: ""
+ defaultBaseUrl?.extras?.putString("message", getString(R.string.settings_general_default_base_url_message))
+ defaultBaseUrl?.extras?.putString("hint", getString(R.string.app_base_url))
defaultBaseUrl?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String, value: String?) {
val baseUrl = value ?: return
@@ -355,7 +413,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
// Broadcast enabled
val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return
- val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId)
+ val broadcastEnabled: SwitchPreferenceCompat? = findPreference(broadcastEnabledPrefId)
broadcastEnabled?.isChecked = repository.getBroadcastEnabled()
broadcastEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
@@ -365,7 +423,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
return repository.getBroadcastEnabled()
}
}
- broadcastEnabled?.summaryProvider = Preference.SummaryProvider { pref ->
+ broadcastEnabled?.summaryProvider = Preference.SummaryProvider { pref ->
if (pref.isChecked) {
getString(R.string.settings_advanced_broadcast_summary_enabled)
} else {
@@ -375,7 +433,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
// Enable UnifiedPush
val unifiedPushEnabledPrefId = context?.getString(R.string.settings_advanced_unifiedpush_key) ?: return
- val unifiedPushEnabled: SwitchPreference? = findPreference(unifiedPushEnabledPrefId)
+ val unifiedPushEnabled: SwitchPreferenceCompat? = findPreference(unifiedPushEnabledPrefId)
unifiedPushEnabled?.isChecked = repository.getUnifiedPushEnabled()
unifiedPushEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
@@ -385,7 +443,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
return repository.getUnifiedPushEnabled()
}
}
- unifiedPushEnabled?.summaryProvider = Preference.SummaryProvider { pref ->
+ unifiedPushEnabled?.summaryProvider = Preference.SummaryProvider { pref ->
if (pref.isChecked) {
getString(R.string.settings_advanced_unifiedpush_summary_enabled)
} else {
@@ -420,7 +478,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
// Record logs
val recordLogsPrefId = context?.getString(R.string.settings_advanced_record_logs_key) ?: return
- val recordLogsEnabled: SwitchPreference? = findPreference(recordLogsPrefId)
+ val recordLogsEnabled: SwitchPreferenceCompat? = findPreference(recordLogsPrefId)
recordLogsEnabled?.isChecked = Log.getRecord()
recordLogsEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
@@ -433,7 +491,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
return Log.getRecord()
}
}
- recordLogsEnabled?.summaryProvider = Preference.SummaryProvider { pref ->
+ recordLogsEnabled?.summaryProvider = Preference.SummaryProvider { pref ->
if (pref.isChecked) {
getString(R.string.settings_advanced_record_logs_summary_enabled)
} else {
@@ -670,7 +728,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
} else {
getString(R.string.settings_advanced_export_logs_scrub_dialog_empty)
}
- val dialog = AlertDialog.Builder(activity)
+ val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
.setMessage(scrubbedText)
.setPositiveButton(R.string.settings_advanced_export_logs_scrub_dialog_button_ok) { _, _ -> /* Nothing */ }
@@ -711,7 +769,7 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
data class NopasteResponse(val url: String)
}
- class UserSettingsFragment : PreferenceFragmentCompat() {
+ class UserSettingsFragment : BasePreferenceFragment() {
private lateinit var repository: Repository
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
diff --git a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt
index dc219ed1..c3d11ea7 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/ShareActivity.kt
@@ -9,6 +9,7 @@ import android.text.TextWatcher
import android.view.*
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textfield.TextInputLayout
@@ -18,6 +19,8 @@ import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import androidx.core.view.size
+import androidx.core.view.get
class ShareActivity : AppCompatActivity() {
private val repository by lazy { (application as Application).repository }
@@ -55,7 +58,24 @@ class ShareActivity : AppCompatActivity() {
Log.d(TAG, "Create $this with intent $intent")
// Action bar
+ val toolbarLayout = findViewById(R.id.app_bar_drawer)
+ val dynamicColors = repository.getDynamicColorsEnabled()
+ val darkMode = isDarkThemeOn(this)
+ val statusBarColor = Colors.statusBarNormal(this, dynamicColors, darkMode)
+ val toolbarTextColor = Colors.toolbarTextColor(this, dynamicColors, darkMode)
+ toolbarLayout.setBackgroundColor(statusBarColor)
+
+ val toolbar = toolbarLayout.findViewById(R.id.toolbar)
+ toolbar.setTitleTextColor(toolbarTextColor)
+ toolbar.setNavigationIconTint(toolbarTextColor)
+ toolbar.overflowIcon?.setTint(toolbarTextColor)
+ setSupportActionBar(toolbar)
title = getString(R.string.share_title)
+
+ // Set system status bar color and appearance
+ window.statusBarColor = statusBarColor
+ WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars =
+ Colors.shouldUseLightStatusBar(dynamicColors, darkMode)
// Show 'Back' button
supportActionBar?.setDisplayHomeAsUpEnabled(true)
@@ -235,6 +255,13 @@ class ShareActivity : AppCompatActivity() {
menuInflater.inflate(R.menu.menu_share_action_bar, menu)
this.menu = menu
sendItem = menu.findItem(R.id.share_menu_send)
+
+ // Tint menu icons based on theme
+ val toolbarTextColor = Colors.toolbarTextColor(this, repository.getDynamicColorsEnabled(), isDarkThemeOn(this))
+ for (i in 0 until menu.size) {
+ menu[i].icon?.setTint(toolbarTextColor)
+ }
+
validateInput() // Disable icon
return true
}
diff --git a/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt
index 6da8304a..e32b88fc 100644
--- a/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt
+++ b/app/src/main/java/io/heckel/ntfy/ui/UserFragment.kt
@@ -9,7 +9,9 @@ import android.view.WindowManager
import android.widget.Button
import android.widget.TextView
import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
import io.heckel.ntfy.R
import io.heckel.ntfy.db.User
import io.heckel.ntfy.util.AfterChangedTextWatcher
@@ -21,6 +23,7 @@ class UserFragment : DialogFragment() {
private lateinit var baseUrlsInUse: ArrayList
private lateinit var listener: UserDialogListener
+ private lateinit var baseUrlViewLayout: TextInputLayout
private lateinit var baseUrlView: TextInputEditText
private lateinit var usernameView: TextInputEditText
private lateinit var passwordView: TextInputEditText
@@ -54,9 +57,10 @@ class UserFragment : DialogFragment() {
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_user_dialog, null)
val positiveButtonTextResId = if (user == null) R.string.user_dialog_button_add else R.string.user_dialog_button_save
- val titleView = view.findViewById(R.id.user_dialog_title) as TextView
- val descriptionView = view.findViewById(R.id.user_dialog_description) as TextView
+ val titleView = view.findViewById(R.id.user_dialog_title)
+ val descriptionView = view.findViewById(R.id.user_dialog_description)
+ baseUrlViewLayout = view.findViewById(R.id.user_dialog_base_url_layout)
baseUrlView = view.findViewById(R.id.user_dialog_base_url)
usernameView = view.findViewById(R.id.user_dialog_username)
passwordView = view.findViewById(R.id.user_dialog_password)
@@ -64,18 +68,18 @@ class UserFragment : DialogFragment() {
if (user == null) {
titleView.text = getString(R.string.user_dialog_title_add)
descriptionView.text = getString(R.string.user_dialog_description_add)
- baseUrlView.visibility = View.VISIBLE
+ baseUrlViewLayout.visibility = View.VISIBLE
passwordView.hint = getString(R.string.user_dialog_password_hint_add)
} else {
titleView.text = getString(R.string.user_dialog_title_edit)
descriptionView.text = getString(R.string.user_dialog_description_edit)
- baseUrlView.visibility = View.GONE
+ baseUrlViewLayout.visibility = View.GONE
usernameView.setText(user!!.username)
passwordView.hint = getString(R.string.user_dialog_password_hint_edit)
}
// Build dialog
- val builder = AlertDialog.Builder(activity)
+ val builder = MaterialAlertDialogBuilder(requireContext())
.setView(view)
.setPositiveButton(positiveButtonTextResId) { _, _ ->
saveClicked()
diff --git a/app/src/main/java/io/heckel/ntfy/up/Constants.kt b/app/src/main/java/io/heckel/ntfy/up/Constants.kt
index ab9c8160..7db2680e 100644
--- a/app/src/main/java/io/heckel/ntfy/up/Constants.kt
+++ b/app/src/main/java/io/heckel/ntfy/up/Constants.kt
@@ -13,9 +13,8 @@ const val ACTION_MESSAGE = "org.unifiedpush.android.connector.MESSAGE"
const val ACTION_REGISTER = "org.unifiedpush.android.distributor.REGISTER"
const val ACTION_UNREGISTER = "org.unifiedpush.android.distributor.UNREGISTER"
-const val FEATURE_BYTES_MESSAGE = "org.unifiedpush.android.distributor.feature.BYTES_MESSAGE"
-
const val EXTRA_APPLICATION = "application"
+const val EXTRA_PI = "pi"
const val EXTRA_TOKEN = "token"
const val EXTRA_ENDPOINT = "endpoint"
const val EXTRA_MESSAGE = "message"
diff --git a/app/src/main/java/io/heckel/ntfy/up/LinkActivity.kt b/app/src/main/java/io/heckel/ntfy/up/LinkActivity.kt
new file mode 100644
index 00000000..bbffae60
--- /dev/null
+++ b/app/src/main/java/io/heckel/ntfy/up/LinkActivity.kt
@@ -0,0 +1,36 @@
+package io.heckel.ntfy.up
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+
+/**
+ * This implements the "Select default distributor" selection for UnifiedPush.
+ *
+ * To test, install ntfy and another distributor (e.g. SunUp) on the same phone.
+ * Install an app that uses UnifiedPush (e.g. UP Example) and click "Register".
+ *
+ * You should see a popup to select the default distributor.
+ * See https://unifiedpush.org/developers/spec/android/#link-activity
+ */
+class LinkActivity: Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ intent?.data?.run {
+ Log.d(TAG, "Received request for $callingPackage")
+ val intent = Intent("org.unifiedpush.register.dummy_app")
+ val pendingIntent = PendingIntent.getBroadcast(this@LinkActivity, 0, intent, PendingIntent.FLAG_IMMUTABLE)
+ val result = Intent().apply {
+ putExtra(EXTRA_PI, pendingIntent)
+ }
+ setResult(RESULT_OK, result)
+ } ?: setResult(RESULT_CANCELED)
+ finish()
+ }
+
+ companion object {
+ private val TAG = LinkActivity::class.simpleName
+ }
+}
diff --git a/app/src/main/java/io/heckel/ntfy/util/MarkwonFactory.kt b/app/src/main/java/io/heckel/ntfy/util/MarkwonFactory.kt
index c9630683..65b6aaef 100644
--- a/app/src/main/java/io/heckel/ntfy/util/MarkwonFactory.kt
+++ b/app/src/main/java/io/heckel/ntfy/util/MarkwonFactory.kt
@@ -1,12 +1,10 @@
package io.heckel.ntfy.util
import android.content.Context
-import android.graphics.Paint
import android.graphics.Typeface
import android.text.style.*
import android.text.util.Linkify
-import androidx.core.content.ContextCompat
-import io.heckel.ntfy.R
+import io.heckel.ntfy.ui.Colors
import io.noties.markwon.*
import io.noties.markwon.core.CorePlugin
import io.noties.markwon.core.CoreProps
@@ -36,7 +34,7 @@ internal object MarkwonFactory {
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureTheme(builder: MarkwonTheme.Builder) {
builder
- .linkColor(ContextCompat.getColor(context, R.color.teal))
+ .linkColor(Colors.linkColor(context))
.isLinkUnderlined(true)
}
diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt
index cf6f34cb..017830a8 100644
--- a/app/src/main/java/io/heckel/ntfy/util/Util.kt
+++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt
@@ -1,7 +1,5 @@
package io.heckel.ntfy.util
-import android.animation.ArgbEvaluator
-import android.animation.ValueAnimator
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentResolver
@@ -20,11 +18,9 @@ import android.text.Editable
import android.text.TextWatcher
import android.util.Base64
import android.view.View
-import android.view.Window
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate
-import androidx.core.content.ContextCompat
import io.heckel.ntfy.R
import io.heckel.ntfy.db.ACTION_PROGRESS_FAILED
import io.heckel.ntfy.db.ACTION_PROGRESS_ONGOING
@@ -68,8 +64,13 @@ fun subscriptionTopicShortUrl(subscription: Subscription) : String {
return topicShortUrl(subscription.baseUrl, subscription.topic)
}
-fun displayName(subscription: Subscription) : String {
- return subscription.displayName ?: subscriptionTopicShortUrl(subscription)
+fun displayName(appBaseUrl: String?, subscription: Subscription) : String {
+ if (subscription.displayName != null) {
+ return subscription.displayName
+ } else if (appBaseUrl == subscription.baseUrl) {
+ return subscription.topic
+ }
+ return subscriptionTopicShortUrl(subscription)
}
fun shortUrl(url: String) = url
@@ -190,11 +191,11 @@ fun decodeBytesMessage(notification: Notification): ByteArray {
* See above; prepend emojis to title if the title is non-empty.
* Otherwise, they are prepended to the message.
*/
-fun formatTitle(subscription: Subscription, notification: Notification): String {
+fun formatTitle(appBaseUrl: String?, subscription: Subscription, notification: Notification): String {
return if (notification.title != "") {
formatTitle(notification)
} else {
- displayName(subscription)
+ displayName(appBaseUrl, subscription)
}
}
@@ -276,16 +277,6 @@ data class FileInfo(
val size: Long,
)
-// Status bar color fading to match action bar, see https://stackoverflow.com/q/51150077/1440785
-fun fadeStatusBarColor(window: Window, fromColor: Int, toColor: Int) {
- val statusBarColorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), fromColor, toColor)
- statusBarColorAnimation.addUpdateListener { animator ->
- val color = animator.animatedValue as Int
- window.statusBarColor = color
- }
- statusBarColorAnimation.start()
-}
-
// Generates a (cryptographically secure) random string of a certain length
fun randomString(len: Int): String {
val random = SecureRandom()
@@ -344,10 +335,7 @@ fun supportedImage(mimeType: String?): Boolean {
// Play didn't grant us the permission, and F-Droid users didn't want us to have it.
// See https://github.com/binwiederhier/ntfy/issues/531 & https://github.com/binwiederhier/ntfy/issues/684
fun canOpenAttachment(attachment: Attachment?): Boolean {
- if (attachment?.type == ANDROID_APP_MIME_TYPE) {
- return false
- }
- return true
+ return attachment?.type != ANDROID_APP_MIME_TYPE
}
// Check if battery optimization is enabled, see https://stackoverflow.com/a/49098293/1440785
@@ -507,11 +495,10 @@ fun Button.dangerButton(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setTextAppearance(R.style.DangerText)
} else {
- setTextColor(ContextCompat.getColor(context, Colors.dangerText(context)))
+ setTextColor(Colors.dangerText(context))
}
}
fun Long.nullIfZero(): Long? {
return if (this == 0L) return null else this
}
-
diff --git a/app/src/main/res/anim/slide_in_bottom.xml b/app/src/main/res/anim/slide_in_bottom.xml
new file mode 100644
index 00000000..dc163787
--- /dev/null
+++ b/app/src/main/res/anim/slide_in_bottom.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/anim/slide_out_bottom.xml b/app/src/main/res/anim/slide_out_bottom.xml
new file mode 100644
index 00000000..362a7564
--- /dev/null
+++ b/app/src/main/res/anim/slide_out_bottom.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_bolt_outline_white_24dp.xml b/app/src/main/res/drawable/ic_bolt_outline_white_24dp.xml
index 9ecde1ff..1a91ebc8 100644
--- a/app/src/main/res/drawable/ic_bolt_outline_white_24dp.xml
+++ b/app/src/main/res/drawable/ic_bolt_outline_white_24dp.xml
@@ -1,6 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notifications_off_time_white_outline_24dp.xml b/app/src/main/res/drawable/ic_notifications_off_time_white_outline_24dp.xml
index f3c0ad0b..09425d1c 100644
--- a/app/src/main/res/drawable/ic_notifications_off_time_white_outline_24dp.xml
+++ b/app/src/main/res/drawable/ic_notifications_off_time_white_outline_24dp.xml
@@ -1,6 +1,7 @@
-
+
+
+
+
-
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index d532db64..1bf20371 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,290 +1,309 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:layout_height="wrap_content" />
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+ style="@style/BannerCardStyle"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/main_banner_websocket"
+ android:id="@+id/main_banner_websocket_reconnect" android:visibility="visible">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml
index f02c645d..087faaa2 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -1,9 +1,27 @@
-
+
+
-
+
+
+
+
-
+ android:layout_height="match_parent" />
+
+
+
+
diff --git a/app/src/main/res/layout/activity_share.xml b/app/src/main/res/layout/activity_share.xml
index dae99ae5..fd5ea44c 100644
--- a/app/src/main/res/layout/activity_share.xml
+++ b/app/src/main/res/layout/activity_share.xml
@@ -1,9 +1,23 @@
-
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:fitsSystemWindows="true">
+
+
+
+
+
@@ -163,4 +179,7 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@+id/share_error_text" android:layout_marginTop="2dp"/>
-
+
+
+
+
diff --git a/app/src/main/res/layout/app_bar_drawer.xml b/app/src/main/res/layout/app_bar_drawer.xml
new file mode 100644
index 00000000..f61c73b6
--- /dev/null
+++ b/app/src/main/res/layout/app_bar_drawer.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_add_dialog.xml b/app/src/main/res/layout/fragment_add_dialog.xml
index ad0c7d49..910d1678 100644
--- a/app/src/main/res/layout/fragment_add_dialog.xml
+++ b/app/src/main/res/layout/fragment_add_dialog.xml
@@ -1,73 +1,121 @@
-
-
-
-
+
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
-
+ android:textAppearance="?android:attr/textAppearanceMedium"/>
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
-
-
-
+
+
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
+ android:orientation="horizontal">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_user_dialog.xml b/app/src/main/res/layout/fragment_user_dialog.xml
index f6ed2715..a2e982fe 100644
--- a/app/src/main/res/layout/fragment_user_dialog.xml
+++ b/app/src/main/res/layout/fragment_user_dialog.xml
@@ -1,55 +1,93 @@
-
-
+ 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="match_parent"
+ android:paddingHorizontal="?dialogPreferredPadding"
+ android:visibility="visible">
-
+
+
+
+
+
+
-
+
+
+
+
+
+
-
+
+
+
+
+
+
+ android:maxLines="1"
+ android:inputType="textPassword"/>
+
+
diff --git a/app/src/main/res/layout/preference_dialog_edittext_edited.xml b/app/src/main/res/layout/preference_dialog_edittext_edited.xml
index b55bb63e..f4b36fc8 100644
--- a/app/src/main/res/layout/preference_dialog_edittext_edited.xml
+++ b/app/src/main/res/layout/preference_dialog_edittext_edited.xml
@@ -45,15 +45,18 @@
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorSecondary"/>
-
+ android:layout_marginHorizontal="?dialogPreferredPadding"
+ android:paddingTop="?dialogPreferredPadding">
+
+
+
+
diff --git a/app/src/main/res/layout/view_preference_switch.xml b/app/src/main/res/layout/view_preference_switch.xml
new file mode 100644
index 00000000..ba3fd967
--- /dev/null
+++ b/app/src/main/res/layout/view_preference_switch.xml
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_add_dialog.xml b/app/src/main/res/menu/menu_add_dialog.xml
new file mode 100644
index 00000000..13ea08fd
--- /dev/null
+++ b/app/src/main/res/menu/menu_add_dialog.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/menu/menu_detail_action_bar.xml b/app/src/main/res/menu/menu_detail_action_bar.xml
index 0b76cf93..b90c71fb 100644
--- a/app/src/main/res/menu/menu_detail_action_bar.xml
+++ b/app/src/main/res/menu/menu_detail_action_bar.xml
@@ -1,17 +1,44 @@
-