androidkotlinservicebroadcastreceiverandroid-pendingintent

How to call a method of a service from Boradcast Receiver in Android?


I have implemented a synchronous download mechanism for my download TrackDownloadService. I can not trigger the cancelDownload() method to cancel the download when I click "Cancel" from my notification. How to make it work? Please give me some suggestions if I am doing something wrong i my code.

This is the PausingDispatchQueue which is responsible for pause and resume functionality:

class PausingDispatchQueue : AbstractCoroutineContextElement(Key) {

    private val paused = AtomicBoolean(false)
    private val queue = ArrayDeque<Resumer>()

    val isPaused: Boolean
        get() = paused.get()

    fun pause() {
        paused.set(true)
    }

    fun resume() {
        if (paused.compareAndSet(true, false)) {
            dispatchNext()
        }
    }

    fun queue(context: CoroutineContext, block: Runnable, dispatcher: CoroutineDispatcher) {
        queue.addLast(Resumer(dispatcher, context, block))
    }

    private fun dispatchNext() {
        val resumer = queue.removeFirstOrNull() ?: return
        resumer.dispatch()
    }

    private inner class Resumer(
        private val dispatcher: CoroutineDispatcher,
        private val context: CoroutineContext,
        private val block: Runnable,
    ) : Runnable {
        override fun run() {
            block.run()
            if (!paused.get()) {
                dispatchNext()
            }
        }

        fun dispatch() {
            dispatcher.dispatch(context, this)
        }
    }

    companion object Key : CoroutineContext.Key<PausingDispatchQueue>
}

This is the TracksDownloadService class:

class TrackDownloadService : Service() {
    private lateinit var downloadScope: CoroutineScope
    private lateinit var notificationManager: NotificationManager
    private val CHANNEL_ID = "TrackDownloadChannel"
    private val NOTIFICATION_ID = 4
    private lateinit var cancelReceiver: DownloadCancelReceiver
    private val queue = PausingDispatchQueue()
    private val cancelToken = AtomicBoolean(false)
    private val downloadFlow = MutableSharedFlow<DownloadEvent>(extraBufferCapacity = 1) // To emit DownloadEvents

    private val mBinder: IBinder = MyBinder()
    override fun onBind(intent: Intent): IBinder {
        return mBinder
    }

    inner class MyBinder : Binder() {
        val service: TrackDownloadService
            get() = this@TrackDownloadService
    }

    companion object {
        const val TAG = "TrackDownloadService"
        private lateinit var cancelPendingIntent: PendingIntent
//        var isDownloadCancelled: Boolean = false
        private var instance: TrackDownloadService? = null

        fun getInstance(): TrackDownloadService? {
            return instance
        }
        fun stopService(context: Context) {
            val intent  = Intent(context, TrackDownloadService::class.java)
            intent.action = "com.offlinemusicplayer.ACTION_STOP_FOREGROUND_SERVICE"
            context.stopService(intent)
        }
    }

