ntfy-android/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
2023-03-03 07:57:36 -07:00

828 lines
42 KiB
Kotlin

package io.heckel.ntfy.ui
import android.Manifest
import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.text.TextUtils
import android.widget.Button
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.preference.*
import androidx.preference.Preference.OnPreferenceClickListener
import com.google.gson.Gson
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.R
import io.heckel.ntfy.backup.Backuper
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User
import io.heckel.ntfy.service.SubscriberServiceManager
import io.heckel.ntfy.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
/**
* Main settings
*
* The "nested screen" navigation stuff (for user management) has been taken from
* https://github.com/googlearchive/android-preferences/blob/master/app/src/main/java/com/example/androidx/preference/sample/MainActivity.kt
*/
class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback,
UserFragment.UserDialogListener {
private lateinit var settingsFragment: SettingsFragment
private lateinit var userSettingsFragment: UserSettingsFragment
private lateinit var repository: Repository
private lateinit var serviceManager: SubscriberServiceManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
Log.d(TAG, "Create $this")
repository = Repository.getInstance(this)
serviceManager = SubscriberServiceManager(this)
if (savedInstanceState == null) {
settingsFragment = SettingsFragment() // Empty constructor!
supportFragmentManager
.beginTransaction()
.replace(R.id.settings_layout, settingsFragment)
.commit()
title = getString(R.string.settings_title)
} else {
title = savedInstanceState.getCharSequence(TITLE_TAG)
}
supportFragmentManager.addOnBackStackChangedListener {
if (supportFragmentManager.backStackEntryCount == 0) {
setTitle(R.string.settings_title)
}
}
// Show 'Back' button
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// Save current activity title, so we can set it again after a configuration change
outState.putCharSequence(TITLE_TAG, title)
}
override fun onSupportNavigateUp(): Boolean {
if (supportFragmentManager.popBackStackImmediate()) {
return true
}
return super.onSupportNavigateUp()
}
override fun onPreferenceStartFragment(
caller: PreferenceFragmentCompat,
pref: Preference
): Boolean {
// Instantiate the new Fragment
val fragmentClass = pref.fragment ?: return false
val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, fragmentClass)
fragment.arguments = pref.extras
// Replace the existing Fragment with the new Fragment
supportFragmentManager.beginTransaction()
.replace(R.id.settings_layout, fragment)
.addToBackStack(null)
.commit()
title = pref.title
// Save user settings fragment for later
if (fragment is UserSettingsFragment) {
userSettingsFragment = fragment
}
return true
}
class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var repository: Repository
private lateinit var serviceManager: SubscriberServiceManager
private var autoDownloadSelection = AUTO_DOWNLOAD_SELECTION_NOT_SET
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.main_preferences, rootKey)
// Dependencies (Fragments need a default constructor)
repository = Repository.getInstance(requireActivity())
serviceManager = SubscriberServiceManager(requireActivity())
autoDownloadSelection = repository.getAutoDownloadMaxSize() // Only used for <= Android P, due to permissions request
// Important note: We do not use the default shared prefs to store settings. Every
// preferenceDataStore is overridden to use the repository. This is convenient, because
// everybody has access to the repository.
// Notifications muted until (global)
val mutedUntilPrefId = context?.getString(R.string.settings_notifications_muted_until_key) ?: return
val mutedUntil: ListPreference? = findPreference(mutedUntilPrefId)
mutedUntil?.value = repository.getGlobalMutedUntil().toString()
mutedUntil?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val mutedUntilValue = value?.toLongOrNull() ?:return
when (mutedUntilValue) {
Repository.MUTED_UNTIL_SHOW_ALL -> repository.setGlobalMutedUntil(mutedUntilValue)
Repository.MUTED_UNTIL_FOREVER -> repository.setGlobalMutedUntil(mutedUntilValue)
Repository.MUTED_UNTIL_TOMORROW -> {
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)
repository.setGlobalMutedUntil(date.timeInMillis/1000)
}
else -> {
val mutedUntilTimestamp = System.currentTimeMillis()/1000 + mutedUntilValue * 60
repository.setGlobalMutedUntil(mutedUntilTimestamp)
}
}
}
override fun getString(key: String?, defValue: String?): String {
return repository.getGlobalMutedUntil().toString()
}
}
mutedUntil?.summaryProvider = Preference.SummaryProvider<ListPreference> {
when (val mutedUntilValue = repository.getGlobalMutedUntil()) {
Repository.MUTED_UNTIL_SHOW_ALL -> getString(R.string.settings_notifications_muted_until_show_all)
Repository.MUTED_UNTIL_FOREVER -> getString(R.string.settings_notifications_muted_until_forever)
else -> {
val formattedDate = formatDateShort(mutedUntilValue)
getString(R.string.settings_notifications_muted_until_x, formattedDate)
}
}
}
// Minimum priority
val minPriorityPrefId = context?.getString(R.string.settings_notifications_min_priority_key) ?: return
val minPriority: ListPreference? = findPreference(minPriorityPrefId)
minPriority?.value = repository.getMinPriority().toString()
minPriority?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val minPriorityValue = value?.toIntOrNull() ?:return
repository.setMinPriority(minPriorityValue)
}
override fun getString(key: String?, defValue: String?): String {
return repository.getMinPriority().toString()
}
}
minPriority?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
when (val minPriorityValue = pref.value.toIntOrNull() ?: 1) { // 1/low means all priorities
PRIORITY_MIN -> getString(R.string.settings_notifications_min_priority_summary_any)
PRIORITY_MAX -> getString(R.string.settings_notifications_min_priority_summary_max)
else -> {
val minPriorityString = toPriorityString(requireContext(), minPriorityValue)
getString(R.string.settings_notifications_min_priority_summary_x_or_higher, minPriorityValue, minPriorityString)
}
}
}
// Keep alerting for max priority
val insistentMaxPriorityPrefId = context?.getString(R.string.settings_notifications_insistent_max_priority_key) ?: return
val insistentMaxPriority: SwitchPreference? = findPreference(insistentMaxPriorityPrefId)
insistentMaxPriority?.isChecked = repository.getInsistentMaxPriorityEnabled()
insistentMaxPriority?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setInsistentMaxPriorityEnabled(value)
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return repository.getInsistentMaxPriorityEnabled()
}
}
insistentMaxPriority?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
getString(R.string.settings_notifications_insistent_max_priority_summary_enabled)
} else {
getString(R.string.settings_notifications_insistent_max_priority_summary_disabled)
}
}
// Channel settings
val channelPrefsPrefId = context?.getString(R.string.settings_notifications_channel_prefs_key) ?: return
val channelPrefs: Preference? = findPreference(channelPrefsPrefId)
channelPrefs?.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
channelPrefs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
channelPrefs?.onPreferenceClickListener = OnPreferenceClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
})
}
false
}
// Auto download
val autoDownloadPrefId = context?.getString(R.string.settings_notifications_auto_download_key) ?: return
val autoDownload: ListPreference? = findPreference(autoDownloadPrefId)
autoDownload?.value = repository.getAutoDownloadMaxSize().toString()
autoDownload?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val maxSize = value?.toLongOrNull() ?:return
repository.setAutoDownloadMaxSize(maxSize)
}
override fun getString(key: String?, defValue: String?): String {
return repository.getAutoDownloadMaxSize().toString()
}
}
autoDownload?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
when (val maxSize = pref.value.toLongOrNull() ?: repository.getAutoDownloadMaxSize()) {
Repository.AUTO_DOWNLOAD_NEVER -> getString(R.string.settings_notifications_auto_download_summary_never)
Repository.AUTO_DOWNLOAD_ALWAYS -> getString(R.string.settings_notifications_auto_download_summary_always)
else -> getString(R.string.settings_notifications_auto_download_summary_smaller_than_x, formatBytes(maxSize, decimals = 0))
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
autoDownload?.setOnPreferenceChangeListener { _, v ->
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {
ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD)
autoDownloadSelection = v.toString().toLongOrNull() ?: repository.getAutoDownloadMaxSize()
false // If permission is granted, auto-download will be enabled in onRequestPermissionsResult()
} else {
true
}
}
}
// Auto delete
val autoDeletePrefId = context?.getString(R.string.settings_notifications_auto_delete_key) ?: return
val autoDelete: ListPreference? = findPreference(autoDeletePrefId)
autoDelete?.value = repository.getAutoDeleteSeconds().toString()
autoDelete?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val seconds = value?.toLongOrNull() ?:return
repository.setAutoDeleteSeconds(seconds)
}
override fun getString(key: String?, defValue: String?): String {
return repository.getAutoDeleteSeconds().toString()
}
}
autoDelete?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
when (pref.value.toLongOrNull() ?: repository.getAutoDeleteSeconds()) {
Repository.AUTO_DELETE_NEVER -> getString(R.string.settings_notifications_auto_delete_summary_never)
Repository.AUTO_DELETE_ONE_DAY_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_one_day)
Repository.AUTO_DELETE_THREE_DAYS_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_three_days)
Repository.AUTO_DELETE_ONE_WEEK_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_one_week)
Repository.AUTO_DELETE_ONE_MONTH_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_one_month)
Repository.AUTO_DELETE_THREE_MONTHS_SECONDS -> getString(R.string.settings_notifications_auto_delete_summary_three_months)
else -> getString(R.string.settings_notifications_auto_delete_summary_one_month) // Must match default const
}
}
// Dark mode
val darkModePrefId = context?.getString(R.string.settings_general_dark_mode_key) ?: return
val darkMode: ListPreference? = findPreference(darkModePrefId)
darkMode?.value = repository.getDarkMode().toString()
darkMode?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val darkModeValue = value?.toIntOrNull() ?: return
repository.setDarkMode(darkModeValue)
AppCompatDelegate.setDefaultNightMode(darkModeValue)
}
override fun getString(key: String?, defValue: String?): String {
return repository.getDarkMode().toString()
}
}
darkMode?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
val darkModeValue = pref.value.toIntOrNull() ?: repository.getDarkMode()
when (darkModeValue) {
AppCompatDelegate.MODE_NIGHT_NO -> getString(R.string.settings_general_dark_mode_summary_light)
AppCompatDelegate.MODE_NIGHT_YES -> getString(R.string.settings_general_dark_mode_summary_dark)
else -> getString(R.string.settings_general_dark_mode_summary_system)
}
}
// 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?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String, value: String?) {
val baseUrl = value ?: return
repository.setDefaultBaseUrl(baseUrl)
}
override fun getString(key: String, defValue: String?): String? {
return repository.getDefaultBaseUrl()
}
}
defaultBaseUrl?.setOnBindEditTextListener { editText ->
editText.addTextChangedListener(AfterChangedTextWatcher {
val okayButton: Button = editText.rootView.findViewById(android.R.id.button1)
val value = editText.text.toString()
okayButton.isEnabled = value.isEmpty() || validUrl(value)
})
}
defaultBaseUrl?.summaryProvider = Preference.SummaryProvider<EditTextPreference> { pref ->
if (TextUtils.isEmpty(pref.text)) {
getString(R.string.settings_general_default_base_url_default_summary, appBaseUrl)
} else {
pref.text
}
}
// Broadcast enabled
val broadcastEnabledPrefId = context?.getString(R.string.settings_advanced_broadcast_key) ?: return
val broadcastEnabled: SwitchPreference? = findPreference(broadcastEnabledPrefId)
broadcastEnabled?.isChecked = repository.getBroadcastEnabled()
broadcastEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setBroadcastEnabled(value)
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return repository.getBroadcastEnabled()
}
}
broadcastEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
getString(R.string.settings_advanced_broadcast_summary_enabled)
} else {
getString(R.string.settings_advanced_broadcast_summary_disabled)
}
}
// Enable UnifiedPush
val enableUPPrefId = context?.getString(R.string.settings_advanced_enable_up_key) ?: return
val enableUP: SwitchPreference? = findPreference(enableUPPrefId)
enableUP?.isChecked = repository.getEnableUP()
enableUP?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setEnableUP(value)
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return repository.getEnableUP()
}
}
enableUP?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
getString(R.string.settings_advanced_enable_up_summary_enabled)
} else {
getString(R.string.settings_advanced_enable_up_summary_disabled)
}
}
// Export logs
val exportLogsPrefId = context?.getString(R.string.settings_advanced_export_logs_key) ?: return
val exportLogs: ListPreference? = findPreference(exportLogsPrefId)
exportLogs?.isVisible = Log.getRecord()
exportLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
exportLogs?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v ->
when (v) {
EXPORT_LOGS_COPY_ORIGINAL -> copyLogsToClipboard(scrub = false)
EXPORT_LOGS_COPY_SCRUBBED -> copyLogsToClipboard(scrub = true)
EXPORT_LOGS_UPLOAD_ORIGINAL -> uploadLogsToNopaste(scrub = false)
EXPORT_LOGS_UPLOAD_SCRUBBED -> uploadLogsToNopaste(scrub = true)
}
false
}
// Clear logs
val clearLogsPrefId = context?.getString(R.string.settings_advanced_clear_logs_key) ?: return
val clearLogs: Preference? = findPreference(clearLogsPrefId)
clearLogs?.isVisible = Log.getRecord()
clearLogs?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
clearLogs?.onPreferenceClickListener = OnPreferenceClickListener {
deleteLogs()
false
}
// Record logs
val recordLogsPrefId = context?.getString(R.string.settings_advanced_record_logs_key) ?: return
val recordLogsEnabled: SwitchPreference? = findPreference(recordLogsPrefId)
recordLogsEnabled?.isChecked = Log.getRecord()
recordLogsEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setRecordLogsEnabled(value)
Log.setRecord(value)
exportLogs?.isVisible = value
clearLogs?.isVisible = value
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return Log.getRecord()
}
}
recordLogsEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreference> { pref ->
if (pref.isChecked) {
getString(R.string.settings_advanced_record_logs_summary_enabled)
} else {
getString(R.string.settings_advanced_record_logs_summary_disabled)
}
}
recordLogsEnabled?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
repository.getSubscriptions().forEach { s ->
Log.addScrubTerm(shortUrl(s.baseUrl), Log.TermType.Domain)
Log.addScrubTerm(s.topic)
}
repository.getUsers().forEach { u ->
Log.addScrubTerm(shortUrl(u.baseUrl), Log.TermType.Domain)
Log.addScrubTerm(u.username, Log.TermType.Username)
Log.addScrubTerm(u.password, Log.TermType.Password)
}
}
true
}
// Backup
val backuper = Backuper(requireContext())
val backupPrefId = context?.getString(R.string.settings_backup_restore_backup_key) ?: return
val backup: ListPreference? = findPreference(backupPrefId)
var backupSelection = BACKUP_EVERYTHING
val backupResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri ->
if (uri == null) {
return@registerForActivityResult
}
lifecycleScope.launch(Dispatchers.IO) {
try {
when (backupSelection) {
BACKUP_EVERYTHING -> backuper.backup(uri)
BACKUP_EVERYTHING_NO_USERS -> backuper.backup(uri, withUsers = false)
BACKUP_SETTINGS_ONLY -> backuper.backup(uri, withUsers = false, withSubscriptions = false)
}
requireActivity().runOnUiThread {
Toast.makeText(context, getString(R.string.settings_backup_restore_backup_successful), Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
Log.w(TAG, "Backup failed", e)
requireActivity().runOnUiThread {
Toast.makeText(context, getString(R.string.settings_backup_restore_backup_failed, e.message), Toast.LENGTH_LONG).show()
}
}
}
}
backup?.preferenceDataStore = object : PreferenceDataStore() { } // Dummy store to protect from accidentally overwriting
backup?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, v ->
backupSelection = v.toString()
val timestamp = SimpleDateFormat("yyMMdd-HHmm").format(Date())
val suggestedFilename = when (backupSelection) {
BACKUP_EVERYTHING_NO_USERS -> "ntfy-backup-no-users-$timestamp.json"
BACKUP_SETTINGS_ONLY -> "ntfy-settings-$timestamp.json"
else -> "ntfy-backup-$timestamp.json"
}
backupResultLauncher.launch(suggestedFilename)
false
}
backup?.onPreferenceClickListener = OnPreferenceClickListener {
true
}
// Restore
val restorePrefId = context?.getString(R.string.settings_backup_restore_restore_key) ?: return
val restore: Preference? = findPreference(restorePrefId)
val restoreResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
return@registerForActivityResult
}
lifecycleScope.launch(Dispatchers.IO) {
try {
val currentDarkMode = repository.getDarkMode()
backuper.restore(uri)
requireActivity().runOnUiThread {
Toast.makeText(context, getString(R.string.settings_backup_restore_restore_successful), Toast.LENGTH_LONG).show()
requireActivity().recreate()
val newDarkMode = repository.getDarkMode()
if (newDarkMode != currentDarkMode) {
AppCompatDelegate.setDefaultNightMode(newDarkMode)
}
}
} catch (e: Exception) {
Log.w(TAG, "Restore failed", e)
requireActivity().runOnUiThread {
Toast.makeText(context, getString(R.string.settings_backup_restore_restore_failed, e.message), Toast.LENGTH_LONG).show()
}
}
}
}
restore?.onPreferenceClickListener = OnPreferenceClickListener {
// Overly open mime type filter (because of https://github.com/binwiederhier/ntfy/issues/223).
// This filter could likely be stricter if we'd write the mime type properly in Backuper.backup(),
// but just in case we want to restore from a file we didn't write outselves, we'll keep this "*/*".
restoreResultLauncher.launch("*/*")
true
}
// Connection protocol
val connectionProtocolPrefId = context?.getString(R.string.settings_advanced_connection_protocol_key) ?: return
val connectionProtocol: ListPreference? = findPreference(connectionProtocolPrefId)
connectionProtocol?.value = repository.getConnectionProtocol()
connectionProtocol?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val proto = value ?: repository.getConnectionProtocol()
repository.setConnectionProtocol(proto)
restartService()
}
override fun getString(key: String?, defValue: String?): String {
return repository.getConnectionProtocol()
}
}
connectionProtocol?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
when (pref.value) {
Repository.CONNECTION_PROTOCOL_WS -> getString(R.string.settings_advanced_connection_protocol_summary_ws)
else -> getString(R.string.settings_advanced_connection_protocol_summary_jsonhttp)
}
}
// Version
val versionPrefId = context?.getString(R.string.settings_about_version_key) ?: return
val versionPref: Preference? = findPreference(versionPrefId)
val version = getString(R.string.settings_about_version_format, BuildConfig.VERSION_NAME, BuildConfig.FLAVOR)
versionPref?.summary = version
versionPref?.onPreferenceClickListener = OnPreferenceClickListener {
val context = context ?: return@OnPreferenceClickListener false
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("ntfy version", version)
clipboard.setPrimaryClip(clip)
Toast
.makeText(context, getString(R.string.settings_about_version_copied_to_clipboard_message), Toast.LENGTH_LONG)
.show()
true
}
}
fun setAutoDownload() {
val autoDownloadSelectionCopy = autoDownloadSelection
if (autoDownloadSelectionCopy == AUTO_DOWNLOAD_SELECTION_NOT_SET) return
val autoDownloadPrefId = context?.getString(R.string.settings_notifications_auto_download_key) ?: return
val autoDownload: ListPreference? = findPreference(autoDownloadPrefId)
autoDownload?.value = autoDownloadSelectionCopy.toString()
repository.setAutoDownloadMaxSize(autoDownloadSelectionCopy)
}
private fun restartService() {
serviceManager.restart() // Service will auto-restart
}
private fun copyLogsToClipboard(scrub: Boolean) {
lifecycleScope.launch(Dispatchers.IO) {
val context = context ?: return@launch
val log = Log.getFormatted(context, scrub = scrub)
requireActivity().runOnUiThread {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("ntfy logs", log)
clipboard.setPrimaryClip(clip)
if (scrub) {
showScrubDialog(getString(R.string.settings_advanced_export_logs_copied_logs))
} else {
Toast
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_logs), Toast.LENGTH_LONG)
.show()
}
}
}
}
private fun uploadLogsToNopaste(scrub: Boolean) {
lifecycleScope.launch(Dispatchers.IO) {
Log.d(TAG, "Uploading log to $EXPORT_LOGS_UPLOAD_URL ...")
val context = context ?: return@launch
val log = Log.getFormatted(context, scrub = scrub)
if (log.length > EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD) {
requireActivity().runOnUiThread {
Toast
.makeText(context, getString(R.string.settings_advanced_export_logs_uploading), Toast.LENGTH_SHORT)
.show()
}
}
val gson = Gson()
val request = Request.Builder()
.url(EXPORT_LOGS_UPLOAD_URL)
.put(log.toRequestBody())
.build()
val client = OkHttpClient.Builder()
.callTimeout(1, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
try {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw Exception("Unexpected response ${response.code}")
}
val body = response.body?.string()?.trim()
if (body.isNullOrEmpty()) throw Exception("Return body is empty")
Log.d(TAG, "Logs uploaded successfully: $body")
val resp = gson.fromJson(body.toString(), NopasteResponse::class.java)
requireActivity().runOnUiThread {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("logs URL", resp.url)
clipboard.setPrimaryClip(clip)
if (scrub) {
showScrubDialog(getString(R.string.settings_advanced_export_logs_copied_url))
} else {
Toast
.makeText(context, getString(R.string.settings_advanced_export_logs_copied_url), Toast.LENGTH_LONG)
.show()
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Error uploading logs", e)
requireActivity().runOnUiThread {
Toast
.makeText(context, getString(R.string.settings_advanced_export_logs_error_uploading, e.message), Toast.LENGTH_LONG)
.show()
}
}
}
}
private fun showScrubDialog(title: String) {
val scrubbed = Log.getScrubTerms()
val scrubbedText = if (scrubbed.isNotEmpty()) {
val scrubTerms = scrubbed.map { e -> "${e.key} -> ${e.value}"}.joinToString(separator = "\n")
getString(R.string.settings_advanced_export_logs_scrub_dialog_text, scrubTerms)
} else {
getString(R.string.settings_advanced_export_logs_scrub_dialog_empty)
}
val dialog = AlertDialog.Builder(activity)
.setTitle(title)
.setMessage(scrubbedText)
.setPositiveButton(R.string.settings_advanced_export_logs_scrub_dialog_button_ok) { _, _ -> /* Nothing */ }
.create()
dialog.show()
}
private fun deleteLogs() {
lifecycleScope.launch(Dispatchers.IO) {
Log.deleteAll()
val context = context ?: return@launch
requireActivity().runOnUiThread {
Toast
.makeText(context, getString(R.string.settings_advanced_clear_logs_deleted_toast), Toast.LENGTH_LONG)
.show()
}
}
}
@Keep
data class NopasteResponse(val url: String)
}
class UserSettingsFragment : PreferenceFragmentCompat() {
private lateinit var repository: Repository
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.user_preferences, rootKey)
repository = Repository.getInstance(requireActivity())
reload()
}
data class UserWithMetadata(
val user: User,
val topics: List<String>
)
fun reload() {
preferenceScreen.removeAll()
lifecycleScope.launch(Dispatchers.IO) {
val baseUrlsWithTopics = repository.getSubscriptions()
.groupBy { it.baseUrl }
.mapValues { e -> e.value.map { it.topic } }
val usersByBaseUrl = repository.getUsers()
.map { user ->
val topics = baseUrlsWithTopics[user.baseUrl] ?: emptyList()
UserWithMetadata(user, topics)
}
.groupBy { it.user.baseUrl }
activity?.runOnUiThread {
addUserPreferences(usersByBaseUrl)
}
}
}
private fun addUserPreferences(usersByBaseUrl: Map<String, List<UserWithMetadata>>) {
val baseUrlsInUse = ArrayList(usersByBaseUrl.keys)
usersByBaseUrl.forEach { entry ->
val baseUrl = entry.key
val users = entry.value
val preferenceCategory = PreferenceCategory(preferenceScreen.context)
preferenceCategory.title = shortUrl(baseUrl)
preferenceScreen.addPreference(preferenceCategory)
users.forEach { user ->
val preference = Preference(preferenceScreen.context)
preference.title = user.user.username
preference.summary = if (user.topics.isEmpty()) {
getString(R.string.settings_general_users_prefs_user_not_used)
} else if (user.topics.size == 1) {
getString(R.string.settings_general_users_prefs_user_used_by_one, user.topics[0])
} else {
getString(R.string.settings_general_users_prefs_user_used_by_many, user.topics.joinToString(", "))
}
preference.onPreferenceClickListener = OnPreferenceClickListener { _ ->
activity?.let {
UserFragment
.newInstance(user.user, baseUrlsInUse)
.show(it.supportFragmentManager, UserFragment.TAG)
}
true
}
preferenceCategory.addPreference(preference)
}
}
// Add user
val userAddCategory = PreferenceCategory(preferenceScreen.context)
userAddCategory.title = getString(R.string.settings_general_users_prefs_user_add)
preferenceScreen.addPreference(userAddCategory)
val userAddPref = Preference(preferenceScreen.context)
userAddPref.title = getString(R.string.settings_general_users_prefs_user_add_title)
userAddPref.summary = getString(R.string.settings_general_users_prefs_user_add_summary)
userAddPref.onPreferenceClickListener = OnPreferenceClickListener { _ ->
activity?.let {
UserFragment
.newInstance(user = null, baseUrlsInUse = baseUrlsInUse)
.show(it.supportFragmentManager, UserFragment.TAG)
}
true
}
userAddCategory.addPreference(userAddPref)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
setAutoDownload()
}
}
}
override fun onAddUser(dialog: DialogFragment, user: User) {
lifecycleScope.launch(Dispatchers.IO) {
repository.addUser(user) // New users are not used, so no service refresh required
runOnUiThread {
userSettingsFragment.reload()
}
}
}
override fun onUpdateUser(dialog: DialogFragment, user: User) {
lifecycleScope.launch(Dispatchers.IO) {
repository.updateUser(user)
serviceManager.restart() // Editing does not change the user ID
runOnUiThread {
userSettingsFragment.reload()
}
}
}
override fun onDeleteUser(dialog: DialogFragment, baseUrl: String) {
lifecycleScope.launch(Dispatchers.IO) {
repository.deleteUser(baseUrl)
serviceManager.restart()
runOnUiThread {
userSettingsFragment.reload()
}
}
}
private fun setAutoDownload() {
if (!this::settingsFragment.isInitialized) return
settingsFragment.setAutoDownload()
}
companion object {
private const val TAG = "NtfySettingsActivity"
private const val TITLE_TAG = "title"
private const val REQUEST_CODE_WRITE_EXTERNAL_STORAGE_PERMISSION_FOR_AUTO_DOWNLOAD = 2586
private const val AUTO_DOWNLOAD_SELECTION_NOT_SET = -99L
private const val BACKUP_EVERYTHING = "everything"
private const val BACKUP_EVERYTHING_NO_USERS = "everything_no_users"
private const val BACKUP_SETTINGS_ONLY = "settings_only"
private const val EXPORT_LOGS_COPY_ORIGINAL = "copy_original"
private const val EXPORT_LOGS_COPY_SCRUBBED = "copy_scrubbed"
private const val EXPORT_LOGS_UPLOAD_ORIGINAL = "upload_original"
private const val EXPORT_LOGS_UPLOAD_SCRUBBED = "upload_scrubbed"
private const val EXPORT_LOGS_UPLOAD_URL = "https://nopaste.net/?f=json" // Run by binwiederhier; see https://github.com/binwiederhier/pcopy
private const val EXPORT_LOGS_UPLOAD_NOTIFY_SIZE_THRESHOLD = 100 * 1024 // Show "Uploading ..." if log larger than X
}
}