feat: Improve new user experience + adjust home load (#5491)

* Added necessary translations

* Added support for redirecting directly to IntentSettings

* Create HomeViewModel

* Used HomeViewModel

* Update app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt

Co-authored-by: Bnyro <82752168+Bnyro@users.noreply.github.com>

* Update app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt

Co-authored-by: Bnyro <82752168+Bnyro@users.noreply.github.com>

* Update app/src/main/java/com/github/libretube/ui/fragments/HomeFragment.kt

Co-authored-by: Bnyro <82752168+Bnyro@users.noreply.github.com>

* Swap elvis operator for if statement for improved readability.

* Move runSafely to separate file

* Change when statement by if statement

Co-authored-by: Bnyro <82752168+Bnyro@users.noreply.github.com>

* Format if statement

* * Remove LiveData properties;
* Change buttons style for consistency;
* Move updateIfChanged to a separate file;

---------

Co-authored-by: Bnyro <82752168+Bnyro@users.noreply.github.com>
This commit is contained in:
RafaRamos 2024-01-19 15:03:17 +00:00 committed by GitHub
parent 3cb99a712d
commit e31943f5ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 379 additions and 148 deletions

View File

@ -0,0 +1,7 @@
package com.github.libretube.extensions
import androidx.lifecycle.MutableLiveData
fun <T> MutableLiveData<T>.updateIfChanged(newValue: T) {
if (value != newValue) value = newValue
}

View File

@ -0,0 +1,21 @@
package com.github.libretube.extensions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun <T> runSafely(
onSuccess: (List<T>) -> Unit = { },
ioBlock: suspend () -> List<T>,
) {
withContext(Dispatchers.IO) {
val result = runCatching { ioBlock.invoke() }
.getOrNull()
?.takeIf { it.isNotEmpty() } ?: return@withContext
withContext(Dispatchers.Main) {
if (result.isNotEmpty()) {
onSuccess.invoke(result)
}
}
}
}

View File

@ -2,11 +2,13 @@ package com.github.libretube.ui.activities
import android.os.Bundle
import androidx.activity.addCallback
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import com.github.libretube.R
import com.github.libretube.databinding.ActivitySettingsBinding
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.preferences.InstanceSettings
import com.github.libretube.ui.preferences.MainSettings
class SettingsActivity : BaseActivity() {
@ -24,9 +26,7 @@ class SettingsActivity : BaseActivity() {
}
if (savedInstanceState == null) {
supportFragmentManager.commit {
replace<MainSettings>(R.id.settings)
}
redirectTo<MainSettings>()
}
// new way of dealing with back presses instead of onBackPressed()
@ -34,15 +34,32 @@ class SettingsActivity : BaseActivity() {
if (supportFragmentManager.findFragmentById(R.id.settings) is MainSettings) {
finishAndRemoveTask()
} else {
supportFragmentManager.commit {
replace<MainSettings>(R.id.settings)
}
redirectTo<MainSettings>()
changeTopBarText(getString(R.string.settings))
}
}
handleRedirect()
}
private fun handleRedirect() {
val redirectKey = intent.extras?.getString(REDIRECT_KEY)
if (redirectKey == REDIRECT_TO_INTENT_SETTINGS) redirectTo<InstanceSettings>()
}
fun changeTopBarText(text: String) {
if (this::binding.isInitialized) binding.toolbar.title = text
}
private inline fun <reified T : Fragment> redirectTo() {
supportFragmentManager.commit {
replace<T>(R.id.settings)
}
}
companion object {
const val REDIRECT_KEY = "redirect"
const val REDIRECT_TO_INTENT_SETTINGS = "intent_settings"
}
}

View File

