mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-01-07 18:10:31 +05:30
Merge branch 'libre-tube:master' into master
This commit is contained in:
commit
a2c1c9121a
@ -1,12 +1,16 @@
|
|||||||
package com.github.libretube.api
|
package com.github.libretube.api
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.github.libretube.R
|
||||||
|
import com.github.libretube.constants.PreferenceKeys
|
||||||
import com.github.libretube.db.DatabaseHolder.Companion.Database
|
import com.github.libretube.db.DatabaseHolder.Companion.Database
|
||||||
import com.github.libretube.db.obj.LocalSubscription
|
import com.github.libretube.db.obj.LocalSubscription
|
||||||
import com.github.libretube.extensions.TAG
|
import com.github.libretube.extensions.TAG
|
||||||
import com.github.libretube.extensions.awaitQuery
|
import com.github.libretube.extensions.awaitQuery
|
||||||
import com.github.libretube.extensions.query
|
import com.github.libretube.extensions.query
|
||||||
import com.github.libretube.util.PreferenceHelper
|
import com.github.libretube.util.PreferenceHelper
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -55,6 +59,24 @@ object SubscriptionHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun handleUnsubscribe(context: Context, channelId: String, channelName: String?, onUnsubscribe: () -> Unit) {
|
||||||
|
if (!PreferenceHelper.getBoolean(PreferenceKeys.CONFIRM_UNSUBSCRIBE, false)) {
|
||||||
|
unsubscribe(channelId)
|
||||||
|
onUnsubscribe.invoke()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(context)
|
||||||
|
.setTitle(R.string.unsubscribe)
|
||||||
|
.setMessage(context.getString(R.string.confirm_unsubscribe, channelName))
|
||||||
|
.setPositiveButton(R.string.unsubscribe) { _, _ ->
|
||||||
|
unsubscribe(channelId)
|
||||||
|
onUnsubscribe.invoke()
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun isSubscribed(channelId: String): Boolean? {
|
suspend fun isSubscribed(channelId: String): Boolean? {
|
||||||
if (PreferenceHelper.getToken() != "") {
|
if (PreferenceHelper.getToken() != "") {
|
||||||
val isSubscribed = try {
|
val isSubscribed = try {
|
||||||
@ -99,7 +121,7 @@ object SubscriptionHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalSubscriptions(): List<LocalSubscription> {
|
private fun getLocalSubscriptions(): List<LocalSubscription> {
|
||||||
return awaitQuery {
|
return awaitQuery {
|
||||||
Database.localSubscriptionDao().getAll()
|
Database.localSubscriptionDao().getAll()
|
||||||
}
|
}
|
||||||
@ -107,6 +129,6 @@ object SubscriptionHelper {
|
|||||||
|
|
||||||
fun getFormattedLocalSubscriptions(): String {
|
fun getFormattedLocalSubscriptions(): String {
|
||||||
val localSubscriptions = getLocalSubscriptions()
|
val localSubscriptions = getLocalSubscriptions()
|
||||||
return localSubscriptions.map { it.channelId }.joinToString(",")
|
return localSubscriptions.joinToString(",") { it.channelId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -104,6 +104,7 @@ object PreferenceKeys {
|
|||||||
const val CLEAR_WATCH_HISTORY = "clear_watch_history"
|
const val CLEAR_WATCH_HISTORY = "clear_watch_history"
|
||||||
const val CLEAR_WATCH_POSITIONS = "clear_watch_positions"
|
const val CLEAR_WATCH_POSITIONS = "clear_watch_positions"
|
||||||
const val SHARE_WITH_TIME_CODE = "share_with_time_code"
|
const val SHARE_WITH_TIME_CODE = "share_with_time_code"
|
||||||
|
const val CONFIRM_UNSUBSCRIBE = "confirm_unsubscribing"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* History
|
* History
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package com.github.libretube.ui.adapters
|
package com.github.libretube.ui.adapters
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@ -128,13 +129,13 @@ class SearchAdapter(
|
|||||||
root.setOnClickListener {
|
root.setOnClickListener {
|
||||||
NavigationHelper.navigateChannel(root.context, item.url)
|
NavigationHelper.navigateChannel(root.context, item.url)
|
||||||
}
|
}
|
||||||
val channelId = item.url!!.toID()
|
|
||||||
|
|
||||||
isSubscribed(channelId, binding)
|
isSubscribed(root.context, item, binding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isSubscribed(channelId: String, binding: ChannelRowBinding) {
|
private fun isSubscribed(context: Context, streamItem: ContentItem, binding: ChannelRowBinding) {
|
||||||
|
val channelId = streamItem.url!!.toID()
|
||||||
// check whether the user subscribed to the channel
|
// check whether the user subscribed to the channel
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
var isSubscribed = SubscriptionHelper.isSubscribed(channelId)
|
var isSubscribed = SubscriptionHelper.isSubscribed(channelId)
|
||||||
@ -151,18 +152,17 @@ class SearchAdapter(
|
|||||||
binding.searchSubButton.setOnClickListener {
|
binding.searchSubButton.setOnClickListener {
|
||||||
if (isSubscribed == false) {
|
if (isSubscribed == false) {
|
||||||
SubscriptionHelper.subscribe(channelId)
|
SubscriptionHelper.subscribe(channelId)
|
||||||
binding.searchSubButton.text =
|
binding.searchSubButton.text = binding.root.context.getString(R.string.unsubscribe)
|
||||||
binding.root.context.getString(R.string.unsubscribe)
|
|
||||||
isSubscribed = true
|
isSubscribed = true
|
||||||
} else {
|
} else {
|
||||||
SubscriptionHelper.unsubscribe(channelId)
|
SubscriptionHelper.handleUnsubscribe(context, channelId, streamItem.uploaderName) {
|
||||||
binding.searchSubButton.text =
|
binding.searchSubButton.text = binding.root.context.getString(R.string.subscribe)
|
||||||
binding.root.context.getString(R.string.subscribe)
|
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun bindPlaylist(
|
private fun bindPlaylist(
|
||||||
item: ContentItem,
|
item: ContentItem,
|
||||||
|
@ -38,13 +38,13 @@ class SubscriptionChannelAdapter(private val subscriptions: MutableList<com.gith
|
|||||||
subscriptionSubscribe.setOnClickListener {
|
subscriptionSubscribe.setOnClickListener {
|
||||||
val channelId = subscription.url!!.toID()
|
val channelId = subscription.url!!.toID()
|
||||||
if (subscribed) {
|
if (subscribed) {
|
||||||
|
SubscriptionHelper.handleUnsubscribe(root.context, channelId, subscription.name ?: "") {
|
||||||
subscriptionSubscribe.text = root.context.getString(R.string.subscribe)
|
subscriptionSubscribe.text = root.context.getString(R.string.subscribe)
|
||||||
SubscriptionHelper.unsubscribe(channelId)
|
|
||||||
subscribed = false
|
subscribed = false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
subscriptionSubscribe.text =
|
|
||||||
root.context.getString(R.string.unsubscribe)
|
|
||||||
SubscriptionHelper.subscribe(channelId)
|
SubscriptionHelper.subscribe(channelId)
|
||||||
|
subscriptionSubscribe.text = root.context.getString(R.string.unsubscribe)
|
||||||
subscribed = true
|
subscribed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,6 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.view.children
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.github.libretube.R
|
import com.github.libretube.R
|
||||||
@ -25,7 +24,6 @@ import com.github.libretube.ui.adapters.VideosAdapter
|
|||||||
import com.github.libretube.ui.base.BaseFragment
|
import com.github.libretube.ui.base.BaseFragment
|
||||||
import com.github.libretube.ui.dialogs.ShareDialog
|
import com.github.libretube.ui.dialogs.ShareDialog
|
||||||
import com.github.libretube.util.ImageHelper
|
import com.github.libretube.util.ImageHelper
|
||||||
import com.google.android.material.chip.Chip
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -74,7 +72,9 @@ class ChannelFragment : BaseFragment() {
|
|||||||
binding.channelRefresh.isRefreshing = true
|
binding.channelRefresh.isRefreshing = true
|
||||||
fetchChannel()
|
fetchChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshChannel()
|
refreshChannel()
|
||||||
|
|
||||||
binding.channelRefresh.setOnRefreshListener {
|
binding.channelRefresh.setOnRefreshListener {
|
||||||
refreshChannel()
|
refreshChannel()
|
||||||
}
|
}
|
||||||
@ -112,6 +112,7 @@ class ChannelFragment : BaseFragment() {
|
|||||||
}
|
}
|
||||||
// needed if the channel gets loaded by the ID
|
// needed if the channel gets loaded by the ID
|
||||||
channelId = response.id
|
channelId = response.id
|
||||||
|
channelName = response.name
|
||||||
val shareData = ShareData(currentChannel = response.name)
|
val shareData = ShareData(currentChannel = response.name)
|
||||||
|
|
||||||
onScrollEnd = {
|
onScrollEnd = {
|
||||||
@ -128,14 +129,15 @@ class ChannelFragment : BaseFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.channelSubscribe.setOnClickListener {
|
binding.channelSubscribe.setOnClickListener {
|
||||||
binding.channelSubscribe.text = if (isSubscribed == true) {
|
if (isSubscribed == true) {
|
||||||
SubscriptionHelper.unsubscribe(channelId!!)
|
SubscriptionHelper.handleUnsubscribe(requireContext(), channelId!!, channelName) {
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
getString(R.string.subscribe)
|
binding.channelSubscribe.text = getString(R.string.subscribe)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
SubscriptionHelper.subscribe(channelId!!)
|
SubscriptionHelper.subscribe(channelId!!)
|
||||||
isSubscribed = true
|
isSubscribed = true
|
||||||
getString(R.string.unsubscribe)
|
binding.channelSubscribe.text = getString(R.string.unsubscribe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,40 +194,56 @@ class ChannelFragment : BaseFragment() {
|
|||||||
binding.channelRecView.adapter = channelAdapter
|
binding.channelRecView.adapter = channelAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.videos.setOnClickListener {
|
setupTabs(response.tabs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupTabs(tabs: List<ChannelTab>?) {
|
||||||
|
tabs?.firstOrNull { it.name == "Playlists" }?.let {
|
||||||
|
binding.playlists.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs?.firstOrNull { it.name == "Channels" }?.let {
|
||||||
|
binding.playlists.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs?.firstOrNull { it.name == "Livestreams" }?.let {
|
||||||
|
binding.playlists.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs?.firstOrNull { it.name == "Shorts" }?.let {
|
||||||
|
binding.playlists.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tabChips.setOnCheckedStateChangeListener { _, _ ->
|
||||||
|
reactToTabChange(tabs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reactToTabChange(tabs: List<ChannelTab>?) {
|
||||||
|
when (binding.tabChips.checkedChipId) {
|
||||||
|
binding.videos.id -> {
|
||||||
binding.channelRecView.adapter = channelAdapter
|
binding.channelRecView.adapter = channelAdapter
|
||||||
onScrollEnd = {
|
onScrollEnd = {
|
||||||
fetchChannelNextPage()
|
fetchChannelNextPage()
|
||||||
}
|
}
|
||||||
binding.tabChips.children.forEach { child ->
|
|
||||||
if (child != it) (child as Chip).isChecked = false
|
|
||||||
}
|
}
|
||||||
|
binding.channels.id -> {
|
||||||
|
tabs?.first { it.name == "Channels" }?.let { loadTab(it) }
|
||||||
}
|
}
|
||||||
|
binding.playlists.id -> {
|
||||||
response.tabs?.firstOrNull { it.name == "Playlists" }?.let {
|
tabs?.first { it.name == "Playlists" }?.let { loadTab(it) }
|
||||||
setupTab(binding.playlists, it)
|
|
||||||
}
|
}
|
||||||
|
binding.livestreams.id -> {
|
||||||
response.tabs?.firstOrNull { it.name == "Channels" }?.let {
|
tabs?.first { it.name == "Livestreams" }?.let { loadTab(it) }
|
||||||
setupTab(binding.channels, it)
|
|
||||||
}
|
}
|
||||||
|
binding.shorts.id -> {
|
||||||
response.tabs?.firstOrNull { it.name == "Livestreams" }?.let {
|
tabs?.first { it.name == "Shorts" }?.let { loadTab(it) }
|
||||||
setupTab(binding.livestreams, it)
|
|
||||||
}
|
|
||||||
|
|
||||||
response.tabs?.firstOrNull { it.name == "Shorts" }?.let {
|
|
||||||
setupTab(binding.shorts, it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupTab(chip: Chip, tab: ChannelTab) {
|
private fun loadTab(tab: ChannelTab) {
|
||||||
chip.visibility = View.VISIBLE
|
|
||||||
chip.setOnClickListener {
|
|
||||||
binding.tabChips.children.forEach {
|
|
||||||
if (it != chip) (it as Chip).isChecked = false
|
|
||||||
}
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val response = try {
|
val response = try {
|
||||||
RetrofitInstance.api.getChannelTab(tab.data!!)
|
RetrofitInstance.api.getChannelTab(tab.data!!)
|
||||||
@ -252,7 +270,6 @@ class ChannelFragment : BaseFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchChannelNextPage() {
|
private fun fetchChannelNextPage() {
|
||||||
fun run() {
|
fun run() {
|
||||||
|
@ -863,11 +863,10 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// check if livestream
|
|
||||||
if (response.duration > 0) {
|
|
||||||
// download clicked
|
|
||||||
binding.relPlayerDownload.setOnClickListener {
|
binding.relPlayerDownload.setOnClickListener {
|
||||||
if (!DownloadService.IS_DOWNLOAD_RUNNING) {
|
if (response.duration <= 0) {
|
||||||
|
Toast.makeText(context, R.string.cannotDownload, Toast.LENGTH_SHORT).show()
|
||||||
|
} else if (!DownloadService.IS_DOWNLOAD_RUNNING) {
|
||||||
val newFragment = DownloadDialog(videoId!!)
|
val newFragment = DownloadDialog(videoId!!)
|
||||||
newFragment.show(childFragmentManager, DownloadDialog::class.java.name)
|
newFragment.show(childFragmentManager, DownloadDialog::class.java.name)
|
||||||
} else {
|
} else {
|
||||||
@ -875,9 +874,6 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Toast.makeText(context, R.string.cannotDownload, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.hls != null) {
|
if (response.hls != null) {
|
||||||
binding.relPlayerOpen.setOnClickListener {
|
binding.relPlayerOpen.setOnClickListener {
|
||||||
@ -1271,9 +1267,10 @@ class PlayerFragment : BaseFragment(), OnlinePlayerOptions {
|
|||||||
}
|
}
|
||||||
binding.playerSubscribe.setOnClickListener {
|
binding.playerSubscribe.setOnClickListener {
|
||||||
if (isSubscribed == true) {
|
if (isSubscribed == true) {
|
||||||
SubscriptionHelper.unsubscribe(channelId)
|
SubscriptionHelper.handleUnsubscribe(requireContext(), channelId, streams.uploader) {
|
||||||
binding.playerSubscribe.text = getString(R.string.subscribe)
|
binding.playerSubscribe.text = getString(R.string.subscribe)
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
SubscriptionHelper.subscribe(channelId)
|
SubscriptionHelper.subscribe(channelId)
|
||||||
binding.playerSubscribe.text = getString(R.string.unsubscribe)
|
binding.playerSubscribe.text = getString(R.string.unsubscribe)
|
||||||
|
10
app/src/main/res/drawable/ic_check.xml
Normal file
10
app/src/main/res/drawable/ic_check.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
|
||||||
|
</vector>
|
@ -124,12 +124,15 @@
|
|||||||
<com.google.android.material.chip.ChipGroup
|
<com.google.android.material.chip.ChipGroup
|
||||||
android:id="@+id/tab_chips"
|
android:id="@+id/tab_chips"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content"
|
||||||
|
app:checkedChip="@+id/videos"
|
||||||
|
app:selectionRequired="true"
|
||||||
|
app:singleLine="true"
|
||||||
|
app:singleSelection="true">
|
||||||
|
|
||||||
<com.google.android.material.chip.Chip
|
<com.google.android.material.chip.Chip
|
||||||
android:id="@+id/videos"
|
android:id="@id/videos"
|
||||||
style="@style/channelChip"
|
style="@style/channelChip"
|
||||||
android:checked="true"
|
|
||||||
android:text="@string/videos"
|
android:text="@string/videos"
|
||||||
android:visibility="visible" />
|
android:visibility="visible" />
|
||||||
|
|
||||||
|
@ -356,6 +356,9 @@
|
|||||||
<string name="alternative_videos_layout">Alternative videos layout</string>
|
<string name="alternative_videos_layout">Alternative videos layout</string>
|
||||||
<string name="defaultIconLight">Default light</string>
|
<string name="defaultIconLight">Default light</string>
|
||||||
<string name="playlistCloned">Playlist cloned</string>
|
<string name="playlistCloned">Playlist cloned</string>
|
||||||
|
<string name="confirm_unsubscribe">Are you sure you want to unsubscribe %1$s?</string>
|
||||||
|
<string name="confirm_unsubscribing">Confirm unsubscribing</string>
|
||||||
|
<string name="confirm_unsubscribing_summary">Show a confirmation dialog before unsubscribing.</string>
|
||||||
|
|
||||||
<!-- Notification channel strings -->
|
<!-- Notification channel strings -->
|
||||||
<string name="download_channel_name">Download Service</string>
|
<string name="download_channel_name">Download Service</string>
|
||||||
|
@ -186,7 +186,7 @@
|
|||||||
<item name="animationMode">slide</item>
|
<item name="animationMode">slide</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="channelChip" parent="@style/Widget.Material3.Chip.Filter.Elevated">
|
<style name="channelChip" parent="@style/Widget.Material3.Chip.Filter">
|
||||||
|
|
||||||
<item name="android:layout_width">wrap_content</item>
|
<item name="android:layout_width">wrap_content</item>
|
||||||
<item name="android:layout_height">wrap_content</item>
|
<item name="android:layout_height">wrap_content</item>
|
||||||
|
@ -36,6 +36,12 @@
|
|||||||
app:key="save_feed"
|
app:key="save_feed"
|
||||||
app:title="@string/save_feed" />
|
app:title="@string/save_feed" />
|
||||||
|
|
||||||
|
<SwitchPreferenceCompat
|
||||||
|
android:icon="@drawable/ic_check"
|
||||||
|
android:summary="@string/confirm_unsubscribing_summary"
|
||||||
|
app:key="confirm_unsubscribing"
|
||||||
|
app:title="@string/confirm_unsubscribing" />
|
||||||
|
|
||||||
</PreferenceCategory>
|
</PreferenceCategory>
|
||||||
|
|
||||||
<PreferenceCategory app:title="@string/backup_restore">
|
<PreferenceCategory app:title="@string/backup_restore">
|
||||||
|
Loading…
Reference in New Issue
Block a user