From 71b5d56f6a5027e3cc8912cfe9545f0f68336362 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 21 Nov 2021 14:54:13 -0500 Subject: [PATCH] WIP: Muted until --- app/build.gradle | 1 + .../io.heckel.ntfy.data.Database/3.json | 118 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 35 ++---- .../main/java/io/heckel/ntfy/data/Database.kt | 24 +++- .../java/io/heckel/ntfy/data/Repository.kt | 2 + .../java/io/heckel/ntfy/ui/DetailActivity.kt | 39 +++++- .../java/io/heckel/ntfy/ui/MainActivity.kt | 1 + .../io/heckel/ntfy/ui/NotificationFragment.kt | 73 +++++++++++ .../io/heckel/ntfy/ui/SettingsFragment.kt | 5 + .../ntfy/ui/SubscriptionSettingsActivity.kt | 25 ++++ .../drawable/ic_notifications_white_24dp.xml | 9 ++ .../layout/activity_subscription_settings.xml | 18 +++ .../layout/notification_dialog_fragment.xml | 66 ++++++++++ .../main/res/menu/detail_action_bar_menu.xml | 2 + app/src/main/res/values/arrays.xml | 16 +++ app/src/main/res/values/strings.xml | 64 ++++++++-- app/src/main/res/xml/root_preferences.xml | 23 ++++ 17 files changed, 479 insertions(+), 42 deletions(-) create mode 100644 app/schemas/io.heckel.ntfy.data.Database/3.json create mode 100644 app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt create mode 100644 app/src/main/java/io/heckel/ntfy/ui/SettingsFragment.kt create mode 100644 app/src/main/java/io/heckel/ntfy/ui/SubscriptionSettingsActivity.kt create mode 100644 app/src/main/res/drawable/ic_notifications_white_24dp.xml create mode 100644 app/src/main/res/layout/activity_subscription_settings.xml create mode 100644 app/src/main/res/layout/notification_dialog_fragment.xml create mode 100644 app/src/main/res/values/arrays.xml create mode 100644 app/src/main/res/xml/root_preferences.xml diff --git a/app/build.gradle b/app/build.gradle index 06a6598..026d482 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -58,6 +58,7 @@ dependencies { // WorkManager implementation "androidx.work:work-runtime-ktx:2.6.0" + implementation 'androidx.preference:preference:1.1.1' // Room (SQLite) def roomVersion = "2.3.0" diff --git a/app/schemas/io.heckel.ntfy.data.Database/3.json b/app/schemas/io.heckel.ntfy.data.Database/3.json new file mode 100644 index 0000000..42b369c --- /dev/null +++ b/app/schemas/io.heckel.ntfy.data.Database/3.json @@ -0,0 +1,118 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "7b0ef556331f6d2dd3515425837c3d3a", + "entities": [ + { + "tableName": "Subscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topic", + "columnName": "topic", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mutedUntil", + "columnName": "mutedUntil", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_Subscription_baseUrl_topic", + "unique": true, + "columnNames": [ + "baseUrl", + "topic" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "Notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `message` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7b0ef556331f6d2dd3515425837c3d3a')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 369e85d..bc21bac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,15 +7,11 @@ - WAKE_LOCK & RECEIVE_BOOT_COMPLETED are required to restart the foreground service if it is stopped; see https://robertohuertas.com/2019/06/29/android_foreground_services/ --> - - - + + + - - + + - - - + - - - - - - - + android:value=".ui.MainActivity"/> + + + - - - + diff --git a/app/src/main/java/io/heckel/ntfy/data/Database.kt b/app/src/main/java/io/heckel/ntfy/data/Database.kt index 0704aad..c7af4b5 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Database.kt @@ -5,6 +5,7 @@ import androidx.room.* import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import kotlinx.coroutines.flow.Flow +import java.util.* @Entity(indices = [Index(value = ["baseUrl", "topic"], unique = true)]) data class Subscription( @@ -12,12 +13,16 @@ data class Subscription( @ColumnInfo(name = "baseUrl") val baseUrl: String, @ColumnInfo(name = "topic") val topic: String, @ColumnInfo(name = "instant") val instant: Boolean, + @ColumnInfo(name = "mutedUntil") val mutedUntil: Long, + //val notificationSchedule: String, + //val notificationSound: String, @Ignore val totalCount: Int = 0, // Total notifications @Ignore val newCount: Int = 0, // New notifications @Ignore val lastActive: Long = 0, // Unix timestamp @Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE ) { - constructor(id: Long, baseUrl: String, topic: String, instant: Boolean) : this(id, baseUrl, topic, instant, 0, 0, 0, ConnectionState.NOT_APPLICABLE) + constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long) : + this(id, baseUrl, topic, instant, mutedUntil, 0, 0, 0, ConnectionState.NOT_APPLICABLE) } enum class ConnectionState { @@ -29,6 +34,7 @@ data class SubscriptionWithMetadata( val baseUrl: String, val topic: String, val instant: Boolean, + val mutedUntil: Long, val totalCount: Int, val newCount: Int, val lastActive: Long @@ -44,7 +50,7 @@ data class Notification( @ColumnInfo(name = "deleted") val deleted: Boolean, ) -@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 2) +@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 3) abstract class Database : RoomDatabase() { abstract fun subscriptionDao(): SubscriptionDao abstract fun notificationDao(): NotificationDao @@ -79,6 +85,12 @@ abstract class Database : RoomDatabase() { db.execSQL("ALTER TABLE Notification ADD COLUMN deleted INTEGER NOT NULL DEFAULT('0')") } } + + private val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Subscription ADD COLUMN mutedUntil INTEGER NOT NULL DEFAULT('0')") + } + } } } @@ -86,7 +98,7 @@ abstract class Database : RoomDatabase() { interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -99,7 +111,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -112,7 +124,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive @@ -125,7 +137,7 @@ interface SubscriptionDao { @Query(""" SELECT - s.id, s.baseUrl, s.topic, s.instant, + s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, COUNT(n.id) totalCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, IFNULL(MAX(n.timestamp),0) AS lastActive diff --git a/app/src/main/java/io/heckel/ntfy/data/Repository.kt b/app/src/main/java/io/heckel/ntfy/data/Repository.kt index 348b40a..72915f3 100644 --- a/app/src/main/java/io/heckel/ntfy/data/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/data/Repository.kt @@ -113,6 +113,7 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif baseUrl = s.baseUrl, topic = s.topic, instant = s.instant, + mutedUntil = s.mutedUntil, totalCount = s.totalCount, newCount = s.newCount, lastActive = s.lastActive, @@ -130,6 +131,7 @@ class Repository(private val subscriptionDao: SubscriptionDao, private val notif baseUrl = s.baseUrl, topic = s.topic, instant = s.instant, + mutedUntil = s.mutedUntil, totalCount = s.totalCount, newCount = s.newCount, lastActive = s.lastActive, 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 df00a25..a62f1f0 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -33,7 +33,7 @@ import kotlinx.coroutines.launch import java.util.* import java.util.concurrent.atomic.AtomicLong -class DetailActivity : AppCompatActivity(), ActionMode.Callback { +class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFragment.NotificationSettingsListener { private val viewModel by viewModels { DetailViewModelFactory((application as Application).repository) } @@ -187,6 +187,10 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { onTestClick() true } + R.id.detail_menu_notification -> { + onNotificationSettingsClick() + true + } R.id.detail_menu_enable_instant -> { onInstantEnableClick(enable = true) true @@ -228,6 +232,38 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { } } + private fun onNotificationSettingsClick() { + Log.d(TAG, "Showing notification settings dialog for ${topicShortUrl(subscriptionBaseUrl, subscriptionTopic)}") + val intent = Intent(this, SubscriptionSettingsActivity::class.java) + startActivityForResult(intent, /*XXXXXX*/MainActivity.REQUEST_CODE_DELETE_SUBSCRIPTION) +/* + val notificationFragment = NotificationFragment() + notificationFragment.show(supportFragmentManager, NotificationFragment.TAG)*/ + } + + override fun onNotificationSettingsChanged(mutedUntil: Long) { + lifecycleScope.launch(Dispatchers.IO) { + val subscription = repository.getSubscription(subscriptionId) + val newSubscription = subscription?.copy(mutedUntil = mutedUntil) + newSubscription?.let { repository.updateSubscription(newSubscription) } + runOnUiThread { + when (mutedUntil) { + 0L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_enabled_toast_message), Toast.LENGTH_SHORT).show() + 1L -> Toast.makeText(this@DetailActivity, getString(R.string.notification_dialog_muted_forever_toast_message), Toast.LENGTH_SHORT).show() + else -> { + val mutedUntilDate = Date(mutedUntil).toString() + Toast.makeText( + this@DetailActivity, + getString(R.string.notification_dialog_muted_until_toast_message, mutedUntilDate), + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + } + private fun onCopyUrlClick() { val url = topicUrl(subscriptionBaseUrl, subscriptionTopic) Log.d(TAG, "Copying topic URL $url to clipboard ") @@ -478,6 +514,5 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback { companion object { const val TAG = "NtfyDetailActivity" - const val CANCEL_NOTIFICATION_DELAY_MILLIS = 20_000L } } 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 3c294ad..2c89315 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -165,6 +165,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc baseUrl = baseUrl, topic = topic, instant = instant, + mutedUntil = 0, totalCount = 0, newCount = 0, lastActive = Date().time/1000 diff --git a/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt new file mode 100644 index 0000000..f59e93e --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/NotificationFragment.kt @@ -0,0 +1,73 @@ +package io.heckel.ntfy.ui + +import android.app.AlertDialog +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import android.view.View +import android.widget.Button +import android.widget.CheckBox +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.textfield.TextInputEditText +import io.heckel.ntfy.R +import io.heckel.ntfy.data.Database +import io.heckel.ntfy.data.Repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class NotificationFragment : DialogFragment() { + private lateinit var repository: Repository + private lateinit var settingsListener: NotificationSettingsListener + + interface NotificationSettingsListener { + fun onNotificationSettingsChanged(mutedUntil: Long) + } + + override fun onAttach(context: Context) { + super.onAttach(context) + settingsListener = activity as NotificationSettingsListener + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + if (activity == null) { + throw IllegalStateException("Activity cannot be null") + } + + // Dependencies + val database = Database.getInstance(activity!!.applicationContext) + repository = Repository.getInstance(database.subscriptionDao(), database.notificationDao()) + + // Build root view + val view = requireActivity().layoutInflater.inflate(R.layout.notification_dialog_fragment, null) + // topicNameText = view.findViewById(R.id.add_dialog_topic_text) as TextInputEditText + + // Build dialog + val alert = AlertDialog.Builder(activity) + .setView(view) + .setPositiveButton(R.string.notification_dialog_save) { _, _ -> + /// + settingsListener.onNotificationSettingsChanged(0L) + } + .setNegativeButton(R.string.notification_dialog_cancel) { _, _ -> + dialog?.cancel() + } + .create() + + // Add logic to disable "Subscribe" button on invalid input + alert.setOnShowListener { + val dialog = it as AlertDialog + /// + } + + return alert + } + + + companion object { + const val TAG = "NtfyNotificationFragment" + } +} diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsFragment.kt new file mode 100644 index 0000000..01204fe --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsFragment.kt @@ -0,0 +1,5 @@ +package io.heckel.ntfy.ui + +import android.os.Bundle +import androidx.preference.PreferenceFragmentCompat +import io.heckel.ntfy.R diff --git a/app/src/main/java/io/heckel/ntfy/ui/SubscriptionSettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SubscriptionSettingsActivity.kt new file mode 100644 index 0000000..1f80505 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/SubscriptionSettingsActivity.kt @@ -0,0 +1,25 @@ +package io.heckel.ntfy.ui + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import androidx.preference.PreferenceFragmentCompat +import io.heckel.ntfy.R + +class SubscriptionSettingsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_subscription_settings) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportFragmentManager + .beginTransaction() + .replace(R.id.subscription_settings_content, SubscriptionSettingsFragment()) + .commit() + } + + class SubscriptionSettingsFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.root_preferences, rootKey) + } + } +} diff --git a/app/src/main/res/drawable/ic_notifications_white_24dp.xml b/app/src/main/res/drawable/ic_notifications_white_24dp.xml new file mode 100644 index 0000000..6410bf0 --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_subscription_settings.xml b/app/src/main/res/layout/activity_subscription_settings.xml new file mode 100644 index 0000000..11b43d5 --- /dev/null +++ b/app/src/main/res/layout/activity_subscription_settings.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/src/main/res/layout/notification_dialog_fragment.xml b/app/src/main/res/layout/notification_dialog_fragment.xml new file mode 100644 index 0000000..d133cea --- /dev/null +++ b/app/src/main/res/layout/notification_dialog_fragment.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/detail_action_bar_menu.xml b/app/src/main/res/menu/detail_action_bar_menu.xml index 959e77b..081be9a 100644 --- a/app/src/main/res/menu/detail_action_bar_menu.xml +++ b/app/src/main/res/menu/detail_action_bar_menu.xml @@ -1,4 +1,6 @@ + + + + Forever + 30 minutes + 1 hour + 2 hours + + + + forever + 30min + 1hr + 2h + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0bc89ed..33692ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,7 +10,8 @@ You are subscribed to instant delivery topics You are subscribed to one instant delivery topic You are subscribed to two instant delivery topics - You are subscribed to three instant delivery topics + You are subscribed to three instant delivery topics + You are subscribed to four instant delivery topics You are subscribed to %1$d instant delivery topics @@ -27,7 +28,9 @@ Unsubscribe - Do you really want to unsubscribe from selected topic(s) and permanently delete all the messages you received? + Do you really want to unsubscribe from selected topic(s) and + permanently delete all the messages you received? + Permanently delete Cancel @@ -38,12 +41,17 @@ Yesterday Add subscription It looks like you don\'t have any subscriptions yet. - Click the button below to create or subscribe to a topic. After that, you can send messages via PUT or POST and you\'ll receive notifications on your phone. - For more detailed instructions, check out the ntfy.sh website and documentation. + Click the button below to create or subscribe to a topic. After that, you can send + messages via PUT or POST and you\'ll receive notifications on your phone. + + For more detailed instructions, check out the ntfy.sh website and documentation. + Subscribe to topic - Topics are not password-protected, so choose a name that\'s not easy to guess. Once subscribed, you can PUT/POST to receive notifications on your phone. + Topics are not password-protected, so choose a name that\'s not easy to + guess. Once subscribed, you can PUT/POST to receive notifications on your phone. + Topic name, e.g. phils_alerts Use another server @@ -60,13 +68,18 @@ You haven\'t received any notifications for this topic yet. - To send notifications to this topic, simply PUT or POST to the topic URL. + To send notifications to this topic, simply PUT or POST to the topic URL. + $ curl -d \"Hi\" %1$s ]]> - For more detailed instructions, check out the ntfy.sh website and documentation. - Do you really want to unsubscribe from this topic and delete all of the messages you received? + For more detailed instructions, check out the ntfy.sh website and documentation. + + Do you really want to unsubscribe from this topic and delete all of the + messages you received? + Permanently delete Cancel - This is a test notification from the Ntfy Android app. It was sent at %1$s. + This is a test notification from the Ntfy Android app. It was sent at %1$s. + Could not send test message: %1$s Copied to clipboard Instant delivery enabled @@ -74,17 +87,44 @@ Instant delivery cannot be disabled for subscriptions from other servers - Send test notification - Copy topic address + Notification Enable instant delivery Disable instant delivery + Send test notification + Copy topic address Instant delivery enabled Unsubscribe Copy Delete - Do you really want to permanently delete the selected message(s)? + Do you really want to permanently delete the selected + message(s)? + Permanently delete Cancel + + + Notification settings + Cancel + Save + Notifications re-enabled + Notifications are now paused + Notifications are now paused until %s + + + + Notifications + Pause notifications + Until … + Sync + + + Your signature + + + + Download incoming attachments + Automatically download attachments for incoming emails + Only download attachments when manually requested diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml new file mode 100644 index 0000000..410829d --- /dev/null +++ b/app/src/main/res/xml/root_preferences.xml @@ -0,0 +1,23 @@ + + + + + + + + + + +