diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b40de55..af7df66 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -111,6 +111,7 @@ dependencies { implementation("com.android.support.constraint:constraint-layout:1.1.3") implementation("com.google.android.exoplayer:exoplayer-core:2.11.5") + implementation("com.google.android.exoplayer:exoplayer-ui:2.11.5") implementation("com.google.android.exoplayer:extension-mediasession:2.11.5") implementation("com.aliassadi:power-preference-lib:1.4.1") diff --git a/app/src/main/java/com/github/apognu/otter/Otter.kt b/app/src/main/java/com/github/apognu/otter/Otter.kt index 81af4ac..d094f3b 100644 --- a/app/src/main/java/com/github/apognu/otter/Otter.kt +++ b/app/src/main/java/com/github/apognu/otter/Otter.kt @@ -2,11 +2,16 @@ package com.github.apognu.otter import android.app.Application import androidx.appcompat.app.AppCompatDelegate +import com.github.apognu.otter.playback.QueueManager import com.github.apognu.otter.utils.Cache import com.github.apognu.otter.utils.Command import com.github.apognu.otter.utils.Event import com.github.apognu.otter.utils.Request import com.google.android.exoplayer2.database.ExoDatabaseProvider +import com.google.android.exoplayer2.offline.DefaultDownloadIndex +import com.google.android.exoplayer2.offline.DefaultDownloaderFactory +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.android.exoplayer2.offline.DownloaderConstructorHelper import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor import com.google.android.exoplayer2.upstream.cache.SimpleCache import com.preference.PowerPreference @@ -30,8 +35,21 @@ class Otter : Application() { val requestBus: BroadcastChannel = BroadcastChannel(10) val progressBus: BroadcastChannel> = ConflatedBroadcastChannel() - var exoCache: SimpleCache? = null - var exoDatabase: ExoDatabaseProvider? = null + private val exoDatabase: ExoDatabaseProvider by lazy { ExoDatabaseProvider(this) } + val exoCache: SimpleCache by lazy { + PowerPreference.getDefaultFile().getInt("media_cache_size", 1).toLong().let { + SimpleCache( + cacheDir.resolve("media"), + LeastRecentlyUsedCacheEvictor(it * 1024 * 1024 * 1024), + exoDatabase + ) + } + } + val exoDownloadManager: DownloadManager by lazy { + DownloaderConstructorHelper(exoCache, QueueManager.factory(this)).run { + DownloadManager(this@Otter, DefaultDownloadIndex(exoDatabase), DefaultDownloaderFactory(this)) + } + } override fun onCreate() { super.onCreate() @@ -41,15 +59,6 @@ class Otter : Application() { Thread.setDefaultUncaughtExceptionHandler(CrashReportHandler()) instance = this - exoDatabase = ExoDatabaseProvider(this) - - PowerPreference.getDefaultFile().getInt("media_cache_size", 1).toLong().also { - exoCache = SimpleCache( - cacheDir.resolve("media"), - LeastRecentlyUsedCacheEvictor(it * 1024 * 1024 * 1024), - exoDatabase - ) - } when (PowerPreference.getDefaultFile().getString("night_mode")) { "on" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) diff --git a/app/src/main/java/com/github/apognu/otter/activities/DownloadsActivity.kt b/app/src/main/java/com/github/apognu/otter/activities/DownloadsActivity.kt index bfb71d1..e580260 100644 --- a/app/src/main/java/com/github/apognu/otter/activities/DownloadsActivity.kt +++ b/app/src/main/java/com/github/apognu/otter/activities/DownloadsActivity.kt @@ -1,17 +1,19 @@ package com.github.apognu.otter.activities import android.os.Bundle +import kotlinx.coroutines.flow.collect import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager import com.github.apognu.otter.R import com.github.apognu.otter.adapters.DownloadsAdapter import com.github.apognu.otter.utils.* -import com.google.gson.Gson +import com.google.android.exoplayer2.offline.Download import kotlinx.android.synthetic.main.activity_downloads.* +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class DownloadsActivity : AppCompatActivity() { lateinit var adapter: DownloadsAdapter @@ -21,17 +23,24 @@ class DownloadsActivity : AppCompatActivity() { setContentView(R.layout.activity_downloads) - adapter = DownloadsAdapter(this, RefreshListener()).also { + adapter = DownloadsAdapter(this, DownloadChangedListener()).also { downloads.layoutManager = LinearLayoutManager(this) downloads.adapter = it } + } - GlobalScope.launch(Main) { - while (true) { - refresh() - delay(1000) + override fun onResume() { + super.onResume() + + GlobalScope.launch(IO) { + EventBus.get().collect { event -> + if (event is Event.DownloadChanged) { + refreshTrack(event.download) + } } } + + refresh() } private fun refresh() { @@ -42,7 +51,7 @@ class DownloadsActivity : AppCompatActivity() { while (response.cursor.moveToNext()) { val download = response.cursor.download - Gson().fromJson(String(download.request.data), DownloadInfo::class.java)?.let { info -> + download.getMetadata()?.let { info -> adapter.downloads.add(info.apply { this.download = download }) @@ -54,9 +63,26 @@ class DownloadsActivity : AppCompatActivity() { } } - inner class RefreshListener : DownloadsAdapter.OnRefreshListener { - override fun refresh() { - this@DownloadsActivity.refresh() + private suspend fun refreshTrack(download: Download) { + if (download.state == Download.STATE_COMPLETED) { + download.getMetadata()?.let { info -> + adapter.downloads.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match -> + withContext(Main) { + adapter.downloads[match.second] = info.apply { + this.download = download + } + + adapter.notifyItemChanged(match.second) + } + } + } + } + } + + inner class DownloadChangedListener : DownloadsAdapter.OnDownloadChangedListener { + override fun onItemRemoved(index: Int) { + adapter.downloads.removeAt(index) + adapter.notifyDataSetChanged() } } } \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/adapters/DownloadsAdapter.kt b/app/src/main/java/com/github/apognu/otter/adapters/DownloadsAdapter.kt index 11f1b69..e5ef6af 100644 --- a/app/src/main/java/com/github/apognu/otter/adapters/DownloadsAdapter.kt +++ b/app/src/main/java/com/github/apognu/otter/adapters/DownloadsAdapter.kt @@ -8,14 +8,14 @@ import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.github.apognu.otter.R import com.github.apognu.otter.playback.PinService -import com.github.apognu.otter.utils.DownloadInfo +import com.github.apognu.otter.utils.* import com.google.android.exoplayer2.offline.Download import com.google.android.exoplayer2.offline.DownloadService import kotlinx.android.synthetic.main.row_download.view.* -class DownloadsAdapter(private val context: Context, private val listener: OnRefreshListener) : RecyclerView.Adapter() { - interface OnRefreshListener { - fun refresh() +class DownloadsAdapter(private val context: Context, private val listener: OnDownloadChangedListener) : RecyclerView.Adapter() { + interface OnDownloadChangedListener { + fun onItemRemoved(index: Int) } var downloads: MutableList = mutableListOf() @@ -38,7 +38,15 @@ class DownloadsAdapter(private val context: Context, private val listener: OnRef when (state.isTerminalState) { true -> { holder.progress.visibility = View.GONE - holder.toggle.visibility = View.GONE + + when (state.state) { + Download.STATE_FAILED -> { + holder.toggle.setImageDrawable(context.getDrawable(R.drawable.retry)) + holder.progress.visibility = View.GONE + } + + else -> holder.toggle.visibility = View.GONE + } } false -> { @@ -53,25 +61,29 @@ class DownloadsAdapter(private val context: Context, private val listener: OnRef } Download.STATE_STOPPED -> holder.toggle.setImageIcon(Icon.createWithResource(context, R.drawable.play)) + else -> holder.toggle.setImageIcon(Icon.createWithResource(context, R.drawable.pause)) } } } holder.toggle.setOnClickListener { - if (state.state == Download.STATE_DOWNLOADING) { - DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, 1, false) - } else { - DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, Download.STOP_REASON_NONE, false) - } + when (state.state) { + Download.STATE_DOWNLOADING -> DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, 1, false) + + Download.STATE_FAILED -> { + Track(download.id, download.title, Artist(0, download.artist, listOf()),Album(0, Album.Artist(""), "", Covers("")), 0, listOf(Track.Upload(download.contentId, 0, 0))).also { + PinService.download(context, it) + } + } - listener.refresh() + else -> DownloadService.sendSetStopReason(context, PinService::class.java, download.contentId, Download.STOP_REASON_NONE, false) + } } holder.delete.setOnClickListener { + listener.onItemRemoved(position) DownloadService.sendRemoveDownload(context, PinService::class.java, download.contentId, false) - - listener.refresh() } } } diff --git a/app/src/main/java/com/github/apognu/otter/fragments/TracksFragment.kt b/app/src/main/java/com/github/apognu/otter/fragments/TracksFragment.kt index 87a7506..8508715 100644 --- a/app/src/main/java/com/github/apognu/otter/fragments/TracksFragment.kt +++ b/app/src/main/java/com/github/apognu/otter/fragments/TracksFragment.kt @@ -11,13 +11,16 @@ import com.github.apognu.otter.adapters.TracksAdapter import com.github.apognu.otter.repositories.FavoritesRepository import com.github.apognu.otter.repositories.TracksRepository import com.github.apognu.otter.utils.* +import com.google.android.exoplayer2.offline.Download import com.squareup.picasso.Picasso import jp.wasabeef.picasso.transformations.RoundedCornersTransformation import kotlinx.android.synthetic.main.fragment_tracks.* +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class TracksFragment : FunkwhaleFragment() { override val viewRes = R.layout.fragment_tracks @@ -78,11 +81,15 @@ class TracksFragment : FunkwhaleFragment() { override fun onResume() { super.onResume() - GlobalScope.launch(Main) { + GlobalScope.launch(IO) { RequestBus.send(Request.GetCurrentTrack).wait()?.let { response -> - adapter.currentTrack = response.track - adapter.notifyDataSetChanged() + withContext(Main) { + adapter.currentTrack = response.track + adapter.notifyDataSetChanged() + } } + + refreshDownloadedTracks() } play.setOnClickListener { @@ -117,29 +124,46 @@ class TracksFragment : FunkwhaleFragment() { } private fun watchEventBus() { - GlobalScope.launch(Main) { + GlobalScope.launch(IO) { EventBus.get().collect { message -> when (message) { is Event.TrackPlayed -> refreshCurrentTrack() is Event.RefreshTrack -> refreshCurrentTrack() - is Event.DownloadChanged -> { - val downloaded = TracksRepository.getDownloadedIds() ?: listOf() + is Event.DownloadChanged -> refreshDownloadedTrack(message.download) + } + } + } + } + + private suspend fun refreshDownloadedTracks() { + val downloaded = TracksRepository.getDownloadedIds() ?: listOf() + + withContext(Main) { + adapter.data = adapter.data.map { + it.downloaded = downloaded.contains(it.id) + it + }.toMutableList() - adapter.data = adapter.data.map { - it.downloaded = downloaded.contains(it.id) - it - }.toMutableList() + adapter.notifyDataSetChanged() + } + } - adapter.notifyDataSetChanged() + private suspend fun refreshDownloadedTrack(download: Download) { + if (download.state == Download.STATE_COMPLETED) { + download.getMetadata()?.let { info -> + adapter.data.withIndex().associate { it.value to it.index }.filter { it.key.id == info.id }.toList().getOrNull(0)?.let { match -> + withContext(Main) { + adapter.data[match.second].downloaded = true + adapter.notifyItemChanged(match.second) } } } } } - private fun refreshCurrentTrack() { - GlobalScope.launch(Main) { - RequestBus.send(Request.GetCurrentTrack).wait()?.let { response -> + private suspend fun refreshCurrentTrack() { + RequestBus.send(Request.GetCurrentTrack).wait()?.let { response -> + withContext(Main) { adapter.currentTrack = response.track adapter.notifyDataSetChanged() } diff --git a/app/src/main/java/com/github/apognu/otter/playback/PinService.kt b/app/src/main/java/com/github/apognu/otter/playback/PinService.kt index 8c4f7d9..3140ad3 100644 --- a/app/src/main/java/com/github/apognu/otter/playback/PinService.kt +++ b/app/src/main/java/com/github/apognu/otter/playback/PinService.kt @@ -1,25 +1,45 @@ package com.github.apognu.otter.playback import android.app.Notification +import android.content.Context import android.content.Intent +import android.net.Uri import com.github.apognu.otter.Otter import com.github.apognu.otter.R import com.github.apognu.otter.utils.* -import com.google.android.exoplayer2.offline.* +import com.google.android.exoplayer2.offline.Download +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.android.exoplayer2.offline.DownloadRequest +import com.google.android.exoplayer2.offline.DownloadService import com.google.android.exoplayer2.scheduler.Scheduler import com.google.android.exoplayer2.ui.DownloadNotificationHelper +import com.google.gson.Gson import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import java.util.* class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) { - private val manager by lazy { - val database = Otter.get().exoDatabase - val cache = Otter.get().exoCache - val helper = DownloaderConstructorHelper(cache, QueueManager.factory(this)) + companion object { + fun download(context: Context, track: Track) { + track.bestUpload()?.let { upload -> + val url = mustNormalizeUrl(upload.listen_url) + val data = Gson().toJson( + DownloadInfo( + track.id, + url, + track.title, + track.artist.name, + null + ) + ).toByteArray() - DownloadManager(this, DefaultDownloadIndex(database), DefaultDownloaderFactory(helper)) + DownloadRequest(url, DownloadRequest.TYPE_PROGRESSIVE, Uri.parse(url), Collections.emptyList(), null, data).also { + sendAddDownload(context, PinService::class.java, it, false) + } + } + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -36,22 +56,25 @@ class PinService : DownloadService(AppContext.NOTIFICATION_DOWNLOADS) { return super.onStartCommand(intent, flags, startId) } - override fun getDownloadManager() = manager + override fun getDownloadManager() = Otter.get().exoDownloadManager.apply { + addListener(DownloadListener()) + } override fun getScheduler(): Scheduler? = null - override fun getForegroundNotification(downloads: MutableList?): Notification { - val quantity = downloads?.size ?: 0 - val description = resources.getQuantityString(R.plurals.downloads_description, quantity, quantity) + override fun getForegroundNotification(downloads: MutableList): Notification { + val description = resources.getQuantityString(R.plurals.downloads_description, downloads.size, downloads.size) return DownloadNotificationHelper(this, AppContext.NOTIFICATION_CHANNEL_DOWNLOADS).buildProgressNotification(R.drawable.downloads, null, description, downloads) } - override fun onDownloadChanged(download: Download?) { - super.onDownloadChanged(download) + private fun getDownloads() = downloadManager.downloadIndex.getDownloads() - EventBus.send(Event.DownloadChanged) - } + inner class DownloadListener : DownloadManager.Listener { + override fun onDownloadChanged(downloadManager: DownloadManager, download: Download) { + super.onDownloadChanged(downloadManager, download) - private fun getDownloads() = manager.downloadIndex.getDownloads() + EventBus.send(Event.DownloadChanged(download)) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt b/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt index 06fe63b..d9f26f5 100644 --- a/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt +++ b/app/src/main/java/com/github/apognu/otter/playback/PlayerService.kt @@ -8,7 +8,6 @@ import android.content.IntentFilter import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager -import android.net.Uri import android.os.Build import android.os.IBinder import android.support.v4.media.session.MediaSessionCompat @@ -16,13 +15,13 @@ import android.view.KeyEvent import com.github.apognu.otter.Otter import com.github.apognu.otter.R import com.github.apognu.otter.utils.* -import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.ExoPlaybackException +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.offline.DownloadRequest -import com.google.android.exoplayer2.offline.DownloadService.sendAddDownload import com.google.android.exoplayer2.source.TrackGroupArray import com.google.android.exoplayer2.trackselection.TrackSelectionArray -import com.google.gson.Gson import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.GlobalScope @@ -30,7 +29,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch -import java.util.* class PlayerService : Service() { private lateinit var queue: QueueManager @@ -88,7 +86,7 @@ class PlayerService : Service() { mediaControlsManager = MediaControlsManager(this, mediaSession) - player = ExoPlayerFactory.newSimpleInstance(this).apply { + player = SimpleExoPlayer.Builder(this).build().apply { playWhenReady = false playerEventListener = PlayerEventListener().also { @@ -98,12 +96,12 @@ class PlayerService : Service() { MediaSessionConnector(mediaSession).also { it.setPlayer(this) it.setMediaButtonEventHandler { player, _, mediaButtonEvent -> - mediaButtonEvent?.extras?.getParcelable(Intent.EXTRA_KEY_EVENT)?.let { key -> + mediaButtonEvent.extras?.getParcelable(Intent.EXTRA_KEY_EVENT)?.let { key -> if (key.action == KeyEvent.ACTION_UP) { when (key.keyCode) { KeyEvent.KEYCODE_MEDIA_PLAY -> state(true) KeyEvent.KEYCODE_MEDIA_PAUSE -> state(false) - KeyEvent.KEYCODE_MEDIA_NEXT -> player?.next() + KeyEvent.KEYCODE_MEDIA_NEXT -> player.next() KeyEvent.KEYCODE_MEDIA_PREVIOUS -> previousTrack() } } @@ -193,8 +191,8 @@ class PlayerService : Service() { is Command.SetRepeatMode -> player.repeatMode = message.mode - is Command.PinTrack -> download(message.track) - is Command.PinTracks -> message.tracks.forEach { download(it) } + is Command.PinTrack -> PinService.download(this@PlayerService, message.track) + is Command.PinTracks -> message.tracks.forEach { PinService.download(this@PlayerService, it) } } if (player.playWhenReady) { @@ -255,7 +253,7 @@ class PlayerService : Service() { state(false) player.release() - Otter.get().exoCache?.release() + Otter.get().exoCache.release() stopForeground(true) stopSelf() @@ -340,25 +338,6 @@ class PlayerService : Service() { player.seekTo(duration.toLong()) } - private fun download(track: Track) { - track.bestUpload()?.let { upload -> - val url = mustNormalizeUrl(upload.listen_url) - val data = Gson().toJson( - DownloadInfo( - track.id, - url, - track.title, - track.artist.name, - null - ) - ).toByteArray() - - DownloadRequest(url, DownloadRequest.TYPE_PROGRESSIVE, Uri.parse(url), Collections.emptyList(), null, data).also { - sendAddDownload(this@PlayerService, PinService::class.java, it, false) - } - } - } - inner class PlayerEventListener : Player.EventListener { override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { super.onPlayerStateChanged(playWhenReady, playbackState) diff --git a/app/src/main/java/com/github/apognu/otter/repositories/TracksRepository.kt b/app/src/main/java/com/github/apognu/otter/repositories/TracksRepository.kt index 252d532..f4b1818 100644 --- a/app/src/main/java/com/github/apognu/otter/repositories/TracksRepository.kt +++ b/app/src/main/java/com/github/apognu/otter/repositories/TracksRepository.kt @@ -4,7 +4,6 @@ import android.content.Context import com.github.apognu.otter.utils.* import com.github.kittinunf.fuel.gson.gsonDeserializerOf import com.google.android.exoplayer2.offline.Download -import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList @@ -26,7 +25,7 @@ class TracksRepository(override val context: Context?, albumId: Int) : Repositor while (response.cursor.moveToNext()) { val download = response.cursor.download - Gson().fromJson(String(download.request.data), DownloadInfo::class.java)?.let { + download.getMetadata()?.let { if (download.state == Download.STATE_COMPLETED) { ids.add(it.id) } diff --git a/app/src/main/java/com/github/apognu/otter/utils/EventBus.kt b/app/src/main/java/com/github/apognu/otter/utils/EventBus.kt index 67b7639..fe58128 100644 --- a/app/src/main/java/com/github/apognu/otter/utils/EventBus.kt +++ b/app/src/main/java/com/github/apognu/otter/utils/EventBus.kt @@ -1,6 +1,7 @@ package com.github.apognu.otter.utils import com.github.apognu.otter.Otter +import com.google.android.exoplayer2.offline.Download import com.google.android.exoplayer2.offline.DownloadCursor import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.GlobalScope @@ -47,7 +48,7 @@ sealed class Event { object QueueChanged : Event() object RadioStarted : Event() object ListingsChanged : Event() - object DownloadChanged : Event() + class DownloadChanged(val download: Download) : Event() } sealed class Request(var channel: Channel? = null) { diff --git a/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt b/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt index 7d22f5d..1b5b601 100644 --- a/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt +++ b/app/src/main/java/com/github/apognu/otter/utils/Extensions.kt @@ -6,6 +6,8 @@ import com.github.apognu.otter.R import com.github.apognu.otter.fragments.BrowseFragment import com.github.apognu.otter.repositories.Repository import com.github.kittinunf.fuel.core.Request +import com.google.android.exoplayer2.offline.Download +import com.google.gson.Gson import com.squareup.picasso.Picasso import com.squareup.picasso.RequestCreator import kotlinx.coroutines.Dispatchers.Main @@ -62,8 +64,8 @@ fun T.applyOnApi(api: Int, block: T.() -> T): T { } fun Picasso.maybeLoad(url: String?): RequestCreator { - if (url == null) return load(R.drawable.cover) - else return load(url) + return if (url == null) load(R.drawable.cover) + else load(url) } fun Request.authorize(): Request { @@ -73,3 +75,5 @@ fun Request.authorize(): Request { } } } + +fun Download.getMetadata(): DownloadInfo? = Gson().fromJson(String(this.request.data), DownloadInfo::class.java) diff --git a/app/src/main/res/drawable/retry.xml b/app/src/main/res/drawable/retry.xml new file mode 100644 index 0000000..8229a9a --- /dev/null +++ b/app/src/main/res/drawable/retry.xml @@ -0,0 +1,9 @@ + + +