    fun startService(context: Context) {
        val intent = Intent(context, TrackDownloadService::class.java)
        intent.action = "com.offlinemusicplayer.ACTION_CANCEL_DOWNLOAD"
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            context.startForegroundService(intent)
        } else {
            context.startService(intent)
        }
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // Handle incoming intents if needed
        return START_STICKY // Or any other appropriate return value
    }

    override fun onCreate() {
        super.onCreate()
        Log.d(TAG, "OnCreate $TAG")
        createNotificationChannel()
        startForeground(NOTIFICATION_ID, createNotification("Starting download..."))
//         Register the receiver
        cancelReceiver = DownloadCancelReceiver()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                registerReceiver(cancelReceiver,
                    IntentFilter(DownloadCancelReceiver.ACTION_CANCEL_DOWNLOAD),
                    RECEIVER_NOT_EXPORTED
                )
            }
        }

        // Reinitialize the coroutine scope
        downloadScope = CoroutineScope(Dispatchers.IO)



        initiateDownloadProcess()
    }

    override fun onDestroy() {
        super.onDestroy()
        // Cancel the download coroutine scope
        Log.d(TAG, "Service onDestroy is called")
        unregisterReceiver(cancelReceiver)
//        isDownloadCancelled = false
//        downloadScope.coroutineContext.cancelChildren()
    }

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID,
                "Track Download",
                NotificationManager.IMPORTANCE_LOW
            )
            notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            notificationManager.createNotificationChannel(channel)
        }
    }

    private fun createNotification(contentText: String): Notification {
        val cancelIntent = Intent(this, DownloadCancelReceiver::class.java).apply {
            action = DownloadCancelReceiver.ACTION_CANCEL_DOWNLOAD
        }

        cancelPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            PendingIntent.getBroadcast(this, 0, cancelIntent, PendingIntent.FLAG_MUTABLE)
        } else {
            PendingIntent.getBroadcast(this, 0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT)
        }

        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("Track Download")
            .setContentText(contentText)
            .setSmallIcon(R.drawable.ic_folder_64)
            .setProgress(100, 0, false)
            .setPriority(NotificationCompat.PRIORITY_LOW)
            .addAction(R.drawable.ic_file_delete, "Cancel", cancelPendingIntent)
            .build()
    }

    private fun updateNotification(progress: Int, contentText: String) {
        val cancelIntent: Intent = Intent(this, DownloadCancelReceiver::class.java).apply {
            action = DownloadCancelReceiver.ACTION_CANCEL_DOWNLOAD
        }

        cancelPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            PendingIntent.getBroadcast(this, 0, cancelIntent, PendingIntent.FLAG_MUTABLE)
        } else {
            PendingIntent.getBroadcast(this, 0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT)
        }

        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("Track Download")
            .setContentText(contentText)
            .setSmallIcon(R.drawable.ic_folder_64)
            .setProgress(100, progress, false)
            .setPriority(NotificationCompat.PRIORITY_LOW)
            .addAction(R.drawable.ic_file_delete, "Cancel", cancelPendingIntent)
            .build()
        notificationManager.notify(NOTIFICATION_ID, notification)
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    private fun initiateDownloadProcess() {
        downloadScope.launch {
            DownloadChannel.downloadEventChannel
                .consumeAsFlow()
                .flatMapLatest { downloadEvent ->
                    Log.d(TAG, "Inside initiateDownloadProcess Name: ${downloadEvent.track.name}, headers: ${downloadEvent.response.headers}")

                    trackDownloadFlow(downloadEvent)
                }
                .collect { result ->
                    Log.d(TAG, result)
                }
        }
    }

    // Method to create the flow that handles the download event
    private fun trackDownloadFlow(downloadEvent: DownloadEvent): Flow<String> = flow {
        val downloadResponse = downloadEvent.response
        val track = downloadEvent.track

        if (downloadResponse.isSuccessful) {
            Log.d(TAG, "Download response body headers: ${downloadResponse.headers}")
            val body = downloadResponse.body ?: return@flow

            val originalContentLength: String? = downloadResponse.header("x-original-content-length")
            val fileSize = body.contentLength().takeIf { it != -1L } ?: originalContentLength?.toLong() ?: -1L

            Log.d(TAG, "File size: $fileSize")
            val inputStream = body.byteStream()

            // Specify the path to save the file
            val songsFolder = Utils.makeSongsFolder(OfflineMusicApp.instance)
            val filePath = File(songsFolder, track.name + UUID.randomUUID())

            val outputStream = FileOutputStream(filePath)
            val bufferSize = 8192 // 8KB buffer
            val data = ByteArray(bufferSize)
            var total: Long = 0
            var count: Int
            var lastProgressUpdate = 0L

            while (inputStream.read(data).also { count = it } != -1) {
                // Check for cancellation
                if (cancelToken.get()) {
                    Log.d(TAG, "Download canceled!")
                    outputStream.close()
                    inputStream.close()
                    filePath.delete()
                    emit("Download canceled!")
                    return@flow
                }

                // Check for pause
                while (queue.isPaused) {
                    Log.d(TAG, "Download paused... waiting to resume.")
                    delay(500) // Avoid busy-waiting
                }

                total += count
                val progress = ((total * 100) / fileSize).toInt()
                if (progress > lastProgressUpdate + 1 || System.currentTimeMillis() - lastProgressUpdate > 500) {
                    updateNotification(progress, "Downloading: ${track.name} $progress%")
                    withContext(Dispatchers.Main) {
                        DownloadChannel.downloadProgressChannel.send(track.id to progress)
                    }
                    lastProgressUpdate = System.currentTimeMillis()
                }

                outputStream.write(data, 0, count)
            }

            outputStream.flush()
            outputStream.close()
            inputStream.close()

            val newName = DuplicateCopyManager.getTrackName(track.name)
            val dest = File(songsFolder, newName)
            filePath.renameTo(dest)
            withContext(Dispatchers.Default) {
                val trackMetadata = TracksManager().getMetadata(dest.toString())
                DBManager.insertTrack(trackMetadata) {
                    updateNotification(100, "Download completed: ${track.name}")
                    CoroutineScope(Dispatchers.Main).launch {
                        DownloadChannel.downloadProgressChannel.send(track.id to 100)
                    }
                }
            }
            emit("Download completed!")
        } else {
            emit("Failed to download the file: ${track.name}")
        }
    }.flowOn(Dispatchers.IO) // Ensure emissions happen on IO dispatcher

    fun cancelDownload() {
        cancelToken.set(true) // Set the cancellation flag
        Log.d(TAG, "Download cancellation requested.")
        updateNotification(0, "Download canceled!") // Update the notification
    }
}

This is the DonwloadCancelReceiver class to receive the cancel intent

class DownloadCancelReceiver : BroadcastReceiver() {

    companion object {
        const val ACTION_CANCEL_DOWNLOAD = "com.offlinemusicplayer.ACTION_CANCEL_DOWNLOAD"
    }

    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == ACTION_CANCEL_DOWNLOAD) {
            Log.d("DownloadCancelReceiver", "Cancel download action received.")
            // Access the service instance and set the cancel flag
            TrackDownloadService.getInstance()?.cancelDownload()
        }
    }
}

Here, the receiver gets the intent action successfully but cancelDownload() method is not getting called from DownloadCancelReceiver to TrackDownloadService and ultimately cancellation is not working.


Solution

  • TrackDownloadService.getInstance() will always return null, because instance is always null.

    I recommend removing instance and getInstance(). With your current architecture, you can pass in TrackDownloadService via a constructor parameter:

    class DownloadCancelReceiver(service: TrackDownloadService) : BroadcastReceiver() {
        // rest of code goes here
    }
    
    cancelReceiver = DownloadCancelReceiver(this)
    

    Personally, I would revisit the architecture: