Service Cleanup, AutoClear & Notification Optimisations

This commit is contained in:
shabinder 2021-06-23 00:18:01 +05:30
parent b3abc9c4de
commit 6566c35888
14 changed files with 343 additions and 89 deletions

View File

@ -128,11 +128,16 @@ dependencies {
implementation(matomo)
}
with(Versions.androidxLifecycle) {
implementation("androidx.lifecycle:lifecycle-service:$this")
implementation("androidx.lifecycle:lifecycle-common-java8:$this")
}
implementation(Extras.kermit)
//implementation("com.jakewharton.timber:timber:4.7.1")
implementation("dev.icerock.moko:parcelize:0.7.0")
implementation("com.github.shabinder:storage-chooser:2.0.4.45")
implementation("com.google.accompanist:accompanist-insets:0.11.1")
implementation("com.google.accompanist:accompanist-insets:0.12.0")
// Test
testImplementation("junit:junit:4.13.2")

View File

@ -17,13 +17,11 @@
package com.shabinder.spotiflyer.service
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_CANCEL_CURRENT
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Binder
@ -31,8 +29,9 @@ import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Kermit
import com.shabinder.common.di.Dir
import com.shabinder.common.di.FetchPlatformQueryResult
@ -44,23 +43,33 @@ import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.event.coroutines.SuspendableEvent
import com.shabinder.common.models.event.coroutines.failure
import kotlinx.coroutines.CoroutineScope
import com.shabinder.spotiflyer.utils.autoclear.AutoClear
import com.shabinder.spotiflyer.utils.autoclear.autoClear
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import java.io.File
import kotlin.coroutines.CoroutineContext
class ForegroundService : Service(), CoroutineScope {
class ForegroundService : LifecycleService() {
private val tag: String = "Foreground Service"
private val channelId = "ForegroundDownloaderService"
private val notificationId = 101
val trackStatusFlowMap by autoClear { TrackStatusFlowMap(MutableSharedFlow(replay = 1),lifecycleScope) }
private var downloadService: AutoClear<ParallelExecutor> = autoClear { ParallelExecutor(Dispatchers.IO) }
private val fetcher: FetchPlatformQueryResult by inject()
private val logger: Kermit by inject()
private val dir: Dir by inject()
private var messageList = MutableList(5) { emptyMessage }
private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false
private val cancelIntent: PendingIntent by lazy {
val intent = Intent(this, ForegroundService::class.java).apply { action = "kill" }
PendingIntent.getService(this, 0, intent, FLAG_CANCEL_CURRENT)
}
/* Variables Holding Download State */
private var total = 0 // Total Downloads Requested
private var converted = 0 // Total Files Converted
private var downloaded = 0 // Total Files downloaded
@ -68,52 +77,27 @@ class ForegroundService : Service(), CoroutineScope {
private val isFinished get() = converted + failed == total
private var isSingleDownload = false
private lateinit var serviceJob: Job
override val coroutineContext: CoroutineContext
get() = serviceJob + Dispatchers.IO
val trackStatusFlowMap = TrackStatusFlowMap(MutableSharedFlow(replay = 1),this)
private var messageList = mutableListOf("", "", "", "", "")
private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStarted = false
private lateinit var cancelIntent: PendingIntent
private lateinit var downloadManager: DownloadManager
private lateinit var downloadService: ParallelExecutor
private val fetcher: FetchPlatformQueryResult by inject()
private val logger: Kermit by inject()
private val dir: Dir by inject()
inner class DownloadServiceBinder : Binder() {
// Return this instance of MyService so clients can call public methods
val service: ForegroundService
get() =// Return this instance of Foreground Service so clients can call public methods
this@ForegroundService
val service get() = this@ForegroundService
}
private val myBinder: IBinder = DownloadServiceBinder()
override fun onBind(intent: Intent): IBinder = myBinder
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return myBinder
}
@SuppressLint("UnspecifiedImmutableFlag")
override fun onCreate() {
super.onCreate()
serviceJob = SupervisorJob()
downloadService = ParallelExecutor(Dispatchers.IO)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(channelId, "Downloader Service")
}
val intent = Intent(this, ForegroundService::class.java).apply { action = "kill" }
cancelIntent = PendingIntent.getService(this, 0, intent, FLAG_CANCEL_CURRENT)
downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
createNotificationChannel(CHANNEL_ID, "Downloader Service")
}
@SuppressLint("WakelockTimeout")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
// Send a notification that service is started
Log.i(tag, "Foreground Service Started.")
startForeground(notificationId, getNotification())
Log.i(TAG, "Foreground Service Started.")
startForeground(NOTIFICATION_ID, getNotification())
intent?.let {
when (it.action) {
@ -127,7 +111,7 @@ class ForegroundService : Service(), CoroutineScope {
START_STICKY
} else {
isServiceStarted = true
Log.i(tag, "Starting the foreground service task")
Log.i(TAG, "Starting the foreground service task")
wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "EndlessService::lock").apply {
@ -142,7 +126,6 @@ class ForegroundService : Service(), CoroutineScope {
* Function To Download All Tracks Available in a List
**/
fun downloadAllTracks(trackList: List<TrackDetails>) {
trackList.size.also { size ->
total += size
isSingleDownload = (size == 1)
@ -151,8 +134,8 @@ class ForegroundService : Service(), CoroutineScope {
trackList.forEach {
trackStatusFlowMap[it.title] = DownloadStatus.Queued
launch {
downloadService.execute {
lifecycleScope.launch {
downloadService.value.execute {
fetcher.findMp3DownloadLink(it).fold(
success = { url ->
enqueueDownload(url, it)
@ -170,21 +153,22 @@ class ForegroundService : Service(), CoroutineScope {
private suspend fun enqueueDownload(url: String, track: TrackDetails) {
// Initiating Download
addToNotification("Downloading ${track.title}")
addToNotification(Message(track.title, DownloadStatus.Downloading()))
trackStatusFlowMap[track.title] = DownloadStatus.Downloading()
// Enqueueing Download
downloadFile(url).collect {
when (it) {
is DownloadResult.Error -> {
logger.d(tag) { it.message }
logger.d(TAG) { it.message }
failed++
trackStatusFlowMap[track.title] = DownloadStatus.Failed(it.cause ?: Exception(it.message))
removeFromNotification("Downloading ${track.title}")
removeFromNotification(Message(track.title, DownloadStatus.Downloading()))
}
is DownloadResult.Progress -> {
trackStatusFlowMap[track.title] = DownloadStatus.Downloading(it.progress)
updateProgressInNotification(Message(track.title,DownloadStatus.Downloading(it.progress)))
}
is DownloadResult.Success -> {
@ -195,15 +179,15 @@ class ForegroundService : Service(), CoroutineScope {
// Send Converting Status
trackStatusFlowMap[track.title] = DownloadStatus.Converting
addToNotification("Processing ${track.title}")
addToNotification(Message(track.title, DownloadStatus.Converting))
// All Processing Completed for this Track
job.invokeOnCompletion {
converted++
trackStatusFlowMap[track.title] = DownloadStatus.Downloaded
removeFromNotification("Processing ${track.title}")
removeFromNotification(Message(track.title, DownloadStatus.Converting))
}
logger.d(tag) { "${track.title} Download Completed" }
logger.d(TAG) { "${track.title} Download Completed" }
downloaded++
}.failure { error ->
error.printStackTrace()
@ -211,7 +195,7 @@ class ForegroundService : Service(), CoroutineScope {
failed++
trackStatusFlowMap[track.title] = DownloadStatus.Failed(error)
}
removeFromNotification("Downloading ${track.title}")
removeFromNotification(Message(track.title, DownloadStatus.Downloading()))
}
}
}
@ -219,7 +203,7 @@ class ForegroundService : Service(), CoroutineScope {
}
private fun releaseWakeLock() {
logger.d(tag) { "Releasing Wake Lock" }
logger.d(TAG) { "Releasing Wake Lock" }
try {
wakeLock?.let {
if (it.isHeld) {
@ -227,14 +211,14 @@ class ForegroundService : Service(), CoroutineScope {
}
}
} catch (e: Exception) {
logger.d(tag) { "Service stopped without being started: ${e.message}" }
logger.d(TAG) { "Service stopped without being started: ${e.message}" }
}
isServiceStarted = false
}
@Suppress("SameParameterValue")
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(channelId: String, channelName: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
channelName, NotificationManager.IMPORTANCE_DEFAULT
@ -243,22 +227,25 @@ class ForegroundService : Service(), CoroutineScope {
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
service.createNotificationChannel(channel)
}
}
/*
* Time To Wrap UP
* - `Clean Up` and `Stop this Foreground Service`
* */
private fun killService() {
launch {
logger.d(tag) { "Killing Self" }
messageList = mutableListOf("Cleaning And Exiting", "", "", "", "")
downloadService.close()
lifecycleScope.launch {
logger.d(TAG) { "Killing Self" }
messageList = messageList.getEmpty().apply {
set(index = 0, Message("Cleaning And Exiting",DownloadStatus.NotDownloaded))
}
downloadService.value.close()
downloadService.reset()
updateNotification()
cleanFiles(File(dir.defaultDir()))
// TODO cleanFiles(File(dir.imageCacheDir()))
messageList = mutableListOf("", "", "", "", "")
// cleanFiles(File(dir.imageCacheDir()))
messageList = messageList.getEmpty()
releaseWakeLock()
serviceJob.cancel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
stopForeground(true)
stopSelf()
@ -270,6 +257,7 @@ class ForegroundService : Service(), CoroutineScope {
override fun onDestroy() {
super.onDestroy()
logger.i(TAG) { "onDestroy, isFinished: $isFinished" }
if (isFinished) {
killService()
}
@ -277,6 +265,7 @@ class ForegroundService : Service(), CoroutineScope {
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
logger.i(TAG) { "onTaskRemoved, isFinished: $isFinished" }
if (isFinished) {
killService()
}
@ -285,30 +274,39 @@ class ForegroundService : Service(), CoroutineScope {
/*
* Create A New Notification with all the updated data
* */
private fun getNotification(): Notification = NotificationCompat.Builder(this, channelId).run {
private fun getNotification(): Notification = NotificationCompat.Builder(this, CHANNEL_ID).run {
setSmallIcon(R.drawable.ic_download_arrow)
setContentTitle("Total: $total Completed:$converted Failed:$failed")
setSilent(true)
// val max
// val progress = if(total != 0) 0 else (((failed+converted).toDouble() / total.toDouble()).roundToInt())
setProgress(total,failed+converted,false)
setStyle(
NotificationCompat.InboxStyle().run {
addLine(messageList[messageList.size - 1])
addLine(messageList[messageList.size - 2])
addLine(messageList[messageList.size - 3])
addLine(messageList[messageList.size - 4])
addLine(messageList[messageList.size - 5])
addLine(messageList[messageList.size - 1].asString())
addLine(messageList[messageList.size - 2].asString())
addLine(messageList[messageList.size - 3].asString())
addLine(messageList[messageList.size - 4].asString())
addLine(messageList[messageList.size - 5].asString())
}
)
addAction(R.drawable.ic_round_cancel_24, "Exit", cancelIntent)
build()
}
private fun addToNotification(message: String) {
private fun addToNotification(message: Message) {
messageList.add(message)
updateNotification()
}
private fun removeFromNotification(message: String) {
messageList.remove(message)
private fun removeFromNotification(message: Message) {
messageList.removeAll { it.title == message.title }
updateNotification()
}
private fun updateProgressInNotification(message: Message) {
val index = messageList.indexOfFirst { it.title == message.title }
messageList[index] = message
updateNotification()
}
@ -318,6 +316,13 @@ class ForegroundService : Service(), CoroutineScope {
private fun updateNotification() {
val mNotificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
mNotificationManager.notify(notificationId, getNotification())
mNotificationManager.notify(NOTIFICATION_ID, getNotification())
}
companion object {
private const val TAG: String = "Foreground Service"
private const val CHANNEL_ID = "ForegroundDownloaderService"
private const val NOTIFICATION_ID = 101
}
}

View File

@ -0,0 +1,33 @@
package com.shabinder.spotiflyer.service
import com.shabinder.common.models.DownloadStatus
typealias Message = Pair<String, DownloadStatus>
val Message.title: String get() = first
val Message.downloadStatus: DownloadStatus get() = second
val Message.progress: String get() = when (downloadStatus) {
is DownloadStatus.Downloading -> "-> ${(downloadStatus as DownloadStatus.Downloading).progress}%"
is DownloadStatus.Converting -> "-> 100%"
is DownloadStatus.Downloaded -> "-> Done"
is DownloadStatus.Failed -> "-> Failed"
is DownloadStatus.Queued -> "-> Queued"
is DownloadStatus.NotDownloaded -> ""
}
val emptyMessage = Message("",DownloadStatus.NotDownloaded)
// `Progress` is not being shown because we don't get get consistent Updates from Download Fun ,
// all Progress data is emitted all together from fun
fun Message.asString(): String {
val statusString = when(downloadStatus){
is DownloadStatus.Downloading -> "Downloading"
is DownloadStatus.Converting -> "Processing"
else -> ""
}
return "$statusString $title ${""/*progress*/}".trim()
}
fun List<Message>.getEmpty(): MutableList<Message> = MutableList(size) { emptyMessage }

View File

@ -0,0 +1,74 @@
package com.shabinder.spotiflyer.utils.autoclear
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import com.shabinder.common.requireNotNull
import com.shabinder.spotiflyer.utils.autoclear.AutoClear.Companion.TRIGGER
import com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers.LifecycleCreateAndDestroyObserver
import com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers.LifecycleResumeAndPauseObserver
import com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers.LifecycleStartAndStopObserver
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class AutoClear<T : Any?>(
lifecycle: Lifecycle,
private val initializer: (() -> T)?,
private val trigger: TRIGGER = TRIGGER.ON_CREATE,
) : ReadWriteProperty<LifecycleOwner, T?> {
companion object {
enum class TRIGGER {
ON_CREATE,
ON_START,
ON_RESUME
}
}
private var _value: T?
get() = observer.value
set(value) { observer.value = value }
val value: T get() = _value.requireNotNull()
private val observer: LifecycleAutoInitializer<T?> by lazy {
when(trigger) {
TRIGGER.ON_CREATE -> LifecycleCreateAndDestroyObserver(initializer)
TRIGGER.ON_START -> LifecycleStartAndStopObserver(initializer)
TRIGGER.ON_RESUME -> LifecycleResumeAndPauseObserver(initializer)
}
}
init {
lifecycle.addObserver(observer)
}
override fun getValue(thisRef: LifecycleOwner, property: KProperty<*>): T {
if (_value != null) {
return value
}
// If for Some Reason Initializer is not invoked even after Initialisation, invoke it after checking state
if (thisRef.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
return initializer?.invoke().also { _value = it }
?: throw IllegalStateException("The value has not yet been set or no default initializer provided")
} else {
throw IllegalStateException("Activity might have been destroyed or not initialized yet")
}
}
override fun setValue(thisRef: LifecycleOwner, property: KProperty<*>, value: T?) {
this._value = value
}
fun reset() {
this._value = null
}
}
fun <T : Any> LifecycleOwner.autoClear(
trigger: TRIGGER = TRIGGER.ON_CREATE,
initializer: () -> T
): AutoClear<T> {
return AutoClear(this.lifecycle, initializer, trigger)
}

View File

@ -0,0 +1,62 @@
package com.shabinder.spotiflyer.utils.autoclear
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class AutoClearFragment<T : Any?>(
fragment: Fragment,
private val initializer: (() -> T)?
) : ReadWriteProperty<Fragment, T?> {
private var _value: T? = null
init {
fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
val viewLifecycleOwnerObserver = Observer<LifecycleOwner?> { viewLifecycleOwner ->
viewLifecycleOwner?.lifecycle?.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
_value = null
}
})
}
override fun onCreate(owner: LifecycleOwner) {
fragment.viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerObserver)
}
override fun onDestroy(owner: LifecycleOwner) {
fragment.viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerObserver)
}
}
)
}
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
val value = _value
if (value != null) {
return value
}
if (thisRef.viewLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
return initializer?.invoke().also { _value = it }
?: throw IllegalStateException("The value has not yet been set or no default initializer provided")
} else {
throw IllegalStateException("Fragment might have been destroyed or not initialized yet")
}
}
override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T?) {
_value = value
}
}
fun <T : Any> Fragment.autoClear(initializer: () -> T): AutoClearFragment<T> {
return AutoClearFragment(this, initializer)
}

View File

@ -0,0 +1,7 @@
package com.shabinder.spotiflyer.utils.autoclear
import androidx.lifecycle.DefaultLifecycleObserver
interface LifecycleAutoInitializer<T>: DefaultLifecycleObserver {
var value: T?
}

View File

@ -0,0 +1,21 @@
package com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers
import androidx.lifecycle.LifecycleOwner
import com.shabinder.spotiflyer.utils.autoclear.LifecycleAutoInitializer
class LifecycleCreateAndDestroyObserver<T: Any?>(
private val initializer: (() -> T)?
) : LifecycleAutoInitializer<T> {
override var value: T? = null
override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
value = initializer?.invoke()
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
value = null
}
}

View File

@ -0,0 +1,21 @@
package com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers
import androidx.lifecycle.LifecycleOwner
import com.shabinder.spotiflyer.utils.autoclear.LifecycleAutoInitializer
class LifecycleResumeAndPauseObserver<T: Any?>(
private val initializer: (() -> T)?
) : LifecycleAutoInitializer<T> {
override var value: T? = null
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
value = initializer?.invoke()
}
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
value = null
}
}

View File

@ -0,0 +1,21 @@
package com.shabinder.spotiflyer.utils.autoclear.lifecycleobservers
import androidx.lifecycle.LifecycleOwner
import com.shabinder.spotiflyer.utils.autoclear.LifecycleAutoInitializer
class LifecycleStartAndStopObserver<T: Any?>(
private val initializer: (() -> T)?
) : LifecycleAutoInitializer<T> {
override var value: T? = null
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
value = initializer?.invoke()
}
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
value = null
}
}

View File

@ -49,7 +49,7 @@ object Versions {
const val minSdkVersion = 21
const val compileSdkVersion = 29
const val targetSdkVersion = 29
const val androidLifecycle = "2.3.0"
const val androidxLifecycle = "2.3.1"
}
object HostOS {

View File

@ -1,3 +1,3 @@
package com.shabinder.common
fun <T: Any> T?.requireNotNull() : T = requireNotNull(this)
fun <T: Any?> T?.requireNotNull() : T = requireNotNull(this)

View File

@ -4,7 +4,11 @@ sealed class SpotiFlyerException(override val message: String): Exception(messag
data class FeatureNotImplementedYet(override val message: String = "Feature not yet implemented."): SpotiFlyerException(message)
data class NoInternetException(override val message: String = "Check Your Internet Connection"): SpotiFlyerException(message)
data class MP3ConversionFailed(override val message: String = "MP3 Converter unreachable, probably BUSY !"): SpotiFlyerException(message)
data class MP3ConversionFailed(
val extraInfo:String? = null,
override val message: String = "MP3 Converter unreachable, probably BUSY ! \nCAUSE:$extraInfo"
): SpotiFlyerException(message)
data class NoMatchFound(
val trackName: String? = null,

View File

@ -122,6 +122,7 @@ class FetchPlatformQueryResult(
trackName = track.title,
trackArtists = track.artists
).flatMapError { saavnError ->
logger.e { "Fetching From Saavn Failed: \n${saavnError.stackTraceToString()}" }
// Saavn Failed, Lets Try Fetching Now From Youtube Music
youtubeMusic.findMp3SongDownloadURLYT(track).flatMapError { ytMusicError ->
// If Both Failed Bubble the Exception Up with both StackTraces

View File

@ -47,16 +47,16 @@ interface AudioToMp3 {
"${activeHost.removeSuffix("send")}${jobLink.substringAfterLast("/")}"
)
} catch (e: Exception) {
e.printStackTrace()
if(e is ClientRequestException && e.response.status.value == 404) {
// No Need to Retry, Host/Converter is Busy
throw SpotiFlyerException.MP3ConversionFailed()
throw SpotiFlyerException.MP3ConversionFailed(e.message)
}
// Try Using New Host/Converter
convertRequest(URL, audioQuality).value.also {
activeHost = it.first
jobLink = it.second
}
e.printStackTrace()
""
}
retryCount--