Yt Music Fix

This commit is contained in:
shabinder 2021-02-22 23:08:33 +05:30
parent ec89357d4b
commit 1b58bbfcf0
21 changed files with 319 additions and 294 deletions

View File

@ -73,6 +73,7 @@ dependencies {
//DECOMPOSE //DECOMPOSE
implementation(Decompose.decompose) implementation(Decompose.decompose)
implementation(Decompose.extensionsCompose) implementation(Decompose.extensionsCompose)
/* /*
//Lifecycle //Lifecycle
Versions.androidLifecycle.let{ Versions.androidLifecycle.let{

View File

@ -56,6 +56,6 @@
android:value="rzp_live_3ZQeoFYOxjmXye" android:value="rzp_live_3ZQeoFYOxjmXye"
/> />
<service android:name="com.shabinder.spotiflyer.worker.ForegroundService"/> <service android:name="com.shabinder.common.di.worker.ForegroundService"/>
</application> </application>
</manifest> </manifest>

View File

@ -1,14 +1,18 @@
package com.shabinder.android package com.shabinder.android
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.lifecycle.lifecycleScope
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.jetbrains.rootComponent import com.arkivanov.decompose.extensions.compose.jetbrains.rootComponent
import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory
@ -20,12 +24,14 @@ import com.shabinder.common.di.Dir
import com.shabinder.common.di.FetchPlatformQueryResult import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.common.di.createDirectories import com.shabinder.common.di.createDirectories
import com.shabinder.common.models.DownloadStatus import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.root.SpotiFlyerRoot import com.shabinder.common.root.SpotiFlyerRoot
import com.shabinder.common.root.SpotiFlyerRootContent import com.shabinder.common.root.SpotiFlyerRootContent
import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks import com.shabinder.common.root.callbacks.SpotiFlyerRootCallBacks
import com.shabinder.common.ui.SpotiFlyerTheme import com.shabinder.common.ui.SpotiFlyerTheme
import com.shabinder.common.ui.colorOffWhite import com.shabinder.common.ui.colorOffWhite
import com.shabinder.database.Database import com.shabinder.database.Database
import com.tonyodev.fetch2.Status
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -44,6 +50,8 @@ class MainActivity : ComponentActivity() {
//TODO pass updates from Foreground Service //TODO pass updates from Foreground Service
private val downloadFlow = MutableStateFlow(hashMapOf<String, DownloadStatus>()) private val downloadFlow = MutableStateFlow(hashMapOf<String, DownloadStatus>())
private lateinit var updateUIReceiver: BroadcastReceiver
private lateinit var queryReceiver: BroadcastReceiver
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -95,6 +103,71 @@ class MainActivity : ComponentActivity() {
} }
} }
private fun initializeBroadcast(){
val intentFilter = IntentFilter().apply {
addAction(Status.QUEUED.name)
addAction(Status.FAILED.name)
addAction(Status.DOWNLOADING.name)
addAction("Progress")
addAction("Converting")
addAction("track_download_completed")
}
updateUIReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
//Update Flow with latest details
if (intent != null) {
val trackDetails = intent.getParcelableExtra<TrackDetails?>("track")
trackDetails?.let { track ->
lifecycleScope.launch {
val latestMap = downloadFlow.value.apply {
this[track.title] = when (intent.action) {
Status.QUEUED.name -> DownloadStatus.Queued
Status.FAILED.name -> DownloadStatus.Failed
Status.DOWNLOADING.name -> DownloadStatus.Downloading()
"Progress" -> DownloadStatus.Downloading(intent.getIntExtra("progress", 0))
"Converting" -> DownloadStatus.Converting
"track_download_completed" -> DownloadStatus.Downloaded
else -> DownloadStatus.NotDownloaded
}
}
downloadFlow.emit(latestMap)
}
}
}
}
}
val queryFilter = IntentFilter().apply { addAction("query_result") }
queryReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
//UI update here
if (intent != null){
@Suppress("UNCHECKED_CAST")
val trackList = intent.getSerializableExtra("tracks") as? HashMap<String, DownloadStatus>?
trackList?.let { list ->
Log.i("Service Response", "${list.size} Tracks Active")
lifecycleScope.launch {
downloadFlow.emit(list)
}
}
}
}
}
registerReceiver(updateUIReceiver, intentFilter)
registerReceiver(queryReceiver, queryFilter)
}
override fun onResume() {
super.onResume()
initializeBroadcast()
}
override fun onPause() {
super.onPause()
unregisterReceiver(updateUIReceiver)
unregisterReceiver(queryReceiver)
}
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntentFromExternalActivity(intent) handleIntentFromExternalActivity(intent)

View File

@ -1,7 +1,26 @@
package com.shabinder.android.di package com.shabinder.android.di
import com.shabinder.common.database.appContext
import com.tonyodev.fetch2.Fetch
import com.tonyodev.fetch2.FetchConfiguration
import org.koin.dsl.module import org.koin.dsl.module
val appModule = module { val appModule = module {
single { createFetchInstance() }
}
private fun createFetchInstance():Fetch{
val fetchConfiguration =
FetchConfiguration.Builder(appContext).run {
setNamespace("ForegroundDownloaderService")
setDownloadConcurrentLimit(4)
build()
}
return Fetch.run {
setDefaultInstanceConfiguration(fetchConfiguration)
getDefaultInstance()
}.apply {
removeAll() //Starting fresh
}
} }

View File

@ -12,6 +12,7 @@ import android.provider.Settings
import com.github.javiersantos.appupdater.AppUpdater import com.github.javiersantos.appupdater.AppUpdater
import com.github.javiersantos.appupdater.enums.Display import com.github.javiersantos.appupdater.enums.Display
import com.github.javiersantos.appupdater.enums.UpdateFrom import com.github.javiersantos.appupdater.enums.UpdateFrom
import com.tonyodev.fetch2.Fetch
fun Activity.checkIfLatestVersion() { fun Activity.checkIfLatestVersion() {
AppUpdater(this,0).run { AppUpdater(this,0).run {

View File

@ -10,6 +10,7 @@ allprojects {
maven(url = "https://jitpack.io") maven(url = "https://jitpack.io")
maven(url = "https://dl.bintray.com/ekito/koin") maven(url = "https://dl.bintray.com/ekito/koin")
maven(url = "https://kotlin.bintray.com/kotlinx/") maven(url = "https://kotlin.bintray.com/kotlinx/")
maven(url = "https://dl.bintray.com/icerockdev/moko")
maven(url = "https://kotlin.bintray.com/kotlin-js-wrappers/") maven(url = "https://kotlin.bintray.com/kotlin-js-wrappers/")
maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev") maven(url = "https://maven.pkg.jetbrains.space/public/p/compose/dev")
} }

View File

@ -29,7 +29,7 @@ object Versions {
const val minSdkVersion = 24 const val minSdkVersion = 24
const val compileSdkVersion = 30 const val compileSdkVersion = 30
const val targetSdkVersion = 29 const val targetSdkVersion = 29
const val androidLifecycle = "2.3.0-rc01" const val androidLifecycle = "2.3.0"
} }
object Koin { object Koin {
val core = "org.koin:koin-core:${Versions.koin}" val core = "org.koin:koin-core:${Versions.koin}"
@ -123,7 +123,7 @@ object Extras {
const val kermit = "co.touchlab:kermit:${Versions.kermit}" const val kermit = "co.touchlab:kermit:${Versions.kermit}"
object Android { object Android {
val razorpay = "com.razorpay:checkout:1.6.4" val razorpay = "com.razorpay:checkout:1.6.4"
val fetch = "androidx.tonyodev.fetch2:xfetch2:3.1.5" val fetch = "androidx.tonyodev.fetch2:xfetch2:3.1.6"
val appUpdator = "com.github.amitbd1508:AppUpdater:4.1.0" val appUpdator = "com.github.amitbd1508:AppUpdater:4.1.0"
} }
} }

View File

@ -70,14 +70,13 @@ internal class SpotiFlyerListStoreProvider(
} }
dispatch(Result.UpdateTrackList(list.toMutableList().updateTracksStatuses(downloadProgressFlow.value))) dispatch(Result.UpdateTrackList(list.toMutableList().updateTracksStatuses(downloadProgressFlow.value)))
} }
is Intent.StartDownload -> { is Intent.StartDownload -> {
downloadTracks(listOf(intent.track),fetchQuery.youtubeMusic::getYTIDBestMatch,dir::saveFileWithMetadata)
dispatch(Result.UpdateTrackItem(intent.track.apply { downloaded = DownloadStatus.Queued })) dispatch(Result.UpdateTrackItem(intent.track.apply { downloaded = DownloadStatus.Queued }))
} }
} }
} }
} }
private object ReducerImpl : Reducer<State, Result> { private object ReducerImpl : Reducer<State, Result> {
override fun State.reduce(result: Result): State = override fun State.reduce(result: Result): State =
when (result) { when (result) {

View File

@ -1,6 +1,7 @@
plugins { plugins {
id("multiplatform-compose-setup") id("multiplatform-compose-setup")
id("android-setup") id("android-setup")
id("kotlin-parcelize")
kotlin("plugin.serialization") kotlin("plugin.serialization")
} }
@ -8,6 +9,7 @@ kotlin {
sourceSets { sourceSets {
commonMain { commonMain {
dependencies { dependencies {
api("dev.icerock.moko:parcelize:0.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1")
} }
} }

View File

@ -17,8 +17,11 @@
package com.shabinder.common.models package com.shabinder.common.models
import com.shabinder.common.models.spotify.Source import com.shabinder.common.models.spotify.Source
import dev.icerock.moko.parcelize.Parcelable
import dev.icerock.moko.parcelize.Parcelize
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Parcelize
@Serializable @Serializable
data class TrackDetails( data class TrackDetails(
var title:String, var title:String,
@ -36,14 +39,15 @@ data class TrackDetails(
var progress: Int = 2,//2 for visual progress bar hint var progress: Int = 2,//2 for visual progress bar hint
var outputFilePath: String, var outputFilePath: String,
var videoID:String? = null var videoID:String? = null
) ):Parcelable
@Serializable @Serializable
sealed class DownloadStatus { sealed class DownloadStatus:Parcelable {
object Downloaded :DownloadStatus() @Parcelize object Downloaded :DownloadStatus()
data class Downloading(val progress: Int = 0):DownloadStatus() @Parcelize data class Downloading(val progress: Int = 2):DownloadStatus()
object Queued :DownloadStatus() @Parcelize object Queued :DownloadStatus()
object NotDownloaded :DownloadStatus() @Parcelize object NotDownloaded :DownloadStatus()
object Converting :DownloadStatus() @Parcelize object Converting :DownloadStatus()
object Failed :DownloadStatus() @Parcelize object Failed :DownloadStatus()
} }

View File

@ -32,6 +32,8 @@ kotlin {
androidMain { androidMain {
dependencies{ dependencies{
implementation(Ktor.clientAndroid) implementation(Ktor.clientAndroid)
implementation(Extras.Android.fetch)
implementation(Koin.android)
api(files("$rootDir/libs/mobile-ffmpeg.aar")) api(files("$rootDir/libs/mobile-ffmpeg.aar"))
} }
} }

View File

@ -3,7 +3,12 @@ package com.shabinder.common.di
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import androidx.core.content.ContextCompat
import com.github.kiulian.downloader.model.YoutubeVideo
import com.github.kiulian.downloader.model.formats.Format
import com.github.kiulian.downloader.model.quality.AudioQuality
import com.shabinder.common.database.appContext import com.shabinder.common.database.appContext
import com.shabinder.common.di.worker.ForegroundService
import com.shabinder.common.models.TrackDetails import com.shabinder.common.models.TrackDetails
actual fun openPlatform(packageID:String, platformLink:String){ actual fun openPlatform(packageID:String, platformLink:String){
@ -41,5 +46,25 @@ actual suspend fun downloadTracks(
getYTIDBestMatch:suspend (String,TrackDetails)->String?, getYTIDBestMatch:suspend (String,TrackDetails)->String?,
saveFileWithMetaData:suspend (mp3ByteArray:ByteArray, trackDetails: TrackDetails) -> Unit saveFileWithMetaData:suspend (mp3ByteArray:ByteArray, trackDetails: TrackDetails) -> Unit
){ ){
//TODO if(!list.isNullOrEmpty()){
val serviceIntent = Intent(appContext, ForegroundService::class.java)
serviceIntent.putParcelableArrayListExtra("object",ArrayList<TrackDetails>(list))
appContext.let { ContextCompat.startForegroundService(it, serviceIntent) }
}
}
fun YoutubeVideo.getData(): Format?{
return try {
findAudioWithQuality(AudioQuality.medium)?.get(0) as Format
} catch (e: java.lang.IndexOutOfBoundsException) {
try {
findAudioWithQuality(AudioQuality.high)?.get(0) as Format
} catch (e: java.lang.IndexOutOfBoundsException) {
try {
findAudioWithQuality(AudioQuality.low)?.get(0) as Format
} catch (e: java.lang.IndexOutOfBoundsException) {
null
}
}
}
} }

View File

@ -3,6 +3,7 @@ package com.shabinder.common.di
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.MediaScannerConnection
import android.os.Environment import android.os.Environment
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
@ -95,6 +96,7 @@ actual class Dir actual constructor(
.removeAllTags() .removeAllTags()
.setId3v1Tags(trackDetails) .setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails) .setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3")
} }
} }
Config.RETURN_CODE_CANCEL -> { Config.RETURN_CODE_CANCEL -> {
@ -107,6 +109,13 @@ actual class Dir actual constructor(
} }
} }
actual fun addToLibrary(path:String) {
logger.d{"Scanning File"}
MediaScannerConnection.scanFile(
appContext,
listOf(path).toTypedArray(), null,null)
}
actual suspend fun loadImage(url: String): ImageBitmap? { actual suspend fun loadImage(url: String): ImageBitmap? {
val cachePath = imageCacheDir() + getNameURL(url) val cachePath = imageCacheDir() + getNameURL(url)
return (loadCachedImage(cachePath) ?: freshImage(url))?.asImageBitmap() return (loadCachedImage(cachePath) ?: freshImage(url))?.asImageBitmap()

View File

@ -34,7 +34,7 @@ actual class YoutubeProvider actual constructor(
private val logger: Kermit, private val logger: Kermit,
private val dir: Dir, private val dir: Dir,
){ ){
private val ytDownloader: YoutubeDownloader = YoutubeDownloader() val ytDownloader: YoutubeDownloader = YoutubeDownloader()
/* /*
* YT Album Art Schema * YT Album Art Schema
* HI-RES Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg" * HI-RES Url: https://i.ytimg.com/vi/$searchId/maxresdefault.jpg"

View File

@ -14,7 +14,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.shabinder.android.worker package com.shabinder.common.di.worker
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.* import android.app.*
@ -24,42 +24,30 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.* import android.os.*
import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import com.arthenica.mobileffmpeg.Config import co.touchlab.kermit.Kermit
import com.arthenica.mobileffmpeg.Config.RETURN_CODE_CANCEL
import com.arthenica.mobileffmpeg.Config.RETURN_CODE_SUCCESS
import com.arthenica.mobileffmpeg.FFmpeg
import com.github.kiulian.downloader.YoutubeDownloader import com.github.kiulian.downloader.YoutubeDownloader
import com.mpatric.mp3agic.Mp3File import com.shabinder.common.di.Dir
import com.shabinder.android.worker.removeAllTags import com.shabinder.common.di.FetchPlatformQueryResult
import com.shabinder.android.worker.setId3v1Tags import com.shabinder.common.di.getData
import com.shabinder.android.worker.setId3v2Tags import com.shabinder.common.models.DownloadStatus
import com.shabinder.spotiflyer.di.Directories import com.shabinder.common.models.TrackDetails
import com.shabinder.spotiflyer.models.DownloadStatus import com.shabinder.common.ui.R.*
import com.shabinder.spotiflyer.models.TrackDetails
import com.shabinder.spotiflyer.models.spotify.Source
import com.shabinder.spotiflyer.networking.YoutubeMusicApi
import com.shabinder.spotiflyer.networking.makeJsonBody
import com.shabinder.spotiflyer.providers.getYTTracks
import com.shabinder.spotiflyer.providers.sortByBestMatch
import com.shabinder.spotiflyer.utils.*
import com.tonyodev.fetch2.* import com.tonyodev.fetch2.*
import com.tonyodev.fetch2core.DownloadBlock import com.tonyodev.fetch2core.DownloadBlock
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.* import kotlinx.coroutines.*
import retrofit2.Call import org.koin.android.ext.android.inject
import retrofit2.Callback
import retrofit2.Response
import java.io.File import java.io.File
import java.util.* import java.util.*
import kotlin.coroutines.CoroutineContext
class ForegroundService : Service(){ class ForegroundService : Service(),CoroutineScope{
private val tag = "Foreground Service" private val tag: String = "Foreground Service"
private val channelId = "ForegroundDownloaderService" private val channelId = "ForegroundDownloaderService"
private val notificationId = 101 private val notificationId = 101
private var total = 0 //Total Downloads Requested private var total = 0 //Total Downloads Requested
@ -69,29 +57,33 @@ class ForegroundService : Service(){
private val isFinished: Boolean private val isFinished: Boolean
get() = converted + failed == total get() = converted + failed == total
private var isSingleDownload: Boolean = false private var isSingleDownload: Boolean = false
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private lateinit var serviceJob :Job
override val coroutineContext: CoroutineContext
get() = serviceJob + Dispatchers.IO
private val requestMap = hashMapOf<Request, TrackDetails>() private val requestMap = hashMapOf<Request, TrackDetails>()
private val allTracksStatus = hashMapOf<String, DownloadStatus>() private val allTracksStatus = hashMapOf<String, DownloadStatus>()
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false private var isServiceStarted = false
private var messageList = mutableListOf("", "", "", "","") private var messageList = mutableListOf("", "", "", "","")
private lateinit var cancelIntent:PendingIntent private lateinit var cancelIntent:PendingIntent
private lateinit var fetch:Fetch
private lateinit var downloadManager : DownloadManager private lateinit var downloadManager : DownloadManager
@Inject lateinit var ytDownloader: YoutubeDownloader
@Inject lateinit var youtubeMusicApi: YoutubeMusicApi private val fetcher: FetchPlatformQueryResult by inject()
@Inject lateinit var directories:Directories private val logger: Kermit by inject()
private val defaultDir private val fetch: Fetch by inject()
get() = directories.defaultDir() private val dir: Dir by inject()
private val imageDir private val ytDownloader:YoutubeDownloader
get() = directories.imageDir() get() = fetcher.youtubeProvider.ytDownloader
override fun onBind(intent: Intent): IBinder? = null override fun onBind(intent: Intent): IBinder? = null
@SuppressLint("UnspecifiedImmutableFlag") @SuppressLint("UnspecifiedImmutableFlag")
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
serviceJob = SupervisorJob()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(channelId,"Downloader Service") createNotificationChannel(channelId,"Downloader Service")
} }
@ -101,14 +93,15 @@ class ForegroundService : Service(){
).apply{action = "kill"} ).apply{action = "kill"}
cancelIntent = PendingIntent.getService (this, 0 , intent , FLAG_CANCEL_CURRENT ) cancelIntent = PendingIntent.getService (this, 0 , intent , FLAG_CANCEL_CURRENT )
downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
initialiseFetch() fetch.removeAllListeners().addListener(fetchListener)
} }
@SuppressLint("WakelockTimeout") @SuppressLint("WakelockTimeout")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Send a notification that service is started // Send a notification that service is started
log(tag, "Service Started.") Log.i(tag,"Foreground Service Started.")
startForeground(notificationId, getNotification()) startForeground(notificationId, getNotification())
intent?.let{ intent?.let{
when (it.action) { when (it.action) {
"kill" -> killService() "kill" -> killService()
@ -126,15 +119,6 @@ class ForegroundService : Service(){
val downloadObjects: ArrayList<TrackDetails>? = (it.getParcelableArrayListExtra("object") ?: it.extras?.getParcelableArrayList( val downloadObjects: ArrayList<TrackDetails>? = (it.getParcelableArrayListExtra("object") ?: it.extras?.getParcelableArrayList(
"object" "object"
)) ))
val imagesList: ArrayList<String>? = (it.getStringArrayListExtra("imagesList") ?: it.extras?.getStringArrayList(
"imagesList"
))
imagesList?.let{ imageList ->
serviceScope.launch {
downloadAllImages(imageList)
}
}
downloadObjects?.let { list -> downloadObjects?.let { list ->
downloadObjects.size.let { size -> downloadObjects.size.let { size ->
@ -153,8 +137,8 @@ class ForegroundService : Service(){
//Service Already Started //Service Already Started
START_STICKY START_STICKY
} else{ } else{
log(tag, "Starting the foreground service task")
isServiceStarted = true isServiceStarted = true
Log.i(tag,"Starting the foreground service task")
wakeLock = wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply {
@ -170,74 +154,51 @@ class ForegroundService : Service(){
**/ **/
private fun downloadAllTracks(trackList: List<TrackDetails>) { private fun downloadAllTracks(trackList: List<TrackDetails>) {
trackList.forEach { trackList.forEach {
serviceScope.launch { launch {
if (!it.videoID.isNullOrBlank()) {//Video ID already known! if (!it.videoID.isNullOrBlank()) {//Video ID already known!
downloadTrack(it.videoID!!, it) downloadTrack(it.videoID!!, it)
} else { } else {
val searchQuery = "${it.title} - ${it.artists.joinToString(",")}" val searchQuery = "${it.title} - ${it.artists.joinToString(",")}"
val jsonBody = makeJsonBody(searchQuery.trim()).toJsonString() val videoID = fetcher.youtubeMusic.getYTIDBestMatch(searchQuery,it)
youtubeMusicApi.getYoutubeMusicResponse(jsonBody).enqueue( logger.d("Service VideoID") { videoID ?: "Not Found" }
object : Callback<String> { if (videoID.isNullOrBlank()) {
override fun onResponse(
call: Call<String>,
response: Response<String>
) {
serviceScope.launch {
val videoId = sortByBestMatch(
getYTTracks(response.body().toString()),
trackName = it.title,
trackArtists = it.artists,
trackDurationSec = it.durationSec
).keys.firstOrNull()
log("Service VideoID", videoId ?: "Not Found")
if (videoId.isNullOrBlank()) {
sendTrackBroadcast(Status.FAILED.name, it) sendTrackBroadcast(Status.FAILED.name, it)
failed++ failed++
updateNotification() updateNotification()
allTracksStatus[it.title] = DownloadStatus.Failed allTracksStatus[it.title] = DownloadStatus.Failed
} else {//Found Youtube Video ID } else {//Found Youtube Video ID
downloadTrack(videoId, it) downloadTrack(videoID, it)
} }
} }
} }
override fun onFailure(call: Call<String>, t: Throwable) {
if (t.message.toString()
.contains("Failed to connect")
) showDialog("Failed, Check Your Internet Connection!")
log("YT API Req. Fail", t.message.toString())
}
}
)
}
}
} }
} }
fun downloadTrack(videoID:String,track: TrackDetails){
serviceScope.launch(Dispatchers.IO) { private fun downloadTrack(videoID:String, track: TrackDetails){
launch {
try { try {
val audioData = ytDownloader.getVideo(videoID).getData() val audioData = ytDownloader.getVideo(videoID).getData()
audioData?.let { audioData?.let {
val url: String = it.url() val url: String = it.url()
log("DHelper Link Found", url) logger.d("DHelper Link Found") { url }
val request= Request(url, track.outputFile).apply{ val request= Request(url, track.outputFilePath).apply{
priority = Priority.NORMAL priority = Priority.NORMAL
networkType = NetworkType.ALL networkType = NetworkType.ALL
} }
fetch.enqueue(request, fetch.enqueue(request,
{ request1 -> { request1 ->
requestMap[request1] = track requestMap[request1] = track
log(tag, "Enqueuing Download") logger.d(tag){"Enqueuing Download"}
}, },
{ error -> { error ->
log(tag, "Enqueuing Error:${error.throwable.toString()}") logger.d(tag){"Enqueuing Error:${error.throwable.toString()}"}
} }
) )
} }
}catch (e: java.lang.Exception){ }catch (e: java.lang.Exception){
log("Service YT Error", e.message.toString()) logger.d("Service YT Error"){e.message.toString()}
} }
} }
} }
@ -266,12 +227,12 @@ class ForegroundService : Service(){
downloadBlocks: List<DownloadBlock>, downloadBlocks: List<DownloadBlock>,
totalBlocks: Int totalBlocks: Int
) { ) {
serviceScope.launch { launch {
val track = requestMap[download.request] val track = requestMap[download.request]
addToNotification("Downloading ${track?.title}") addToNotification("Downloading ${track?.title}")
log(tag, "${track?.title} Download Started") logger.d(tag){"${track?.title} Download Started"}
track?.let{ track?.let{
allTracksStatus[it.title] = DownloadStatus.Downloading allTracksStatus[it.title] = DownloadStatus.Downloading()
sendTrackBroadcast(Status.DOWNLOADING.name,track) sendTrackBroadcast(Status.DOWNLOADING.name,track)
} }
} }
@ -290,20 +251,20 @@ class ForegroundService : Service(){
} }
override fun onCompleted(download: Download) { override fun onCompleted(download: Download) {
serviceScope.launch { launch {
val track = requestMap[download.request] val track = requestMap[download.request]
removeFromNotification("Downloading ${track?.title}") removeFromNotification("Downloading ${track?.title}")
try{ try{
track?.let { track?.let {
convertToMp3(download.file, it) dir.saveFileWithMetadata(byteArrayOf(),it)
allTracksStatus[it.title] = DownloadStatus.Converting allTracksStatus[it.title] = DownloadStatus.Converting
} }
log(tag, "${track?.title} Download Completed") logger.d(tag){"${track?.title} Download Completed"}
}catch ( }catch (
e: KotlinNullPointerException e: KotlinNullPointerException
){ ){
log(tag, "${track?.title} Download Failed! Error:Fetch!!!!") logger.d(tag){"${track?.title} Download Failed! Error:Fetch!!!!"}
log(tag, "${track?.title} Requesting Download thru Android DM") logger.d(tag){"${track?.title} Requesting Download thru Android DM"}
downloadUsingDM(download.request.url, download.request.file, track!!) downloadUsingDM(download.request.url, download.request.file, track!!)
downloaded++ downloaded++
requestMap.remove(download.request) requestMap.remove(download.request)
@ -324,11 +285,11 @@ class ForegroundService : Service(){
} }
override fun onError(download: Download, error: Error, throwable: Throwable?) { override fun onError(download: Download, error: Error, throwable: Throwable?) {
serviceScope.launch { launch {
val track = requestMap[download.request] val track = requestMap[download.request]
downloaded++ downloaded++
log(tag, download.error.throwable.toString()) logger.d(tag){download.error.throwable.toString()}
log(tag, "${track?.title} Requesting Download thru Android DM") logger.d(tag){"${track?.title} Requesting Download thru Android DM"}
downloadUsingDM(download.request.url, download.request.file, track!!) downloadUsingDM(download.request.url, download.request.file, track!!)
requestMap.remove(download.request) requestMap.remove(download.request)
removeFromNotification("Downloading ${track.title}") removeFromNotification("Downloading ${track.title}")
@ -345,24 +306,28 @@ class ForegroundService : Service(){
etaInMilliSeconds: Long, etaInMilliSeconds: Long,
downloadedBytesPerSecond: Long downloadedBytesPerSecond: Long
) { ) {
serviceScope.launch { launch {
val track = requestMap[download.request] requestMap[download.request]?.run {
log(tag, "${track?.title} ETA: ${etaInMilliSeconds / 1000} sec") allTracksStatus[title] = DownloadStatus.Downloading(download.progress)
logger.d(tag){"${title} ETA: ${etaInMilliSeconds / 1000} sec"}
val intent = Intent().apply { val intent = Intent().apply {
action = "Progress" action = "Progress"
putExtra("progress", download.progress) putExtra("progress", download.progress)
putExtra("track", requestMap[download.request]) putExtra("track", this@run)
} }
sendBroadcast(intent) sendBroadcast(intent)
} }
} }
} }
}
/** /**
* If fetch Fails , Android Download Manager To RESCUE!! * If fetch Fails , Android Download Manager To RESCUE!!
**/ **/
fun downloadUsingDM(url: String, outputDir: String, track: TrackDetails){ fun downloadUsingDM(url: String, outputDir: String, track: TrackDetails){
serviceScope.launch { launch {
val uri = Uri.parse(url) val uri = Uri.parse(url)
val request = DownloadManager.Request(uri).apply { val request = DownloadManager.Request(uri).apply {
setAllowedNetworkTypes( setAllowedNetworkTypes(
@ -378,7 +343,7 @@ class ForegroundService : Service(){
//Start Download //Start Download
val downloadID = downloadManager.enqueue(request) val downloadID = downloadManager.enqueue(request)
log("DownloadManager", "Download Request Sent") logger.d("DownloadManager"){"Download Request Sent"}
val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() { val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@ -387,8 +352,7 @@ class ForegroundService : Service(){
//Checking if the received broadcast is for our enqueued download by matching download id //Checking if the received broadcast is for our enqueued download by matching download id
if (downloadID == id) { if (downloadID == id) {
allTracksStatus[track.title] = DownloadStatus.Converting allTracksStatus[track.title] = DownloadStatus.Converting
convertToMp3(outputDir, track) launch { dir.saveFileWithMetadata(byteArrayOf(),track);converted++ }
converted++
//Unregister this broadcast Receiver //Unregister this broadcast Receiver
this@ForegroundService.unregisterReceiver(this) this@ForegroundService.unregisterReceiver(this)
} }
@ -398,69 +362,7 @@ class ForegroundService : Service(){
} }
} }
/**
*Converting Downloaded Audio (m4a) to Mp3.( Also Applying Metadata)
**/
fun convertToMp3(filePath: String, track: TrackDetails){
serviceScope.launch {
sendTrackBroadcast("Converting",track)
val m4aFile = File(filePath)
addToNotification("Processing ${track.title}")
FFmpeg.executeAsync(
"-i $filePath -y -b:a 160k -acodec libmp3lame -vn ${filePath.substringBeforeLast('.') + ".mp3"}"
) { _, returnCode ->
when (returnCode) {
RETURN_CODE_SUCCESS -> {
log(Config.TAG, "Async command execution completed successfully.")
removeFromNotification("Processing ${track.title}")
m4aFile.delete()
writeMp3Tags(filePath.substringBeforeLast('.') + ".mp3", track)
//FFMPEG task Completed
}
RETURN_CODE_CANCEL -> {
log(Config.TAG, "Async command execution cancelled by user.")
}
else -> {
log(
Config.TAG, String.format(
"Async command execution failed with rc=%d.",
returnCode
)
)
}
}
}
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private fun writeMp3Tags(filePath: String, track: TrackDetails){
serviceScope.launch {
var mp3File = Mp3File(filePath)
mp3File = removeAllTags(mp3File)
mp3File = setId3v1Tags(mp3File, track)
mp3File = setId3v2Tags(mp3File, track,this@ForegroundService)
log("Mp3Tags", "saving file")
mp3File.save(filePath.substringBeforeLast('.') + ".new.mp3")
val file = File(filePath)
file.delete()
val newFile = File((filePath.substringBeforeLast('.') + ".new.mp3"))
newFile.renameTo(file)
converted++
updateNotification()
addToLibrary(file.absolutePath)
allTracksStatus.remove(track.title)
//Notify Download Completed
sendTrackBroadcast("track_download_completed",track)
//All tasks completed (REST IN PEACE)
if(isFinished && !isSingleDownload){
delay(5000)
onDestroy()
}
}
}
/** /**
* This is the method that can be called to update the Notification * This is the method that can be called to update the Notification
@ -472,7 +374,7 @@ class ForegroundService : Service(){
} }
private fun releaseWakeLock() { private fun releaseWakeLock() {
log(tag, "Releasing Wake Lock") logger.d(tag){"Releasing Wake Lock"}
try { try {
wakeLock?.let { wakeLock?.let {
if (it.isHeld) { if (it.isHeld) {
@ -480,7 +382,7 @@ class ForegroundService : Service(){
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
log(tag, "Service stopped without being started: ${e.message}") logger.d(tag){"Service stopped without being started: ${e.message}"}
} }
isServiceStarted = false isServiceStarted = false
} }
@ -501,7 +403,7 @@ class ForegroundService : Service(){
* Cleaning All Residual Files except Mp3 Files * Cleaning All Residual Files except Mp3 Files
**/ **/
private fun cleanFiles(dir: File) { private fun cleanFiles(dir: File) {
log(tag, "Starting Cleaning in ${dir.path} ") logger.d(tag){"Starting Cleaning in ${dir.path} "}
val fList = dir.listFiles() val fList = dir.listFiles()
fList?.let { fList?.let {
for (file in fList) { for (file in fList) {
@ -509,7 +411,7 @@ class ForegroundService : Service(){
cleanFiles(file) cleanFiles(file)
} else if(file.isFile) { } else if(file.isFile) {
if(file.path.toString().substringAfterLast(".") != "mp3"){ if(file.path.toString().substringAfterLast(".") != "mp3"){
log(tag, "Cleaning ${file.path}") logger.d(tag){ "Cleaning ${file.path}"}
file.delete() file.delete()
} }
} }
@ -517,76 +419,18 @@ class ForegroundService : Service(){
} }
} }
/*
* Add File to Android's Media Library.
* */
private fun addToLibrary(path:String) {
log(tag,"Scanning File")
MediaScannerConnection.scanFile(this,
listOf(path).toTypedArray(), null,null)
}
/**
* Function to fetch all Images for use in mp3 tags.
**/
suspend fun downloadAllImages(urlList: ArrayList<String>, func: ((resource:File) -> Unit)? = null) {
/*
* Last Element of this List defines Its Source
* */
val source = urlList.last()
log("Image","Fetching All ")
for (url in urlList.subList(0, urlList.size - 1)) {
log("Image","Fetching")
val imgUri = url.toUri().buildUpon().scheme("https").build()
val r = ImageRequest.Builder(this@ForegroundService)
.data(imgUri)
.build()
val bitmap = Coil.execute(r).drawable?.toBitmap()
val file = when (source) {
Source.Spotify.name -> {
File(imageDir, url.substringAfterLast('/') + ".jpeg")
}
Source.YouTube.name -> {
File(
imageDir,
url.substringBeforeLast('/', url)
.substringAfterLast(
'/',
url
) + ".jpeg"
)
}
Source.Gaana.name -> {
File(
imageDir,
(url.substringBeforeLast('/').substringAfterLast(
'/'
)) + ".jpeg"
)
}
else -> File(imageDir, url.substringAfterLast('/') + ".jpeg")
}
if (bitmap != null) {
file.writeBitmap(bitmap)
func?.let { it(file) }
log("Image","Saved")
} else log("Foreground Service", "Album Art Could Not be Fetched")
}
}
private fun killService() { private fun killService() {
serviceScope.launch{ launch{
log(tag,"Killing Self") logger.d(tag){"Killing Self"}
messageList = mutableListOf("Cleaning And Exiting","","","","") messageList = mutableListOf("Cleaning And Exiting","","","","")
fetch.cancelAll() fetch.cancelAll()
fetch.removeAll() fetch.removeAll()
updateNotification() updateNotification()
cleanFiles(File(defaultDir)) cleanFiles(File(dir.defaultDir()))
cleanFiles(File(imageDir)) cleanFiles(File(dir.imageCacheDir()))
messageList = mutableListOf("","","","","") messageList = mutableListOf("","","","","")
releaseWakeLock() releaseWakeLock()
serviceJob.cancel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
stopForeground(true) stopForeground(true)
} else { } else {
@ -609,25 +453,8 @@ class ForegroundService : Service(){
} }
} }
private fun initialiseFetch() {
val fetchConfiguration =
FetchConfiguration.Builder(this).run {
setNamespace(channelId)
setDownloadConcurrentLimit(4)
build()
}
fetch = Fetch.run {
setDefaultInstanceConfiguration(fetchConfiguration)
getDefaultInstance()
}.apply {
addListener(fetchListener)
removeAll() //Starting fresh
}
}
private fun getNotification():Notification = NotificationCompat.Builder(this, channelId).run { private fun getNotification():Notification = NotificationCompat.Builder(this, channelId).run {
setSmallIcon(R.drawable.ic_download_arrow) setSmallIcon(drawable.ic_download_arrow)
setContentTitle("Total: $total Completed:$converted Failed:$failed") setContentTitle("Total: $total Completed:$converted Failed:$failed")
setSilent(true) setSilent(true)
setStyle( setStyle(
@ -639,7 +466,7 @@ class ForegroundService : Service(){
addLine(messageList[messageList.size - 5]) addLine(messageList[messageList.size - 5])
} }
) )
addAction(R.drawable.ic_round_cancel_24,"Exit",cancelIntent) addAction(drawable.ic_round_cancel_24,"Exit",cancelIntent)
build() build()
} }
@ -661,3 +488,10 @@ class ForegroundService : Service(){
this@ForegroundService.sendBroadcast(intent) this@ForegroundService.sendBroadcast(intent)
} }
} }
private fun Fetch.removeAllListeners():Fetch{
for (listener in this.getListenerSet()) {
this.removeListener(listener)
}
return this
}

View File

@ -25,6 +25,7 @@ expect class Dir(
suspend fun loadImage(url:String): ImageBitmap? suspend fun loadImage(url:String): ImageBitmap?
suspend fun clearCache() suspend fun clearCache()
suspend fun saveFileWithMetadata(mp3ByteArray: ByteArray, trackDetails: TrackDetails) suspend fun saveFileWithMetadata(mp3ByteArray: ByteArray, trackDetails: TrackDetails)
fun addToLibrary(path:String)
} }
suspend fun downloadFile(url: String): Flow<DownloadResult> { suspend fun downloadFile(url: String): Flow<DownloadResult> {

View File

@ -12,7 +12,7 @@ import kotlinx.coroutines.withContext
class FetchPlatformQueryResult( class FetchPlatformQueryResult(
private val gaanaProvider: GaanaProvider, private val gaanaProvider: GaanaProvider,
private val spotifyProvider: SpotifyProvider, private val spotifyProvider: SpotifyProvider,
private val youtubeProvider: YoutubeProvider, val youtubeProvider: YoutubeProvider,
val youtubeMusic: YoutubeMusic, val youtubeMusic: YoutubeMusic,
private val database: Database private val database: Database
) { ) {

View File

@ -27,7 +27,7 @@ class YoutubeMusic constructor(
trackDurationSec = trackDetails.durationSec trackDurationSec = trackDetails.durationSec
).keys.firstOrNull() ).keys.firstOrNull()
} }
suspend fun getYTTracks(query: String):List<YoutubeTrack>{ private suspend fun getYTTracks(query: String):List<YoutubeTrack>{
val youtubeTracks = mutableListOf<YoutubeTrack>() val youtubeTracks = mutableListOf<YoutubeTrack>()
val responseObj = Json.parseToJsonElement(getYoutubeMusicResponse(query)) val responseObj = Json.parseToJsonElement(getYoutubeMusicResponse(query))
@ -121,7 +121,8 @@ class YoutubeMusic constructor(
! other constituents of a result block will lead to errors, hence the 'in ! other constituents of a result block will lead to errors, hence the 'in
! result[:-1] ,i.e., skip last element in array ' ! result[:-1] ,i.e., skip last element in array '
*/ */
for(detail in result.subList(0,result.size-1)){ for(detailArray in result.subList(0,result.size-1)){
for(detail in detailArray.jsonArray){
if(detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]?.jsonObject?.size?:0 < 2) continue if(detail.jsonObject["musicResponsiveListItemFlexColumnRenderer"]?.jsonObject?.size?:0 < 2) continue
// if not a dummy, collect All Variables // if not a dummy, collect All Variables
@ -137,7 +138,8 @@ class YoutubeMusic constructor(
} }
} }
} }
// log("YT Music details",availableDetails.toString()) }
//logger.d("YT Music details"){availableDetails.toString()}
/* /*
! Filter Out non-Song/Video results and incomplete results here itself ! Filter Out non-Song/Video results and incomplete results here itself
! From what we know about detail order, note that [1] - indicate result type ! From what we know about detail order, note that [1] - indicate result type
@ -171,7 +173,7 @@ class YoutubeMusic constructor(
return youtubeTracks return youtubeTracks
} }
fun sortByBestMatch( private fun sortByBestMatch(
ytTracks:List<YoutubeTrack>, ytTracks:List<YoutubeTrack>,
trackName:String, trackName:String,
trackArtists:List<String>, trackArtists:List<String>,
@ -240,7 +242,7 @@ class YoutubeMusic constructor(
val avgMatch = (artistMatch + durationMatch)/2 val avgMatch = (artistMatch + durationMatch)/2
linksWithMatchValue[result.videoId.toString()] = avgMatch.toInt() linksWithMatchValue[result.videoId.toString()] = avgMatch.toInt()
} }
//log("YT Api Result", "$trackName - $linksWithMatchValue") logger.d("YT Api Result"){"$trackName - $linksWithMatchValue"}
return linksWithMatchValue.toList().sortedByDescending { it.second }.toMap() return linksWithMatchValue.toList().sortedByDescending { it.second }.toMap()
} }

View File

@ -75,7 +75,7 @@ actual class Dir actual constructor(private val logger: Kermit) {
.setId3v1Tags(trackDetails) .setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails) .setId3v2TagsAndSaveFile(trackDetails)
} }
actual fun addToLibrary(path:String){}
actual suspend fun loadImage(url: String): ImageBitmap? { actual suspend fun loadImage(url: String): ImageBitmap? {
val cachePath = imageCacheDir() + getNameURL(url) val cachePath = imageCacheDir() + getNameURL(url)
var picture: ImageBitmap? = loadCachedImage(cachePath) var picture: ImageBitmap? = loadCachedImage(cachePath)

View File

@ -0,0 +1,26 @@
<!--
~ Copyright (c) 2021 Shabinder Singh
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4c4.41,0 8,3.59 8,8s-3.59,8 -8,8s-8,-3.59 -8,-8S7.59,4 12,4M12,2C6.48,2 2,6.48 2,12c0,5.52 4.48,10 10,10c5.52,0 10,-4.48 10,-10C22,6.48 17.52,2 12,2L12,2zM13,12l0,-4h-2l0,4H8l4,4l4,-4H13z"/>
</vector>

View File

@ -0,0 +1,26 @@
<!--
~ Copyright (c) 2021 Shabinder Singh
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM16.3,16.3c-0.39,0.39 -1.02,0.39 -1.41,0L12,13.41 9.11,16.3c-0.39,0.39 -1.02,0.39 -1.41,0 -0.39,-0.39 -0.39,-1.02 0,-1.41L10.59,12 7.7,9.11c-0.39,-0.39 -0.39,-1.02 0,-1.41 0.39,-0.39 1.02,-0.39 1.41,0L12,10.59l2.89,-2.89c0.39,-0.39 1.02,-0.39 1.41,0 0.39,0.39 0.39,1.02 0,1.41L13.41,12l2.89,2.89c0.38,0.38 0.38,1.02 0,1.41z"/>
</vector>