ntfy-android/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt
2022-04-14 10:54:17 -04:00

805 lines
41 KiB
Kotlin

package io.heckel.ntfy.ui
import android.Manifest
import android.app.AlertDialog
import android.app.NotificationManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
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 lateinit var notificationManager: NotificationManager
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
notificationManager = requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// 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> { _ ->
val mutedUntilValue = repository.getGlobalMutedUntil()
when (mutedUntilValue) {
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 ->
val minPriorityValue = pref.value.toIntOrNull() ?: 1 // 1/low means all priorities
when (minPriorityValue) {
1 -> getString(R.string.settings_notifications_min_priority_summary_any)
5 -> 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)
}
}
}
// DND override priority
val dndOverrideEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && notificationManager.isNotificationPolicyAccessGranted
val dndOverridePriorityPrefId = context?.getString(R.string.settings_notifications_dnd_override_priority_key) ?: return
val dndOverridePriority: ListPreference? = findPreference(minPriorityPrefId)
dndOverridePriority?.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
dndOverridePriority?.value = repository.getDnsOverridePriority().toString()
dndOverridePriority?.preferenceDataStore = object : PreferenceDataStore() {
override fun putString(key: String?, value: String?) {
val dndOverridePriorityValue = value?.toIntOrNull() ?:return
//repository.setMinPriority(minPriorityValue)
}
override fun getString(key: String?, defValue: String?): String {
return repository.getDnsOverridePriority().toString()
}
}
dndOverridePriority?.summaryProvider = Preference.SummaryProvider<ListPreference> { pref ->
val priorityValue = pref.value.toIntOrNull() ?: 1 // 1/low means all priorities
when (priorityValue) {
1 -> getString(R.string.settings_notifications_min_priority_summary_any)
5 -> getString(R.string.settings_notifications_min_priority_summary_max)
else -> {
val minPriorityString = toPriorityString(requireContext(), priorityValue)
getString(R.string.settings_notifications_min_priority_summary_x_or_higher, priorityValue, minPriorityString)
}
}
}
// 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 ->
val maxSize = pref.value.toLongOrNull() ?: repository.getAutoDownloadMaxSize()
when (maxSize) {
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 ->
val seconds = pref.value.toLongOrNull() ?: repository.getAutoDeleteSeconds()
when (seconds) {
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)
}
}
// 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 { _, v ->
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 {
restoreResultLauncher.launch(Backuper.MIME_TYPE)
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 == null || body.isEmpty()) throw Exception("Return body is empty")
Log.d(TAG, "Logs uploaded successfully: $body")
val resp = gson.fromJson(body.toString(), NopasteResponse::class.java)
val context = context ?: return@launch
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)
val context = context ?: return@launch
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
}
}