mirror of
https://github.com/libre-tube/LibreTube.git
synced 2025-04-29 08:20:32 +05:30
Merge pull request #5667 from Bnyro/master
feat: make LibreTube app backups import-compatible with Piped
This commit is contained in:
commit
19bc8025c3
@ -1,11 +1,16 @@
|
|||||||
package com.github.libretube.db.obj
|
package com.github.libretube.db.obj
|
||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Ignore
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
import com.github.libretube.ui.dialogs.ShareDialog
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@Entity(tableName = "localSubscription")
|
@Entity(tableName = "localSubscription")
|
||||||
data class LocalSubscription(
|
data class LocalSubscription(
|
||||||
@PrimaryKey val channelId: String = ""
|
@PrimaryKey val channelId: String,
|
||||||
)
|
@Ignore val url: String = "",
|
||||||
|
) {
|
||||||
|
constructor(channelId: String): this(channelId, "${ShareDialog.YOUTUBE_FRONTEND_URL}/channel/$channelId")
|
||||||
|
}
|
||||||
|
@ -2,12 +2,19 @@ package com.github.libretube.db.obj
|
|||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonNames
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
@Entity(tableName = "subscriptionGroups")
|
@Entity(tableName = "subscriptionGroups")
|
||||||
data class SubscriptionGroup(
|
data class SubscriptionGroup(
|
||||||
@PrimaryKey var name: String,
|
@PrimaryKey
|
||||||
|
@SerialName("groupName")
|
||||||
|
@JsonNames("groupName", "name")
|
||||||
|
var name: String,
|
||||||
var channels: List<String> = listOf(),
|
var channels: List<String> = listOf(),
|
||||||
var index: Int = 0
|
var index: Int = 0
|
||||||
)
|
)
|
||||||
|
@ -49,10 +49,10 @@ object BackupHelper {
|
|||||||
Database.watchHistoryDao().insertAll(backupFile.watchHistory.orEmpty())
|
Database.watchHistoryDao().insertAll(backupFile.watchHistory.orEmpty())
|
||||||
Database.searchHistoryDao().insertAll(backupFile.searchHistory.orEmpty())
|
Database.searchHistoryDao().insertAll(backupFile.searchHistory.orEmpty())
|
||||||
Database.watchPositionDao().insertAll(backupFile.watchPositions.orEmpty())
|
Database.watchPositionDao().insertAll(backupFile.watchPositions.orEmpty())
|
||||||
Database.localSubscriptionDao().insertAll(backupFile.localSubscriptions.orEmpty())
|
Database.localSubscriptionDao().insertAll(backupFile.subscriptions.orEmpty())
|
||||||
Database.customInstanceDao().insertAll(backupFile.customInstances.orEmpty())
|
Database.customInstanceDao().insertAll(backupFile.customInstances.orEmpty())
|
||||||
Database.playlistBookmarkDao().insertAll(backupFile.playlistBookmarks.orEmpty())
|
Database.playlistBookmarkDao().insertAll(backupFile.playlistBookmarks.orEmpty())
|
||||||
Database.subscriptionGroupsDao().insertAll(backupFile.channelGroups.orEmpty())
|
Database.subscriptionGroupsDao().insertAll(backupFile.groups.orEmpty())
|
||||||
|
|
||||||
backupFile.localPlaylists?.forEach {
|
backupFile.localPlaylists?.forEach {
|
||||||
// the playlist will be created with an id of 0, so that Room will auto generate a
|
// the playlist will be created with an id of 0, so that Room will auto generate a
|
||||||
@ -72,6 +72,7 @@ object BackupHelper {
|
|||||||
*/
|
*/
|
||||||
private fun restorePreferences(context: Context, preferences: List<PreferenceItem>?) {
|
private fun restorePreferences(context: Context, preferences: List<PreferenceItem>?) {
|
||||||
if (preferences == null) return
|
if (preferences == null) return
|
||||||
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).edit(commit = true) {
|
PreferenceManager.getDefaultSharedPreferences(context).edit(commit = true) {
|
||||||
// clear the previous settings
|
// clear the previous settings
|
||||||
clear()
|
clear()
|
||||||
|
@ -9,7 +9,6 @@ import com.github.libretube.api.PlaylistsHelper
|
|||||||
import com.github.libretube.api.RetrofitInstance
|
import com.github.libretube.api.RetrofitInstance
|
||||||
import com.github.libretube.api.SubscriptionHelper
|
import com.github.libretube.api.SubscriptionHelper
|
||||||
import com.github.libretube.db.DatabaseHolder.Database
|
import com.github.libretube.db.DatabaseHolder.Database
|
||||||
import com.github.libretube.db.obj.SubscriptionGroup
|
|
||||||
import com.github.libretube.enums.ImportFormat
|
import com.github.libretube.enums.ImportFormat
|
||||||
import com.github.libretube.extensions.TAG
|
import com.github.libretube.extensions.TAG
|
||||||
import com.github.libretube.extensions.toastFromMainDispatcher
|
import com.github.libretube.extensions.toastFromMainDispatcher
|
||||||
@ -19,8 +18,8 @@ import com.github.libretube.obj.FreetubeSubscriptions
|
|||||||
import com.github.libretube.obj.NewPipeSubscription
|
import com.github.libretube.obj.NewPipeSubscription
|
||||||
import com.github.libretube.obj.NewPipeSubscriptions
|
import com.github.libretube.obj.NewPipeSubscriptions
|
||||||
import com.github.libretube.obj.PipedImportPlaylist
|
import com.github.libretube.obj.PipedImportPlaylist
|
||||||
import com.github.libretube.obj.PipedBackupFile
|
import com.github.libretube.obj.PipedPlaylistFile
|
||||||
import com.github.libretube.obj.PipedChannelGroup
|
import com.github.libretube.ui.dialogs.ShareDialog
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.stream.Collectors
|
import java.util.stream.Collectors
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
@ -63,7 +62,7 @@ object ImportHelper {
|
|||||||
JsonHelper.json.decodeFromStream<NewPipeSubscriptions>(it)
|
JsonHelper.json.decodeFromStream<NewPipeSubscriptions>(it)
|
||||||
}
|
}
|
||||||
subscriptions?.subscriptions.orEmpty().map {
|
subscriptions?.subscriptions.orEmpty().map {
|
||||||
it.url.replace("https://www.youtube.com/channel/", "")
|
it.url.replace("${ShareDialog.YOUTUBE_FRONTEND_URL}/channel/", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,7 +71,7 @@ object ImportHelper {
|
|||||||
JsonHelper.json.decodeFromStream<FreetubeSubscriptions>(it)
|
JsonHelper.json.decodeFromStream<FreetubeSubscriptions>(it)
|
||||||
}
|
}
|
||||||
subscriptions?.subscriptions.orEmpty().map {
|
subscriptions?.subscriptions.orEmpty().map {
|
||||||
it.url.replace("https://www.youtube.com/channel/", "")
|
it.url.replace("${ShareDialog.YOUTUBE_FRONTEND_URL}/channel/", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +106,7 @@ object ImportHelper {
|
|||||||
when (importFormat) {
|
when (importFormat) {
|
||||||
ImportFormat.NEWPIPE -> {
|
ImportFormat.NEWPIPE -> {
|
||||||
val newPipeChannels = subs.map {
|
val newPipeChannels = subs.map {
|
||||||
NewPipeSubscription(it.name, 0, "https://www.youtube.com${it.url}")
|
NewPipeSubscription(it.name, 0, "${ShareDialog.YOUTUBE_FRONTEND_URL}${it.url}")
|
||||||
}
|
}
|
||||||
val newPipeSubscriptions = NewPipeSubscriptions(subscriptions = newPipeChannels)
|
val newPipeSubscriptions = NewPipeSubscriptions(subscriptions = newPipeChannels)
|
||||||
activity.contentResolver.openOutputStream(uri)?.use {
|
activity.contentResolver.openOutputStream(uri)?.use {
|
||||||
@ -117,7 +116,7 @@ object ImportHelper {
|
|||||||
|
|
||||||
ImportFormat.FREETUBE -> {
|
ImportFormat.FREETUBE -> {
|
||||||
val freeTubeChannels = subs.map {
|
val freeTubeChannels = subs.map {
|
||||||
FreetubeSubscription(it.name, "", "https://www.youtube.com${it.url}")
|
FreetubeSubscription(it.name, "", "${ShareDialog.YOUTUBE_FRONTEND_URL}${it.url}")
|
||||||
}
|
}
|
||||||
val freeTubeSubscriptions = FreetubeSubscriptions(subscriptions = freeTubeChannels)
|
val freeTubeSubscriptions = FreetubeSubscriptions(subscriptions = freeTubeChannels)
|
||||||
activity.contentResolver.openOutputStream(uri)?.use {
|
activity.contentResolver.openOutputStream(uri)?.use {
|
||||||
@ -141,7 +140,7 @@ object ImportHelper {
|
|||||||
when (importFormat) {
|
when (importFormat) {
|
||||||
ImportFormat.PIPED -> {
|
ImportFormat.PIPED -> {
|
||||||
val playlistFile = activity.contentResolver.openInputStream(uri)?.use {
|
val playlistFile = activity.contentResolver.openInputStream(uri)?.use {
|
||||||
JsonHelper.json.decodeFromStream<PipedBackupFile>(it)
|
JsonHelper.json.decodeFromStream<PipedPlaylistFile>(it)
|
||||||
}
|
}
|
||||||
importPlaylists.addAll(playlistFile?.playlists.orEmpty())
|
importPlaylists.addAll(playlistFile?.playlists.orEmpty())
|
||||||
|
|
||||||
@ -231,7 +230,7 @@ object ImportHelper {
|
|||||||
when (importFormat) {
|
when (importFormat) {
|
||||||
ImportFormat.PIPED -> {
|
ImportFormat.PIPED -> {
|
||||||
val playlists = PlaylistsHelper.exportPipedPlaylists()
|
val playlists = PlaylistsHelper.exportPipedPlaylists()
|
||||||
val playlistFile = PipedBackupFile("Piped", 1, playlists = playlists)
|
val playlistFile = PipedPlaylistFile(playlists = playlists)
|
||||||
|
|
||||||
activity.contentResolver.openOutputStream(uri)?.use {
|
activity.contentResolver.openOutputStream(uri)?.use {
|
||||||
JsonHelper.json.encodeToStream(playlistFile, it)
|
JsonHelper.json.encodeToStream(playlistFile, it)
|
||||||
@ -251,28 +250,4 @@ object ImportHelper {
|
|||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
suspend fun importGroups(activity: Activity, uri: Uri) {
|
|
||||||
val pipedFile = activity.contentResolver.openInputStream(uri)?.use {
|
|
||||||
JsonHelper.json.decodeFromStream<PipedBackupFile>(it)
|
|
||||||
} ?: return
|
|
||||||
|
|
||||||
pipedFile.groups.forEach {
|
|
||||||
val group = SubscriptionGroup(it.groupName, it.channels)
|
|
||||||
Database.subscriptionGroupsDao().createGroup(group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
suspend fun exportGroups(activity: Activity, uri: Uri) {
|
|
||||||
val channelGroups = Database.subscriptionGroupsDao().getAll().map {
|
|
||||||
PipedChannelGroup(it.name, it.channels)
|
|
||||||
}
|
|
||||||
val pipedFile = PipedBackupFile("Piped", 1, groups = channelGroups)
|
|
||||||
|
|
||||||
activity.contentResolver.openOutputStream(uri)?.use {
|
|
||||||
JsonHelper.json.encodeToStream(pipedFile, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -8,17 +8,43 @@ import com.github.libretube.db.obj.SearchHistoryItem
|
|||||||
import com.github.libretube.db.obj.SubscriptionGroup
|
import com.github.libretube.db.obj.SubscriptionGroup
|
||||||
import com.github.libretube.db.obj.WatchHistoryItem
|
import com.github.libretube.db.obj.WatchHistoryItem
|
||||||
import com.github.libretube.db.obj.WatchPosition
|
import com.github.libretube.db.obj.WatchPosition
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonNames
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
data class BackupFile(
|
data class BackupFile(
|
||||||
|
//
|
||||||
|
// some stuff for compatibility with Piped imports
|
||||||
|
//
|
||||||
|
val format: String = "Piped",
|
||||||
|
val version: Int = 1,
|
||||||
|
|
||||||
|
//
|
||||||
|
// only compatible with LibreTube itself, database objects
|
||||||
|
//
|
||||||
var watchHistory: List<WatchHistoryItem>? = emptyList(),
|
var watchHistory: List<WatchHistoryItem>? = emptyList(),
|
||||||
var watchPositions: List<WatchPosition>? = emptyList(),
|
var watchPositions: List<WatchPosition>? = emptyList(),
|
||||||
var searchHistory: List<SearchHistoryItem>? = emptyList(),
|
var searchHistory: List<SearchHistoryItem>? = emptyList(),
|
||||||
var localSubscriptions: List<LocalSubscription>? = emptyList(),
|
|
||||||
var customInstances: List<CustomInstance>? = emptyList(),
|
var customInstances: List<CustomInstance>? = emptyList(),
|
||||||
var playlistBookmarks: List<PlaylistBookmark>? = emptyList(),
|
var playlistBookmarks: List<PlaylistBookmark>? = emptyList(),
|
||||||
var localPlaylists: List<LocalPlaylistWithVideos>? = emptyList(),
|
|
||||||
|
//
|
||||||
|
// Preferences, stored as a key value map
|
||||||
|
//
|
||||||
var preferences: List<PreferenceItem>? = emptyList(),
|
var preferences: List<PreferenceItem>? = emptyList(),
|
||||||
var channelGroups: List<SubscriptionGroup>? = emptyList()
|
|
||||||
|
//
|
||||||
|
// Database objects with compatibility for Piped imports/exports
|
||||||
|
//
|
||||||
|
@JsonNames("groups", "channelGroups")
|
||||||
|
var groups: List<SubscriptionGroup>? = emptyList(),
|
||||||
|
|
||||||
|
@JsonNames("subscriptions", "localSubscriptions")
|
||||||
|
var subscriptions: List<LocalSubscription>? = emptyList(),
|
||||||
|
|
||||||
|
// playlists are exported in two different formats because the formats differ too much unfortunately
|
||||||
|
var localPlaylists: List<LocalPlaylistWithVideos>? = emptyList(),
|
||||||
|
var playlists: List<PipedImportPlaylist>? = emptyList(),
|
||||||
)
|
)
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
package com.github.libretube.obj
|
package com.github.libretube.obj
|
||||||
|
|
||||||
|
import com.github.libretube.ui.dialogs.ShareDialog
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class FreetubeSubscription(
|
data class FreetubeSubscription(
|
||||||
val name: String,
|
val name: String,
|
||||||
@SerialName("id") val serviceId: String,
|
@SerialName("id") val channelId: String,
|
||||||
val url: String = "https://www.youtube.com/channel/$serviceId"
|
val url: String = "${ShareDialog.YOUTUBE_FRONTEND_URL}/channel/$channelId"
|
||||||
)
|
)
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
package com.github.libretube.obj
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PipedBackupFile(
|
|
||||||
val format: String,
|
|
||||||
val version: Int,
|
|
||||||
val playlists: List<PipedImportPlaylist> = emptyList(),
|
|
||||||
val groups: List<PipedChannelGroup> = emptyList()
|
|
||||||
)
|
|
@ -1,9 +0,0 @@
|
|||||||
package com.github.libretube.obj
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PipedChannelGroup(
|
|
||||||
val groupName: String,
|
|
||||||
val channels: List<String>
|
|
||||||
)
|
|
@ -0,0 +1,10 @@
|
|||||||
|
package com.github.libretube.obj
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PipedPlaylistFile(
|
||||||
|
val format: String = "Piped",
|
||||||
|
val version: Int = 1,
|
||||||
|
val playlists: List<PipedImportPlaylist> = emptyList()
|
||||||
|
)
|
@ -13,7 +13,9 @@ import com.github.libretube.constants.IntentData
|
|||||||
import com.github.libretube.db.DatabaseHolder.Database
|
import com.github.libretube.db.DatabaseHolder.Database
|
||||||
import com.github.libretube.helpers.PreferenceHelper
|
import com.github.libretube.helpers.PreferenceHelper
|
||||||
import com.github.libretube.obj.BackupFile
|
import com.github.libretube.obj.BackupFile
|
||||||
|
import com.github.libretube.obj.PipedImportPlaylist
|
||||||
import com.github.libretube.obj.PreferenceItem
|
import com.github.libretube.obj.PreferenceItem
|
||||||
|
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -40,7 +42,7 @@ class BackupDialog : DialogFragment() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
data object LocalSubscriptions : BackupOption(R.string.local_subscriptions, onSelected = {
|
data object LocalSubscriptions : BackupOption(R.string.local_subscriptions, onSelected = {
|
||||||
it.localSubscriptions = Database.localSubscriptionDao().getAll()
|
it.subscriptions = Database.localSubscriptionDao().getAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
data object CustomInstances : BackupOption(R.string.backup_customInstances, onSelected = {
|
data object CustomInstances : BackupOption(R.string.backup_customInstances, onSelected = {
|
||||||
@ -53,10 +55,16 @@ class BackupDialog : DialogFragment() {
|
|||||||
|
|
||||||
data object LocalPlaylists : BackupOption(R.string.local_playlists, onSelected = {
|
data object LocalPlaylists : BackupOption(R.string.local_playlists, onSelected = {
|
||||||
it.localPlaylists = Database.localPlaylistsDao().getAll()
|
it.localPlaylists = Database.localPlaylistsDao().getAll()
|
||||||
|
it.playlists = it.localPlaylists?.map { (playlist, playlistVideos) ->
|
||||||
|
val videos = playlistVideos.map { item ->
|
||||||
|
"${ShareDialog.YOUTUBE_FRONTEND_URL}/watch?v=${item.videoId}"
|
||||||
|
}
|
||||||
|
PipedImportPlaylist(playlist.name, "playlist", "private", videos)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
data object SubscriptionGroups : BackupOption(R.string.channel_groups, onSelected = {
|
data object SubscriptionGroups : BackupOption(R.string.channel_groups, onSelected = {
|
||||||
it.channelGroups = Database.subscriptionGroupsDao().getAll()
|
it.groups = Database.subscriptionGroupsDao().getAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
data object Preferences : BackupOption(R.string.preferences, onSelected = { file ->
|
data object Preferences : BackupOption(R.string.preferences, onSelected = { file ->
|
||||||
|
@ -114,22 +114,6 @@ class BackupRestoreSettings : BasePreferenceFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val getChannelGroupsFile = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
|
|
||||||
it?.forEach { uri ->
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
ImportHelper.importGroups(requireActivity(), uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val createChannelGroupsFile = registerForActivityResult(ActivityResultContracts.CreateDocument(JSON)) {
|
|
||||||
it?.let { uri ->
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
ImportHelper.exportGroups(requireActivity(), uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createImportFormatDialog(
|
private fun createImportFormatDialog(
|
||||||
@StringRes titleStringId: Int,
|
@StringRes titleStringId: Int,
|
||||||
items: List<String>,
|
items: List<String>,
|
||||||
@ -195,18 +179,6 @@ class BackupRestoreSettings : BasePreferenceFragment() {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
val importChannelGroups = findPreference<Preference>("import_groups")
|
|
||||||
importChannelGroups?.setOnPreferenceClickListener {
|
|
||||||
getChannelGroupsFile.launch(arrayOf(JSON))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
val exportChannelGroups = findPreference<Preference>("export_groups")
|
|
||||||
exportChannelGroups?.setOnPreferenceClickListener {
|
|
||||||
createChannelGroupsFile.launch("piped-channel-groups.json")
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
childFragmentManager.setFragmentResultListener(
|
childFragmentManager.setFragmentResultListener(
|
||||||
BACKUP_DIALOG_REQUEST_KEY,
|
BACKUP_DIALOG_REQUEST_KEY,
|
||||||
this
|
this
|
||||||
|
@ -31,20 +31,6 @@
|
|||||||
|
|
||||||
</PreferenceCategory>
|
</PreferenceCategory>
|
||||||
|
|
||||||
<PreferenceCategory app:title="@string/channel_groups">
|
|
||||||
|
|
||||||
<Preference
|
|
||||||
android:icon="@drawable/ic_download_filled"
|
|
||||||
app:key="import_groups"
|
|
||||||
app:title="@string/import_groups" />
|
|
||||||
|
|
||||||
<Preference
|
|
||||||
android:icon="@drawable/ic_upload"
|
|
||||||
app:key="export_groups"
|
|
||||||
app:title="@string/export_groups" />
|
|
||||||
|
|
||||||
</PreferenceCategory>
|
|
||||||
|
|
||||||
<PreferenceCategory app:title="@string/app_backup">
|
<PreferenceCategory app:title="@string/app_backup">
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
|
Loading…
x
Reference in New Issue
Block a user