@ -1,5 +1,6 @@
package com.github.libretube.ui.fragments
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -8,42 +9,34 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.api.obj.Playlists
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.PreferenceKeys.HOME_TAB_CONTENT
import com.github.libretube.databinding.FragmentHomeBinding
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.enums.ContentFilter
import com.github.libretube.helpers.LocaleHelper
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.db.obj.PlaylistBookmark
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.ui.activities.SettingsActivity
import com.github.libretube.ui.adapters.PlaylistBookmarkAdapter
import com.github.libretube.ui.adapters.PlaylistsAdapter
import com.github.libretube.ui.adapters.VideosAdapter
import com.github.libretube.ui.adapters.VideosAdapter.Companion.LayoutMode
import com.github.libretube.ui.models.HomeViewModel
import com.github.libretube.ui.models.SubscriptionsViewModel
import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import com.google.android.material.snackbar.Snackbar
class HomeFragment : Fragment() {
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!
private val subscriptionsViewModel: SubscriptionsViewModel by activityViewModels()
private val homeViewModel: HomeViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
@ -82,7 +75,50 @@ class HomeFragment : Fragment() {
fetchHomeFeed()
}
fetchHomeFeed()
binding.refreshButton.setOnClickListener {
fetchHomeFeed()
}
binding.changeInstance.setOnClickListener {
redirectToIntentSettings()
}
}
override fun onResume() {
super.onResume()
observeChanges()
// Avoid re-fetching when re-entering the screen if it was loaded successfully
if (homeViewModel.loadedSuccessfully.value == false) {
fetchHomeFeed()
}
}
private fun observeChanges() {
with (homeViewModel) {
trending.observe(viewLifecycleOwner, ::showTrending)
feed.observe(viewLifecycleOwner, ::showFeed)
bookmarks.observe(viewLifecycleOwner, ::showBookmarks)
playlists.observe(viewLifecycleOwner, ::showPlaylists)
continueWatching.observe(viewLifecycleOwner, ::showContinueWatching)
isLoading.observe(viewLifecycleOwner, ::updateLoading)
}
}
override fun onPause() {
super.onPause()
stopObservingChanges()
}
private fun stopObservingChanges() {
with (homeViewModel) {
trending.removeObserver(::showTrending)
feed.removeObserver(::showFeed)
bookmarks.removeObserver(::showBookmarks)
playlists.removeObserver(::showPlaylists)
continueWatching.removeObserver(::showContinueWatching)
isLoading.removeObserver(::updateLoading)
}
}
override fun onDestroyView() {
@ -91,118 +127,58 @@ class HomeFragment : Fragment() {
}
private fun fetchHomeFeed() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
binding.nothingHere.isGone = true
val defaultItems = resources.getStringArray(R.array.homeTabItemsValues)
val visibleItems = PreferenceHelper
.getStringSet(PreferenceKeys.HOME_TAB_CONTENT, defaultItems.toSet())
awaitAll(
async { if (visibleItems.contains(TRENDING)) loadTrending() },
async { if (visibleItems.contains(WATCHING)) loadVideosToContinueWatching() },
async { if (visibleItems.contains(BOOKMARKS)) loadBookmarks() },
async { if (visibleItems.contains(FEATURED)) loadFeed() },
async { if (visibleItems.contains(PLAYLISTS)) loadPlaylists() }
)
binding.nothingHere.isGone = true
val defaultItems = resources.getStringArray(R.array.homeTabItemsValues)
val visibleItems = PreferenceHelper.getStringSet(HOME_TAB_CONTENT, defaultItems.toSet())
val binding = _binding ?: return@repeatOnLifecycle
// No category is shown because they are either empty or disabled
if (binding.progress.isVisible) {
binding.progress.isGone = true
binding.nothingHere.isVisible = true
}
}
}
homeViewModel.loadHomeFeed(
context = requireContext(),
savedFeed = subscriptionsViewModel.videoFeed.value,
visibleItems = visibleItems,
onUnusualLoadTime = ::showChangeInstanceSnackBar
)
}
private suspend fun loadTrending() {
val region = LocaleHelper.getTrendingRegion(requireContext())
val trending = runCatching {
withContext(Dispatchers.IO) {
RetrofitInstance.api.getTrending(region).deArrow().take(10)
}
}.getOrNull()?.takeIf { it.isNotEmpty() } ?: return
val binding = _binding ?: return
private fun showTrending(streamItems: List<StreamItem>?) {
if (streamItems == null) return
makeVisible(binding.trendingRV, binding.trendingTV)
binding.trendingRV.layoutManager = GridLayoutManager(context, 2)
binding.trendingRV.adapter = VideosAdapter(
trending.toMutableList(),
forceMode = VideosAdapter.Companion.LayoutMode.TRENDING_ROW
streamItems.toMutableList(),
forceMode = LayoutMode.TRENDING_ROW
)
}
private suspend fun loadFeed() {
val savedFeed = subscriptionsViewModel.videoFeed.value
val feed = if (
PreferenceHelper.getBoolean(PreferenceKeys.SAVE_FEED, false) &&
!savedFeed.isNullOrEmpty()
) {
savedFeed
} else {
runCatching {
withContext(Dispatchers.IO) {
SubscriptionHelper.getFeed()
}
}.getOrNull()?.takeIf { it.isNotEmpty() } ?: return
}
val allowShorts = ContentFilter.SHORTS.isEnabled()
val allowVideos = ContentFilter.VIDEOS.isEnabled()
val allowAll = (!allowShorts && !allowVideos)
var filteredFeed = feed.filter {
(allowShorts && it.isShort) || (allowVideos && !it.isShort) || allowAll
}
if (PreferenceHelper.getBoolean(PreferenceKeys.HIDE_WATCHED_FROM_FEED, false)) {
filteredFeed = runBlocking { DatabaseHelper.filterUnwatched(filteredFeed) }
}
val binding = _binding ?: return
private fun showFeed(streamItems: List<StreamItem>?) {
if (streamItems == null) return
makeVisible(binding.featuredRV, binding.featuredTV)
binding.featuredRV.layoutManager = LinearLayoutManager(
context,
LinearLayoutManager.HORIZONTAL,
false
)
binding.featuredRV.adapter = VideosAdapter(
filteredFeed.take(20).toMutableList(),
forceMode = VideosAdapter.Companion.LayoutMode.RELATED_COLUMN
)
binding.featuredRV.setHasFixedSize(true)
val feedVideos = streamItems.take(20).toMutableList()
with (binding.featuredRV) {
layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
adapter = VideosAdapter(feedVideos, forceMode = LayoutMode.RELATED_COLUMN)
}
}
private suspend fun loadBookmarks() {
val bookmarkedPlaylists = withContext(Dispatchers.IO) {
DatabaseHolder.Database.playlistBookmarkDao().getAll()
}.takeIf { it.isNotEmpty() } ?: return
val binding = _binding ?: return
private fun showBookmarks(bookmarks: List<PlaylistBookmark>?) {
if (bookmarks == null) return
makeVisible(binding.bookmarksTV, binding.bookmarksRV)
binding.bookmarksRV.layoutManager = LinearLayoutManager(
context,
LinearLayoutManager.HORIZONTAL,
false
)
binding.bookmarksRV.adapter = PlaylistBookmarkAdapter(
bookmarkedPlaylists,
PlaylistBookmarkAdapter.Companion.BookmarkMode.HOME
)
with (binding.bookmarksRV) {
layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
adapter = PlaylistBookmarkAdapter(bookmarks.toMutableList())
}
}
private suspend fun loadPlaylists() {
val playlists = runCatching {
withContext(Dispatchers.IO) {
PlaylistsHelper.getPlaylists().take(20)
}
}.getOrNull()?.takeIf { it.isNotEmpty() } ?: return
val binding = _binding ?: return
private fun showPlaylists(playlists: List<Playlists>?) {
if (playlists == null) return
makeVisible(binding.playlistsRV, binding.playlistsTV)
binding.playlistsRV.layoutManager = LinearLayoutManager(context)
binding.playlistsRV.adapter = PlaylistsAdapter(
playlists.toMutableList(),
PlaylistsHelper.getPrivatePlaylistType()
playlistType = PlaylistsHelper.getPrivatePlaylistType()
)
binding.playlistsRV.adapter?.registerAdapterDataObserver(object :
RecyclerView.AdapterDataObserver() {
@ -216,46 +192,72 @@ class HomeFragment : Fragment() {
})
}
private suspend fun loadVideosToContinueWatching() {
if (!PlayerHelper.watchHistoryEnabled) return
val videos = withContext(Dispatchers.IO) {
DatabaseHolder.Database.watchHistoryDao().getAll()
}
val unwatchedVideos = DatabaseHelper.filterUnwatched(videos.map { it.toStreamItem() })
.reversed()
.take(20)
if (unwatchedVideos.isEmpty()) return
val binding = _binding ?: return
private fun showContinueWatching(unwatchedVideos: List<StreamItem>?) {
if (unwatchedVideos == null) return
makeVisible(binding.watchingRV, binding.watchingTV)
binding.watchingRV.layoutManager = LinearLayoutManager(
context,
LinearLayoutManager.HORIZONTAL,
false
)
binding.watchingRV.layoutManager = LinearLayoutManager(context, HORIZONTAL, false)
binding.watchingRV.adapter = VideosAdapter(
unwatchedVideos.toMutableList(),
forceMode = VideosAdapter.Companion.LayoutMode.RELATED_COLUMN
forceMode = LayoutMode.RELATED_COLUMN
)
}
private fun updateLoading(isLoading: Boolean) {
if (isLoading) {
showLoading()
} else {
hideLoading()
}
}
private fun showLoading() {
binding.progress.isVisible = !binding.refresh.isRefreshing
binding.nothingHere.isVisible = false
}
private fun hideLoading() {
binding.progress.isVisible = false
binding.refresh.isRefreshing = false
val hasContent = homeViewModel.loadedSuccessfully.value == true
if (hasContent) {
showContent()
} else {
showNothingHere()
}
}
private fun showNothingHere() {
binding.nothingHere.isVisible = true
binding.scroll.isVisible = false
}
private fun showContent() {
binding.nothingHere.isVisible = false
binding.scroll.isVisible = true
}
private fun showChangeInstanceSnackBar() {
val root = _binding?.root ?: return
Snackbar
.make(root, R.string.suggest_change_instance, Snackbar.LENGTH_LONG)
.apply {
setAction(R.string.change) {
redirectToIntentSettings()
}
show()
}
}
private fun redirectToIntentSettings() {
val settingsIntent = Intent(context, SettingsActivity::class.java).apply {
putExtra(SettingsActivity.REDIRECT_KEY, SettingsActivity.REDIRECT_TO_INTENT_SETTINGS)
}
startActivity(settingsIntent)
}
private fun makeVisible(vararg views: View) {
views.forEach {
it.isVisible = true
}
val binding = _binding ?: return
binding.progress.isGone = true
binding.scroll.isVisible = true
binding.refresh.isRefreshing = false
}
companion object {
// The values of the preference entries for the home tab content
private const val FEATURED = "featured"
private const val WATCHING = "watching"
private const val TRENDING = "trending"
private const val BOOKMARKS = "bookmarks"
private const val PLAYLISTS = "playlists"
views.forEach { it.isVisible = true }
}
}

View File

@ -0,0 +1,160 @@
package com.github.libretube.ui.models
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.api.obj.Playlists
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.PreferenceKeys.HIDE_WATCHED_FROM_FEED
import com.github.libretube.constants.PreferenceKeys.SAVE_FEED
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.db.obj.PlaylistBookmark
import com.github.libretube.enums.ContentFilter
import com.github.libretube.extensions.runSafely
import com.github.libretube.extensions.updateIfChanged
import com.github.libretube.helpers.LocaleHelper
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class HomeViewModel: ViewModel() {
private val useSavedFeed get() = PreferenceHelper.getBoolean(SAVE_FEED, false)
private val hideWatched get() = PreferenceHelper.getBoolean(HIDE_WATCHED_FROM_FEED, false)
val trending: MutableLiveData<List<StreamItem>> = MutableLiveData(null)
val feed: MutableLiveData<List<StreamItem>> = MutableLiveData(null)
val bookmarks: MutableLiveData<List<PlaylistBookmark>> = MutableLiveData(null)
val playlists: MutableLiveData<List<Playlists>> = MutableLiveData(null)
val continueWatching: MutableLiveData<List<StreamItem>> = MutableLiveData(null)
val isLoading: MutableLiveData<Boolean> = MutableLiveData(true)
val loadedSuccessfully: MutableLiveData<Boolean> = MutableLiveData(false)
private var loadHomeJob: Job? = null
fun loadHomeFeed(
context: Context,
savedFeed: List<StreamItem>? = null,
visibleItems: Set<String>,
onUnusualLoadTime: () -> Unit
) {
isLoading.value = true
loadHomeJob?.cancel()
loadHomeJob = viewModelScope.launch {
val result = async {
awaitAll(
async { if (visibleItems.contains(TRENDING)) loadTrending(context) },
async { if (visibleItems.contains(FEATURED)) loadFeed(savedFeed) },
async { if (visibleItems.contains(BOOKMARKS)) loadBookmarks() },
async { if (visibleItems.contains(PLAYLISTS)) loadPlaylists() },
async { if (visibleItems.contains(WATCHING)) loadVideosToContinueWatching() }
)
loadedSuccessfully.value = trending.value.isNullOrEmpty() == false
isLoading.value = false
}
withContext(Dispatchers.IO) {
delay(UNUSUAL_LOAD_TIME_MS)
if (result.isActive) {
onUnusualLoadTime.invoke()
}
}
}
}
private suspend fun loadTrending(context: Context) {
val region = LocaleHelper.getTrendingRegion(context)
runSafely(
onSuccess = { videos -> trending.updateIfChanged(videos) },
ioBlock = { RetrofitInstance.api.getTrending(region).deArrow().take(10) }
)
}
private suspend fun loadFeed(savedFeed: List<StreamItem>? = null) {
runSafely(
onSuccess = { videos -> feed.updateIfChanged(videos) },
ioBlock = { tryLoadFeed(savedFeed) }
)
}
private suspend fun loadBookmarks() {
runSafely(
onSuccess = { newBookmarks -> bookmarks.updateIfChanged(newBookmarks) },
ioBlock = { DatabaseHolder.Database.playlistBookmarkDao().getAll() }
)
}
private suspend fun loadPlaylists() {
runSafely(
onSuccess = { newPlaylists -> playlists.updateIfChanged(newPlaylists) },
ioBlock = { PlaylistsHelper.getPlaylists().take(20) }
)
}
private suspend fun loadVideosToContinueWatching() {
if (!PlayerHelper.watchHistoryEnabled) return
runSafely(
onSuccess = { videos -> continueWatching.updateIfChanged(videos) },
ioBlock = ::loadWatchingFromDB
)
}
private suspend fun loadWatchingFromDB(): List<StreamItem> {
val videos = DatabaseHolder.Database.watchHistoryDao().getAll()
return DatabaseHelper
.filterUnwatched(videos.map { it.toStreamItem() })
.reversed()
.take(20)
}
private suspend fun tryLoadFeed(savedFeed: List<StreamItem>?): List<StreamItem> {
val feed = if (useSavedFeed && !savedFeed.isNullOrEmpty()) {
savedFeed
} else {
SubscriptionHelper.getFeed()
}
return if (hideWatched) feed.filterWatched() else feed
}
private suspend fun List<StreamItem>.filterWatched(): List<StreamItem> {
val allowShorts = ContentFilter.SHORTS.isEnabled()
val allowVideos = ContentFilter.VIDEOS.isEnabled()
val allowAll = (!allowShorts && !allowVideos)
val filteredFeed = this.filter {
allowAll || (allowShorts && it.isShort) || (allowVideos && !it.isShort)
}
return runBlocking { DatabaseHelper.filterUnwatched(filteredFeed) }
}
companion object {
private const val UNUSUAL_LOAD_TIME_MS = 10000L
private const val FEATURED = "featured"
private const val WATCHING = "watching"
private const val TRENDING = "trending"
private const val BOOKMARKS = "bookmarks"
private const val PLAYLISTS = "playlists"
}
}

View File

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ProgressBar
android:id="@+id/progress"
@ -126,6 +127,26 @@
android:text="@string/emptyList"
android:textSize="20sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/refresh_button"
style="@style/Widget.Material3.Button.ElevatedButton"
android:layout_width="150dp"
android:layout_height="50dp"
android:layout_gravity="center"
android:layout_marginTop="30dp"
android:textSize="12sp"
android:text="@string/retry"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/change_instance"
style="@style/Widget.Material3.Button.ElevatedButton"
android:layout_width="150dp"
android:layout_height="50dp"
android:layout_gravity="center"
android:layout_marginTop="5dp"
android:textSize="12sp"
android:text="@string/change_instance"/>
</LinearLayout>
</FrameLayout>

View File

@ -301,6 +301,8 @@
<string name="play_next">Play next</string>
<string name="navigation_bar">Navigation bar</string>
<string name="change_region">Trending seems to be unavailable for the current region. Please select another in the settings.</string>
<string name="change_instance">Change instance</string>
<string name="suggest_change_instance">Loading is taking more than usual. Consider changing instance</string>
<string name="playback_pitch">Pitch</string>
<string name="filename">Filename</string>
<string name="invalid_filename">Invalid filename!</string>
@ -504,6 +506,7 @@
<string name="invalid_input">Invalid input</string>
<string name="add_to_group">Add to group</string>
<string name="uptime">%.2f%% uptime</string>
<string name="change">Change</string>
<!-- Notification channel strings -->
<string name="download_channel_name">Download Service</string>