From ef147e2ca5386eac5eead0b5f31e5bea3d37ddbf Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:16:21 +0530 Subject: [PATCH 01/12] added swappable handler for timer --- .../ListenServiceManagerImpl.kt | 170 ++++++++++++ .../org/listenbrainz/android/util/JobQueue.kt | 160 +++++++++++ .../android/util/ListenSubmissionState.kt | 253 ++++++++++++++++++ .../org/listenbrainz/android/util/Timer.kt | 106 ++++++++ 4 files changed, 689 insertions(+) create mode 100644 app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt create mode 100644 app/src/main/java/org/listenbrainz/android/util/JobQueue.kt create mode 100644 app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt create mode 100644 app/src/main/java/org/listenbrainz/android/util/Timer.kt diff --git a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt new file mode 100644 index 00000000..513382dd --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt @@ -0,0 +1,170 @@ +package org.listenbrainz.android.repository.listenservicemanager + +import android.app.Notification +import android.content.Context +import android.media.MediaMetadata +import android.media.session.PlaybackState +import android.service.notification.StatusBarNotification +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.listenbrainz.android.di.DefaultDispatcher +import org.listenbrainz.android.model.PlayingTrack +import org.listenbrainz.android.model.PlayingTrack.Companion.toPlayingTrack +import org.listenbrainz.android.repository.preferences.AppPreferences +import org.listenbrainz.android.util.JobQueue +import org.listenbrainz.android.util.ListenSubmissionState +import org.listenbrainz.android.util.ListenSubmissionState.Companion.extractTitle +import org.listenbrainz.android.util.Log +import javax.inject.Inject + +/** The sole responsibility of this layer is to maintain mutual exclusion between [onMetadataChanged] and + * [onNotificationPosted], filter out repetitive submissions and handle changes in settings which concern + * listen scrobbing. + * + * FUTURE: Call notification popups here as well.*/ +class ListenServiceManagerImpl @Inject constructor( + workManager: WorkManager, + private val appPreferences: AppPreferences, + @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + @ApplicationContext private val context: Context +): ListenServiceManager { + + //private val handler: Handler by lazy { Handler(Looper.getMainLooper()) } + private val jobQueue: JobQueue by lazy { JobQueue(defaultDispatcher) } + private val listenSubmissionState = ListenSubmissionState(jobQueue, workManager, context) + private val scope = MainScope() + + /** Used to avoid repetitive submissions.*/ + private var lastCallbackTs = System.currentTimeMillis() + + /** Used to avoid repetitive submissions.*/ + private var lastNotificationPostTs = System.currentTimeMillis() + private lateinit var whitelist: List + private var isListeningAllowed: Boolean = true + + init { + with(scope) { + launch(defaultDispatcher) { + appPreferences.listeningWhitelist.getFlow().collect { + whitelist = it + // Discard current listen if the controller/package has been removed from whitelist. + if (listenSubmissionState.playingTrack.pkgName !in whitelist) { + listenSubmissionState.discardCurrentListen() + } + } + } + launch(defaultDispatcher) { + appPreferences.isListeningAllowed.getFlow().collect { + isListeningAllowed = it + // Immediately discard current listen if "Send Listens" option has been turned off. + if (!it) { + listenSubmissionState.discardCurrentListen() + } + } + } + } + } + + override fun onMetadataChanged(metadata: MediaMetadata?, player: String) { + jobQueue.post { + if (!isListeningAllowed) return@post + if (metadata == null) return@post + + val newTimestamp = System.currentTimeMillis() + with(listenSubmissionState) { + + // Repetitive submissions blocker + if (playingTrack.isCallbackTrack() + && newTimestamp in lastCallbackTs..lastCallbackTs + CALLBACK_SUBMISSION_TIMEOUT_INTERVAL + && metadata.extractTitle() == playingTrack.title + ) return@post + + lastCallbackTs = newTimestamp + + val newTrack = + metadata.toPlayingTrack(player).apply { timestamp = newTimestamp } + + onControllerCallback(newTrack) + } + Log.e("META") + } + } + + override fun onPlaybackStateChanged(state: PlaybackState?) { + // No need of this right now. + /*scope.launch { + timerMutex.withLock { + listenSubmissionState.toggleTimer(state?.state) + } + }*/ + } + + /** NOTE FOR FUTURE USE: When onNotificationPosted is called twice within 300..600ms delay, it usually + * means the track has been changed.*/ + override fun onNotificationPosted(sbn: StatusBarNotification?) { + jobQueue.post { + if (!isListeningAllowed) return@post + + // Only CATEGORY_TRANSPORT contain media player metadata. + if (sbn?.notification?.category != Notification.CATEGORY_TRANSPORT) return@post + + val newTrack = PlayingTrack( + title = sbn.notification.extras.getString(Notification.EXTRA_TITLE) + ?: return@post, + artist = sbn.notification.extras.getString(Notification.EXTRA_TEXT) + ?: return@post, + pkgName = sbn.packageName, + timestamp = sbn.notification.`when` + ) + + // Avoid repetitive submissions + with(listenSubmissionState) { + if ( + newTrack.pkgName == playingTrack.pkgName + && newTrack.timestamp in lastNotificationPostTs..lastNotificationPostTs + NOTI_SUBMISSION_TIMEOUT_INTERVAL + && newTrack.title == playingTrack.title + ) return@post + + // Check for whitelisted apps + if (sbn.packageName !in whitelist) return@post + + lastNotificationPostTs = newTrack.timestamp + + // Alert submission state + alertMediaNotificationUpdate(newTrack) + } + Log.e("NOTI") + } + } + + override fun onNotificationRemoved(sbn: StatusBarNotification?) { + scope.launch { + if (!isListeningAllowed) return@launch + + if (sbn?.notification?.category == Notification.CATEGORY_TRANSPORT + && sbn.packageName in appPreferences.listeningWhitelist.get() + ) { + listenSubmissionState.alertMediaPlayerRemoved(sbn) + } + } + } + + override fun close() { + //handler.cancel() + scope.cancel() + } + + companion object { + /** Because some notification posts are repetitive and in close proximity to each other, these variables + * are used to mitigate those cases.*/ + const val NOTI_SUBMISSION_TIMEOUT_INTERVAL = 300 + + /** Because some callbacks are repetitive and in close proximity to each other, these variables + * are used to mitigate those cases.*/ + const val CALLBACK_SUBMISSION_TIMEOUT_INTERVAL = 500 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/util/JobQueue.kt b/app/src/main/java/org/listenbrainz/android/util/JobQueue.kt new file mode 100644 index 00000000..9d78ce21 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/util/JobQueue.kt @@ -0,0 +1,160 @@ +package org.listenbrainz.android.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext + +class JobQueue( + private val dispatcher: CoroutineDispatcher, + private val log: Log = Log.Companion +) { + private val exceptionHandler = CoroutineExceptionHandler { _, e -> + log.e("JobQueue Exception: ${e.message}") + } + private val scope = CoroutineScope(SupervisorJob() + dispatcher + exceptionHandler) + + /** `Token: List` map. To support jobs with same token, we are creating list for + * a specified token. */ + private val jobsMap = HashMap>() + private val queue = Channel(Channel.UNLIMITED) + + /** Lock for [jobsMap].*/ + private val mapLock = Mutex() + + init { + // Execution queue + scope.launch(exceptionHandler) { + queue.receiveAsFlow().collect { queueJob -> + ensureActive() + + if (!queueJob.cancelled.get()) { + queueJob.job.join() + log.d("Job with token: ${queueJob.token} completed.") + } else { + queueJob.job.cancelAndJoin() + log.d("Job with token: ${queueJob.token} was cancelled.") + } + + // Remove the job from jobMap + mapLock.withLock { + jobsMap[queueJob.token]?.let { jobList -> + // Remove reference to the QueueJob + jobList.remove(queueJob) + // Remove token from map if empty. + if (jobList.isEmpty()) { + jobsMap.remove(queueJob.token) + } + } + } + } + } + } + + fun post( + token: Any? = null, + context: CoroutineContext = dispatcher, + block: suspend CoroutineScope.() -> Unit + ) { + scope.launch { + val queueJob = createQueueJob(token, context, block) + addJobToMap(queueJob) + queue.trySend(queueJob) + } + } + + fun postDelayed( + delayMillis: Long, + token: Any? = null, + context: CoroutineContext = dispatcher, + block: suspend CoroutineScope.() -> Unit + ) { + scope.launch { + val queueJob = createQueueJob(token, context, block) + addJobToMap(queueJob) + delay(delayMillis) + queue.trySend(queueJob) + } + } + + /** Removes all the delayed posts with [token] identifier. + * @param token if null, all jobs will be cancelled.*/ + fun removePosts(token: Any?) { + scope.launch(dispatcher) { + if (token == null) { + /** Mark all jobs as cancelled. */ + mapLock.withLock { + jobsMap.forEach { + val jobList = it.value + jobList.forEach { queueJob -> + queueJob.cancelled.set(true) + } + } + } + } else { + /** Mark job with unique token as cancelled. */ + mapLock.withLock { + val jobList = jobsMap[token] + jobList?.forEach { queueJob -> + queueJob.cancelled.set(true) + } + } + } + } + } + + private fun createQueueJob( + token: Any?, + context: CoroutineContext, + block: suspend CoroutineScope.() -> Unit + ): QueueJob { + val job = scope.launch(context, CoroutineStart.LAZY) { + if (isActive) { + block() + } + } + + return QueueJob(token, job, AtomicBoolean(false)) + } + + private suspend fun addJobToMap(queueJob: QueueJob) { + mapLock.withLock { + if (jobsMap.containsKey(queueJob.token)) { + // We should not need to do null checks since we are using locks but still + // are for safety. + jobsMap[queueJob.token]?.add(queueJob) + } else { + jobsMap[queueJob.token] = LinkedList().apply { add(queueJob) } + } + } + } + + fun cancel() { + queue.cancel() + scope.cancel() + } + + companion object { + /** A class containing reference to a scheduled job and its token.*/ + private data class QueueJob( + val token: Any?, + val job: Job, + val cancelled: AtomicBoolean + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt new file mode 100644 index 00000000..a5b1f0c1 --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt @@ -0,0 +1,253 @@ +package org.listenbrainz.android.util + +import android.content.Context +import android.media.AudioManager +import android.media.MediaMetadata +import android.service.notification.StatusBarNotification +import androidx.core.content.ContextCompat +import androidx.work.WorkManager +import kotlinx.coroutines.Dispatchers +import org.listenbrainz.android.model.ListenType +import org.listenbrainz.android.model.OnTimerListener +import org.listenbrainz.android.model.PlayingTrack +import org.listenbrainz.android.service.ListenSubmissionWorker.Companion.buildWorkRequest + +class ListenSubmissionState( + jobQueue: JobQueue = JobQueue(Dispatchers.Default), + private val workManager: WorkManager, + private val context: Context +) { + var playingTrack: PlayingTrack = PlayingTrack() + private set + private val audioManager by lazy { + ContextCompat.getSystemService( + context, + AudioManager::class.java + )!! + } + private val timer: Timer = Timer(jobQueue) + + init { + // Setting listener + timer.setOnTimerListener(listener = object : OnTimerListener { + override fun onTimerEnded() { + submitListen(ListenType.SINGLE) + playingTrack.submitted = true + } + + override fun onTimerPaused(remainingMillis: Long) { + Log.d("${remainingMillis / 1000} seconds left to submit listen.") + } + + override fun onTimerStarted() { + if (!playingTrack.playingNowSubmitted) { + submitListen(ListenType.PLAYING_NOW) + playingTrack.playingNowSubmitted = true + } + } + }) + } + + /** Update current [playingTrack] with [this] given some conditions. + * @param newTrack + * @param onTrackIsOutdated lambda to run when the current track is outdated. + * @param onTrackIsSimilarCallbackTrack lambda to run when the new track is similar to current + * playing track AND current playing track's metadata has been derived from **Listen Callback**. + * @param onTrackIsSimilarNotificationTrack lambda to run when the new track is similar to current + * playing track AND current playing track's metadata has been derived from **onNotificationPosted**.*/ + private fun PlayingTrack.updatePlayingTrack( + onTrackIsOutdated: (newTrack: PlayingTrack) -> Unit, + onTrackIsSimilarNotificationTrack: (newTrack: PlayingTrack) -> Unit, + onTrackIsSimilarCallbackTrack: (newTrack: PlayingTrack) -> Unit, + ) { + if (playingTrack.isOutdated(this)) { + + onTrackIsOutdated(this) + + } else if (playingTrack.isNotificationTrack()) { + // This means only onPostedNotification's metadata has arrived and callback is late. + // Timer is already started but we need to update its duration. + + onTrackIsSimilarNotificationTrack(this) + } else if (playingTrack.isCallbackTrack()) { + // Track is callback track. + + onTrackIsSimilarCallbackTrack(this) + } + } + + private fun beforeMetadataSet() { + // Before Metadata set + timer.stop() + playingTrack.reset() + } + + private fun afterMetadataSet() { + // After metadata set + if (isMetadataFaulty()) { + Log.w("${if (playingTrack.artist == null) "Artist" else "Title"} is null, listen cancelled.") + playingTrack.reset() + return + } + + initTimer() + } + + /** Initialize listen metadata and timer. + * @param metadata Metadata to set the state's data. + * @param pkg Package of music player the song is being played from. + */ + fun onControllerCallback( + newTrack: PlayingTrack + ){ + newTrack.updatePlayingTrack( + onTrackIsOutdated = { track -> + // Updating currentTrack + beforeMetadataSet() + playingTrack = track + afterMetadataSet() + }, + onTrackIsSimilarCallbackTrack = { track -> + // Usually this won't happen because metadata isn't being changed. + beforeMetadataSet() + playingTrack = track + afterMetadataSet() + }, + onTrackIsSimilarNotificationTrack = { track -> + // Update but retain timestamp and playingNowSubmitted. + // We usually do not expect this callback to arrive later for submitted to change. + playingTrack = track.apply { + timestamp = playingTrack.timestamp + playingNowSubmitted = playingTrack.playingNowSubmitted + } // Current track will always have more metadata here + + // Update timer because now we have duration. + timer.extendDuration { secondsPassed -> + track.duration/2 - secondsPassed + } + } + ) + // No need to toggle timer here since we can rely on onNotificationPosted to do that. + } + + fun alertMediaNotificationUpdate(newTrack: PlayingTrack) { + newTrack.updatePlayingTrack( + onTrackIsOutdated = { track -> + beforeMetadataSet() + + playingTrack = if (playingTrack.isSimilarTo(track)){ + // Old track has useful metadata like duration, so smartly retrieve. + track.apply { duration = playingTrack.duration } + } else { + track + } + + afterMetadataSet() + alertPlaybackStateChanged() + }, + onTrackIsSimilarCallbackTrack = { track -> + // We definitely know that whenever the notification bar changes a bit, we will get a state + // update which means we have a valid reason to query if music is playing or not. + alertPlaybackStateChanged() + }, + onTrackIsSimilarNotificationTrack = { track -> + // Same as above. + alertPlaybackStateChanged() + } + ) + } + + fun alertMediaPlayerRemoved(notification: StatusBarNotification) { + Log.d("Removed " + notification.notification.extras) + } + + /** Toggle timer based on state. */ + fun alertPlaybackStateChanged() { + if (playingTrack.isSubmitted()) return + + if (audioManager.isMusicActive) { + timer.startOrResume() + } else { + timer.pause() + } + } + + /** Run [artist] and [title] value-check before invoking this function.*/ + private fun initTimer() { + // d(duration.toString()) + if (playingTrack.duration != 0L) { + timer.setDuration( + roundDuration(duration = playingTrack.duration / 2L) // Since maximum time required to validate a listen as submittable listen is 4 minutes. + .coerceAtMost(240_000L) + ) + } else { + timer.setDuration( + roundDuration(duration = DEFAULT_DURATION) + ) + } + Log.d("Timer Set") + } + + // Utility functions + + private fun roundDuration(duration: Long): Long { + return (duration / 1000) * 1000 + } + + private fun submitListen(listenType: ListenType) = + workManager.enqueue(buildWorkRequest(playingTrack, listenType)) + + private fun isMetadataFaulty(): Boolean = playingTrack.artist.isNullOrEmpty() || playingTrack.title.isNullOrEmpty() + + /** Discard current listen.*/ + fun discardCurrentListen() { + timer.stop() + playingTrack = PlayingTrack() + } + + companion object { + const val DEFAULT_DURATION: Long = 60_000L + + fun MediaMetadata.extractTitle(): String? = when { + !getString(MediaMetadata.METADATA_KEY_TITLE) + .isNullOrEmpty() -> getString( + MediaMetadata.METADATA_KEY_TITLE + ) + + !getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE) + .isNullOrEmpty() -> getString( + MediaMetadata.METADATA_KEY_DISPLAY_TITLE + ) + + else -> null + } + + fun MediaMetadata.extractArtist(): String? = when { + !getString(MediaMetadata.METADATA_KEY_ARTIST) + .isNullOrEmpty() -> getString( + MediaMetadata.METADATA_KEY_ARTIST + ) + + !getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST) + .isNullOrEmpty() -> getString( + MediaMetadata.METADATA_KEY_ALBUM_ARTIST + ) + + !getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE) + .isNullOrEmpty() -> getString( + MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE + ) + + !getString(MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION) + .isNullOrEmpty() -> getString( + MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION + ) + + else -> null + } + + fun MediaMetadata.extractDuration(): Long = getLong(MediaMetadata.METADATA_KEY_DURATION) + + fun MediaMetadata.extractReleaseName(): String? = getString(MediaMetadata.METADATA_KEY_ALBUM) + } +} diff --git a/app/src/main/java/org/listenbrainz/android/util/Timer.kt b/app/src/main/java/org/listenbrainz/android/util/Timer.kt new file mode 100644 index 00000000..64ac9d4f --- /dev/null +++ b/app/src/main/java/org/listenbrainz/android/util/Timer.kt @@ -0,0 +1,106 @@ +package org.listenbrainz.android.util + +import android.os.SystemClock +import android.util.Log +import org.listenbrainz.android.model.OnTimerListener +import org.listenbrainz.android.model.TimerState + +/** **NOT** thread safe.*/ +class Timer(private val jobQueue: JobQueue) { + companion object { + private const val MESSAGE_TOKEN = 69 + private const val TAG = "Timer" + } + + private var mState: TimerState = TimerState.ENDED + private var mListener: OnTimerListener? = null + + private var mInitialDuration = 0L + private var mResumeTs: Long = 0 + private var mDurationLeft: Long = 0L + + fun setDuration(duration: Long) { + mInitialDuration = duration + mDurationLeft = duration + } + + fun setOnTimerListener(listener: OnTimerListener) { + mListener = listener + Log.d(TAG, "setOnTimerListener: ") + } + + fun startOrResume(delay: Long = 0L) { + when (mState) { + TimerState.RUNNING -> return + TimerState.PAUSED -> { + mResumeTs = SystemClock.uptimeMillis() + + jobQueue.postDelayed( + mDurationLeft, + MESSAGE_TOKEN, + ) { end() } + Log.d(TAG,"Timer resumed") + + mListener?.onTimerResumed() + mState = TimerState.RUNNING + } + TimerState.ENDED -> { + mResumeTs = SystemClock.uptimeMillis() + mDurationLeft += delay + + jobQueue.postDelayed( + mDurationLeft, + MESSAGE_TOKEN, + ) { end() } + Log.d(TAG,"Timer started") + + mListener?.onTimerStarted() + mState = TimerState.RUNNING + } + } + } + + private fun end() { + if (mState == TimerState.ENDED) { + return + } + mState = TimerState.ENDED + mListener?.onTimerEnded() + reset() + } + + /** Discard current listen post and stop timer.*/ + fun stop() { + if (mState == TimerState.ENDED) { + return + } + mState = TimerState.ENDED + jobQueue.removePosts(MESSAGE_TOKEN) + reset() + } + + fun extendDuration(extensionSeconds: (passedSeconds: Long) -> Long) { + pause() + mDurationLeft = extensionSeconds(/*passedSeconds =*/mInitialDuration - mDurationLeft) + startOrResume() + } + + fun pause() { + Log.d(TAG,"Timer paused") + if (mState == TimerState.PAUSED || mState == TimerState.ENDED) { + return + } + mState = TimerState.PAUSED + jobQueue.removePosts(MESSAGE_TOKEN) + + val durationLeft = mDurationLeft - (SystemClock.uptimeMillis() - mResumeTs) + mListener?.onTimerPaused(durationLeft) + mDurationLeft = durationLeft + } + + private fun reset() { + mResumeTs = 0L + mDurationLeft = 0L + mInitialDuration = 0L + } +} \ No newline at end of file From cf6537c9f77c7d04914b90a3170122b4163bdc24 Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Mon, 23 Sep 2024 13:56:38 +0530 Subject: [PATCH 02/12] actual commit for timer refactor --- .../ListenServiceManagerImpl.kt | 14 ++- .../android/util/ListenSubmissionState.kt | 29 ++++-- .../org/listenbrainz/android/util/Timer.kt | 98 ++++++++++++++++--- 3 files changed, 116 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt index 513382dd..881572bd 100644 --- a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt +++ b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt @@ -4,7 +4,10 @@ import android.app.Notification import android.content.Context import android.media.MediaMetadata import android.media.session.PlaybackState +import android.os.Handler +import android.os.Looper import android.service.notification.StatusBarNotification +import androidx.core.os.HandlerCompat import androidx.work.WorkManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -33,9 +36,10 @@ class ListenServiceManagerImpl @Inject constructor( @ApplicationContext private val context: Context ): ListenServiceManager { - //private val handler: Handler by lazy { Handler(Looper.getMainLooper()) } - private val jobQueue: JobQueue by lazy { JobQueue(defaultDispatcher) } - private val listenSubmissionState = ListenSubmissionState(jobQueue, workManager, context) + private val handler: Handler = HandlerCompat.createAsync(Looper.getMainLooper()) + private val listenSubmissionState = ListenSubmissionState(handler, workManager, context) + //private val jobQueue: JobQueue by lazy { JobQueue(defaultDispatcher) } + //private val listenSubmissionState = ListenSubmissionState(jobQueue, workManager, context) private val scope = MainScope() /** Used to avoid repetitive submissions.*/ @@ -70,7 +74,7 @@ class ListenServiceManagerImpl @Inject constructor( } override fun onMetadataChanged(metadata: MediaMetadata?, player: String) { - jobQueue.post { + handler.post { if (!isListeningAllowed) return@post if (metadata == null) return@post @@ -106,7 +110,7 @@ class ListenServiceManagerImpl @Inject constructor( /** NOTE FOR FUTURE USE: When onNotificationPosted is called twice within 300..600ms delay, it usually * means the track has been changed.*/ override fun onNotificationPosted(sbn: StatusBarNotification?) { - jobQueue.post { + handler.post { if (!isListeningAllowed) return@post // Only CATEGORY_TRANSPORT contain media player metadata. diff --git a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt index a5b1f0c1..bce07347 100644 --- a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt +++ b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt @@ -3,6 +3,7 @@ package org.listenbrainz.android.util import android.content.Context import android.media.AudioManager import android.media.MediaMetadata +import android.os.Handler import android.service.notification.StatusBarNotification import androidx.core.content.ContextCompat import androidx.work.WorkManager @@ -12,11 +13,7 @@ import org.listenbrainz.android.model.OnTimerListener import org.listenbrainz.android.model.PlayingTrack import org.listenbrainz.android.service.ListenSubmissionWorker.Companion.buildWorkRequest -class ListenSubmissionState( - jobQueue: JobQueue = JobQueue(Dispatchers.Default), - private val workManager: WorkManager, - private val context: Context -) { +class ListenSubmissionState { var playingTrack: PlayingTrack = PlayingTrack() private set private val audioManager by lazy { @@ -25,9 +22,27 @@ class ListenSubmissionState( AudioManager::class.java )!! } - private val timer: Timer = Timer(jobQueue) + private val timer: Timer + private val workManager: WorkManager + private val context: Context + + constructor(jobQueue: JobQueue = JobQueue(Dispatchers.Default), workManager: WorkManager, context: Context) { + this.timer = TimerJQ(jobQueue) + this.workManager = workManager + this.context = context + + init() + } + + constructor(handler: Handler, workManager: WorkManager, context: Context) { + this.timer = TimerHandler(handler) + this.workManager = workManager + this.context = context + + init() + } - init { + fun init() { // Setting listener timer.setOnTimerListener(listener = object : OnTimerListener { override fun onTimerEnded() { diff --git a/app/src/main/java/org/listenbrainz/android/util/Timer.kt b/app/src/main/java/org/listenbrainz/android/util/Timer.kt index 64ac9d4f..9182614d 100644 --- a/app/src/main/java/org/listenbrainz/android/util/Timer.kt +++ b/app/src/main/java/org/listenbrainz/android/util/Timer.kt @@ -1,12 +1,27 @@ package org.listenbrainz.android.util +import android.os.Handler import android.os.SystemClock import android.util.Log import org.listenbrainz.android.model.OnTimerListener import org.listenbrainz.android.model.TimerState +interface Timer { + fun startOrResume(delay: Long = 0L) + + fun setDuration(duration: Long) + + fun setOnTimerListener(listener: OnTimerListener) + + fun extendDuration(extensionSeconds: (passedSeconds: Long) -> Long) + + fun pause() + + fun stop() +} + /** **NOT** thread safe.*/ -class Timer(private val jobQueue: JobQueue) { +abstract class TimerBase: Timer { companion object { private const val MESSAGE_TOKEN = 69 private const val TAG = "Timer" @@ -19,23 +34,26 @@ class Timer(private val jobQueue: JobQueue) { private var mResumeTs: Long = 0 private var mDurationLeft: Long = 0L - fun setDuration(duration: Long) { + override fun setDuration(duration: Long) { mInitialDuration = duration mDurationLeft = duration } - fun setOnTimerListener(listener: OnTimerListener) { + override fun setOnTimerListener(listener: OnTimerListener) { mListener = listener Log.d(TAG, "setOnTimerListener: ") } - fun startOrResume(delay: Long = 0L) { + protected fun startOrResume( + delay: Long = 0L, + postDelayed: (duration: Long, token: Int, block: () -> Unit) -> Unit + ) { when (mState) { TimerState.RUNNING -> return TimerState.PAUSED -> { mResumeTs = SystemClock.uptimeMillis() - jobQueue.postDelayed( + postDelayed( mDurationLeft, MESSAGE_TOKEN, ) { end() } @@ -48,7 +66,7 @@ class Timer(private val jobQueue: JobQueue) { mResumeTs = SystemClock.uptimeMillis() mDurationLeft += delay - jobQueue.postDelayed( + postDelayed( mDurationLeft, MESSAGE_TOKEN, ) { end() } @@ -70,28 +88,32 @@ class Timer(private val jobQueue: JobQueue) { } /** Discard current listen post and stop timer.*/ - fun stop() { + protected fun stop(removePosts: (Int) -> Unit) { if (mState == TimerState.ENDED) { return } mState = TimerState.ENDED - jobQueue.removePosts(MESSAGE_TOKEN) + removePosts(MESSAGE_TOKEN) reset() } - fun extendDuration(extensionSeconds: (passedSeconds: Long) -> Long) { - pause() + protected fun extendDuration( + extensionSeconds: (passedSeconds: Long) -> Long, + removePosts: (Int) -> Unit, + postDelayed: (duration: Long, token: Int, block: () -> Unit) -> Unit, + ) { + pause(removePosts) mDurationLeft = extensionSeconds(/*passedSeconds =*/mInitialDuration - mDurationLeft) - startOrResume() + startOrResume(postDelayed = postDelayed) } - fun pause() { + protected fun pause(removePosts: (Int) -> Unit) { Log.d(TAG,"Timer paused") if (mState == TimerState.PAUSED || mState == TimerState.ENDED) { return } mState = TimerState.PAUSED - jobQueue.removePosts(MESSAGE_TOKEN) + removePosts(MESSAGE_TOKEN) val durationLeft = mDurationLeft - (SystemClock.uptimeMillis() - mResumeTs) mListener?.onTimerPaused(durationLeft) @@ -103,4 +125,54 @@ class Timer(private val jobQueue: JobQueue) { mDurationLeft = 0L mInitialDuration = 0L } +} + + +class TimerJQ( + private val jobQueue: JobQueue +): TimerBase() { + override fun startOrResume(delay: Long) = startOrResume(delay) { duration, token, block -> + jobQueue.postDelayed(duration, token) { block() } + } + + override fun stop() = stop { jobQueue.removePosts(it) } + + override fun extendDuration(extensionSeconds: (passedSeconds: Long) -> Long) = extendDuration( + extensionSeconds = extensionSeconds, + removePosts = { jobQueue.removePosts(it) }, + postDelayed = { duration, token, block -> + jobQueue.postDelayed(duration, token) { block() } + } + ) + + override fun pause() = pause { jobQueue.removePosts(it) } +} + + +class TimerHandler( + private val handler: Handler +): TimerBase() { + override fun startOrResume(delay: Long) = startOrResume(delay) { duration, token, block -> + handler.postAtTime( + { block() }, + token, + duration, + ) + } + + override fun stop() = stop { handler.removeCallbacksAndMessages(it) } + + override fun extendDuration(extensionSeconds: (passedSeconds: Long) -> Long) = extendDuration( + extensionSeconds = extensionSeconds, + removePosts = { handler.removeCallbacksAndMessages(it) }, + postDelayed = { duration, token, block -> + handler.postAtTime( + { block() }, + token, + duration, + ) + } + ) + + override fun pause() = pause { handler.removeCallbacksAndMessages(it) } } \ No newline at end of file From 8ad0c9675b74436d59327f85fe9b5d35b87da509 Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Mon, 23 Sep 2024 19:23:25 +0530 Subject: [PATCH 03/12] Logs + fix worker creation --- app/build.gradle.kts | 1 + .../ListenServiceManagerImpl.kt | 24 +++++++++++++++---- .../service/BrainzPlayerServiceConnection.kt | 4 +++- .../android/util/ListenSubmissionState.kt | 8 +++++++ gradle/libs.versions.toml | 1 + 5 files changed, 32 insertions(+), 6 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 33da51dc..4d2f92e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -176,6 +176,7 @@ dependencies { // Dependency Injection implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) + ksp(libs.androidx.hilt.compiler) implementation(libs.androidx.hilt.work) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.startup.runtime) diff --git a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt index 881572bd..e04abbc0 100644 --- a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt +++ b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt @@ -7,6 +7,8 @@ import android.media.session.PlaybackState import android.os.Handler import android.os.Looper import android.service.notification.StatusBarNotification +import android.text.Spannable +import android.text.SpannableString import androidx.core.os.HandlerCompat import androidx.work.WorkManager import dagger.hilt.android.qualifiers.ApplicationContext @@ -36,7 +38,7 @@ class ListenServiceManagerImpl @Inject constructor( @ApplicationContext private val context: Context ): ListenServiceManager { - private val handler: Handler = HandlerCompat.createAsync(Looper.getMainLooper()) + private val handler: Handler = Handler(Looper.getMainLooper()) private val listenSubmissionState = ListenSubmissionState(handler, workManager, context) //private val jobQueue: JobQueue by lazy { JobQueue(defaultDispatcher) } //private val listenSubmissionState = ListenSubmissionState(jobQueue, workManager, context) @@ -117,10 +119,22 @@ class ListenServiceManagerImpl @Inject constructor( if (sbn?.notification?.category != Notification.CATEGORY_TRANSPORT) return@post val newTrack = PlayingTrack( - title = sbn.notification.extras.getString(Notification.EXTRA_TITLE) - ?: return@post, - artist = sbn.notification.extras.getString(Notification.EXTRA_TEXT) - ?: return@post, + title = sbn.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() + ?: sbn.notification.extras.getString(Notification.EXTRA_TITLE) + //?: (sbn.notification.extras.get(Notification.EXTRA_TITLE) as? SpannableString)?.toString() + //?: sbn.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() + ?: run { + Log.d("Notification title is null") + return@post + }, + artist = sbn.notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() + ?: sbn.notification.extras.getString(Notification.EXTRA_TEXT) + //?: (sbn.notification.extras.get(Notification.EXTRA_TEXT) as? SpannableString)?.toString() + //?: sbn.notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() + ?: run { + Log.d("Notification artist is null") + return@post + }, pkgName = sbn.packageName, timestamp = sbn.notification.`when` ) diff --git a/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt b/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt index 942ea63c..ea904ea9 100644 --- a/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt +++ b/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt @@ -3,6 +3,7 @@ package org.listenbrainz.android.service import android.content.ComponentName import android.content.Context import android.media.MediaMetadata +import android.os.Looper import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaControllerCompat @@ -10,6 +11,7 @@ import android.support.v4.media.session.PlaybackStateCompat import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Pause import androidx.compose.material.icons.rounded.PlayArrow +import androidx.core.os.HandlerCompat import androidx.work.WorkManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -99,7 +101,7 @@ class BrainzPlayerServiceConnection( } private inner class MediaControllerCallback(context: Context) : MediaControllerCompat.Callback() { val listenSubmissionState: ListenSubmissionState by lazy { - ListenSubmissionState(workManager = workManager, context = context) + ListenSubmissionState(handler = HandlerCompat.createAsync(Looper.getMainLooper()), workManager = workManager, context = context) } override fun onPlaybackStateChanged(state: PlaybackStateCompat?) { diff --git a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt index bce07347..faa6f433 100644 --- a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt +++ b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt @@ -121,12 +121,14 @@ class ListenSubmissionState { beforeMetadataSet() playingTrack = track afterMetadataSet() + Log.d("onControllerCallback: Updated current track") }, onTrackIsSimilarCallbackTrack = { track -> // Usually this won't happen because metadata isn't being changed. beforeMetadataSet() playingTrack = track afterMetadataSet() + Log.d("onControllerCallback: track is similar.") }, onTrackIsSimilarNotificationTrack = { track -> // Update but retain timestamp and playingNowSubmitted. @@ -140,6 +142,7 @@ class ListenSubmissionState { timer.extendDuration { secondsPassed -> track.duration/2 - secondsPassed } + Log.d("onControllerCallback: track is similar, updated metadata.") } ) // No need to toggle timer here since we can rely on onNotificationPosted to do that. @@ -159,15 +162,18 @@ class ListenSubmissionState { afterMetadataSet() alertPlaybackStateChanged() + Log.d("notificationPosted: Updated current track") }, onTrackIsSimilarCallbackTrack = { track -> // We definitely know that whenever the notification bar changes a bit, we will get a state // update which means we have a valid reason to query if music is playing or not. alertPlaybackStateChanged() + Log.d("notificationPosted: metadata is already updated, playback state changed.") }, onTrackIsSimilarNotificationTrack = { track -> // Same as above. alertPlaybackStateChanged() + Log.d("notificationPosted: track is similar, metadata is about the same, playback state changed.") } ) } @@ -182,8 +188,10 @@ class ListenSubmissionState { if (audioManager.isMusicActive) { timer.startOrResume() + Log.d("Play") } else { timer.pause() + Log.d("Pause") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 29b848e8..1668d0ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,7 @@ minSdk = "22" [libraries] androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigationCompose" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } From 83d94efffa9195e85bddf686aec8d82a3d75d19d Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:19:40 +0530 Subject: [PATCH 04/12] Added better logs --- .../ListenServiceManagerImpl.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt index e04abbc0..63a6c36e 100644 --- a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt +++ b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt @@ -116,7 +116,11 @@ class ListenServiceManagerImpl @Inject constructor( if (!isListeningAllowed) return@post // Only CATEGORY_TRANSPORT contain media player metadata. - if (sbn?.notification?.category != Notification.CATEGORY_TRANSPORT) return@post + if (sbn?.notification?.category != Notification.CATEGORY_TRANSPORT) { + Log.d("Notification category is ${sbn?.notification?.category} not transport") + return@post + } + val newTrack = PlayingTrack( title = sbn.notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() @@ -141,14 +145,20 @@ class ListenServiceManagerImpl @Inject constructor( // Avoid repetitive submissions with(listenSubmissionState) { - if ( + /*if ( newTrack.pkgName == playingTrack.pkgName && newTrack.timestamp in lastNotificationPostTs..lastNotificationPostTs + NOTI_SUBMISSION_TIMEOUT_INTERVAL && newTrack.title == playingTrack.title - ) return@post + ) { + Log.d("Repetitive listen, dismissing.") + return@post + }*/ // Check for whitelisted apps - if (sbn.packageName !in whitelist) return@post + if (sbn.packageName !in whitelist) { + Log.d("Package ${sbn.packageName} not in whitelist, dismissing.") + return@post + } lastNotificationPostTs = newTrack.timestamp From 154b59c914836a173bd6d1a0a26eff652fe4ab5a Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:19:51 +0530 Subject: [PATCH 05/12] Better conditional --- .../org/listenbrainz/android/util/ListenSubmissionState.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt index faa6f433..3c1c429e 100644 --- a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt +++ b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt @@ -100,7 +100,7 @@ class ListenSubmissionState { private fun afterMetadataSet() { // After metadata set if (isMetadataFaulty()) { - Log.w("${if (playingTrack.artist == null) "Artist" else "Title"} is null, listen cancelled.") + Log.w("${if (playingTrack.artist.isNullOrEmpty()) "Artist" else "Title"} is null, listen cancelled.") playingTrack.reset() return } @@ -140,7 +140,7 @@ class ListenSubmissionState { // Update timer because now we have duration. timer.extendDuration { secondsPassed -> - track.duration/2 - secondsPassed + track.duration / 2 - secondsPassed } Log.d("onControllerCallback: track is similar, updated metadata.") } From 6b5b0e455f166e7e2668fcefb8319ff8dcc7b985 Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:20:23 +0530 Subject: [PATCH 06/12] Pin feed card fix and removed extra theme color --- .../android/ui/screens/album/AlbumScreen.kt | 4 ++-- .../android/ui/screens/artist/ArtistScreen.kt | 22 ++++++++--------- .../ui/screens/profile/BaseProfileScreen.kt | 2 +- .../screens/profile/listens/ListensScreen.kt | 24 ++++++++++--------- .../ui/screens/profile/stats/StatsScreen.kt | 2 +- .../ui/screens/profile/taste/TasteScreen.kt | 15 ++++++------ .../listenbrainz/android/ui/theme/Theme.kt | 3 --- 7 files changed, 36 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/album/AlbumScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/album/AlbumScreen.kt index fe389f78..9ec73ce9 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/album/AlbumScreen.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/album/AlbumScreen.kt @@ -208,7 +208,7 @@ private fun TrackListCard( .fillMaxWidth() .padding(23.dp)){ Column { - Text("Tracklist", color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 25.sp)) + Text("Tracklist", color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 25.sp)) Spacer(modifier = Modifier.height(20.dp)) trackList.map { ListenCardSmall(trackName = it?.name ?: "", artists = it?.artists ?: listOf(), coverArtUrl = uiState.coverArt, goToArtistPage = {}){} @@ -241,7 +241,7 @@ private fun TopListenersCard( .fillMaxWidth() .padding(23.dp)){ Column { - Text("Top listeners", color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 25.sp)) + Text("Top listeners", color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 25.sp)) Spacer(modifier = Modifier.height(20.dp)) topListeners.map { ArtistCard(artistName = it?.userName ?: "", listenCountLabel = formatNumber(it?.listenCount ?: 0)) { diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/artist/ArtistScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/artist/ArtistScreen.kt index 65422c5c..1149a383 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/artist/ArtistScreen.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/artist/ArtistScreen.kt @@ -216,7 +216,7 @@ fun BioCard( .padding(23.dp)){ Column { Row (horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - Text(header ?: "", color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 25.sp)) + Text(header ?: "", color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 25.sp)) if(displayRadioButton){ LbRadioButton { @@ -319,9 +319,9 @@ fun BioCard( .background(ListenBrainzTheme.colorScheme.followerCardColor) .padding(10.dp)) { Row { - Text(it.tag, color= ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp)) + Text(it.tag, color= ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp)) Spacer(modifier = Modifier.width(8.dp)) - Text((it.count ?: 0).toString(), color= ListenBrainzTheme.colorScheme.textColor ,style = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp)) + Text((it.count ?: 0).toString(), color= ListenBrainzTheme.colorScheme.text ,style = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp)) } } Spacer(modifier = Modifier.width(10.dp)) @@ -337,9 +337,9 @@ fun BioCard( .background(ListenBrainzTheme.colorScheme.followerCardColor) .padding(10.dp)) { Row { - Text(it.tag, color= ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp)) + Text(it.tag, color= ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp)) Spacer(modifier = Modifier.width(8.dp)) - Text((it.count ?: 0).toString(), color= ListenBrainzTheme.colorScheme.textColor ,style = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp)) + Text((it.count ?: 0).toString(), color= ListenBrainzTheme.colorScheme.text ,style = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp)) } } Spacer(modifier = Modifier.width(10.dp)) @@ -407,7 +407,7 @@ fun Links( .fillMaxWidth() .padding(23.dp)){ Column { - Text("Links", color= ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 25.sp)) + Text("Links", color= ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 25.sp)) Row (modifier = Modifier .horizontalScroll(rememberScrollState()) .padding(top = 10.dp)) { @@ -505,7 +505,7 @@ private fun PopularTracks( .background(brush = ListenBrainzTheme.colorScheme.gradientBrush) .padding(start = 23.dp, end = 23.dp, top = 23.dp)){ Column { - Text("Popular Tracks", color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) + Text("Popular Tracks", color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) Spacer(modifier = Modifier.height(20.dp)) popularTracks.map { ListenCardSmall(trackName = it?.recordingName ?: "", artists = it?.artists ?: listOf( @@ -543,7 +543,7 @@ private fun AlbumsCard( .background(brush = ListenBrainzTheme.colorScheme.gradientBrush) .padding(23.dp)){ Column { - Text(header, color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) + Text(header, color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) Row (modifier = Modifier .horizontalScroll(rememberScrollState()) .padding(top = 20.dp)) { @@ -603,7 +603,7 @@ private fun SimilarArtists( .background(brush = ListenBrainzTheme.colorScheme.gradientBrush) .padding(23.dp)){ Column { - Text("Similar Artists", color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) + Text("Similar Artists", color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) Spacer(modifier = Modifier.height(20.dp)) similarArtists.map { ArtistCard(artistName = it?.name ?: "") { @@ -641,7 +641,7 @@ private fun TopListenersCard( .background(brush = ListenBrainzTheme.colorScheme.gradientBrush) .padding(23.dp)) { Column { - Text("Top Listeners", color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) + Text("Top Listeners", color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) Spacer(modifier = Modifier.height(20.dp)) topListeners.map { ArtistCard(artistName = it?.userName ?: "", listenCountLabel = formatNumber(it?.listenCount ?: 0)) { @@ -683,7 +683,7 @@ fun ReviewsCard( .background(brush = ListenBrainzTheme.colorScheme.gradientBrush) .padding(23.dp)) { Column { - Text("Reviews", color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) + Text("Reviews", color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) if(reviews.isEmpty()){ Spacer(modifier = Modifier.height(10.dp)) Text("Be the first one to review this artist on CritiqueBrainz", color = app_bg_mid, style = MaterialTheme.typography.bodyMedium) diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/BaseProfileScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/BaseProfileScreen.kt index 98c8eef6..ae65d030 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/BaseProfileScreen.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/BaseProfileScreen.kt @@ -163,7 +163,7 @@ fun BaseProfileScreen( else -> "" }, style = ListenBrainzTheme.textStyles.chips, - color = ListenBrainzTheme.colorScheme.textColor + color = ListenBrainzTheme.colorScheme.text ) }, onClick = { currentTab.value = when (position) { diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/listens/ListensScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/listens/ListensScreen.kt index 6d862741..776037ad 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/listens/ListensScreen.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/listens/ListensScreen.kt @@ -259,7 +259,7 @@ fun ListensScreen( } item{ Spacer(modifier = Modifier.height(30.dp)) - Text("Recent Listens", color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp), modifier = Modifier.padding(start = 16.dp)) + Text("Recent Listens", color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp), modifier = Modifier.padding(start = 16.dp)) Spacer(modifier = Modifier.height(10.dp)) } itemsIndexed(items = (when(recentListensCollapsibleState.value){ @@ -345,7 +345,7 @@ fun ListensScreen( )){ Column { Spacer(modifier = Modifier.height(30.dp)) - Text("Your Compatibility", color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp), modifier = Modifier.padding(start = 16.dp)) + Text("Your Compatibility", color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp), modifier = Modifier.padding(start = 16.dp)) Spacer(modifier = Modifier.height(10.dp)) CompatibilityCard(compatibility = uiState.listensTabUiState.compatibility ?: 0f, uiState.listensTabUiState.similarArtists, goToArtistPage = goToArtistPage) } @@ -510,7 +510,9 @@ private fun BuildSimilarArtists(similarArtists: List, onArtistClick: (St withStyle(style = SpanStyle(color = lb_purple_night)) { append(artist.artistName) } - pop() + if (artist.artistMbid != null) { + pop() + } if (index < similarArtists.size - 1) { append(", ") } @@ -626,11 +628,11 @@ private fun SongsListened(username: String? , listenCount: Int?, isSelf: Boolean true -> "You have listened to" false -> "$username has listened to" } - , color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) + , color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp)) Spacer(modifier = Modifier.height(15.dp)) HorizontalDivider(color = ListenBrainzTheme.colorScheme.dividerColor, modifier = Modifier.padding(start = 60.dp, end = 60.dp)) Spacer(modifier = Modifier.height(15.dp)) - Text(listenCount.toString(), color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp), textAlign = TextAlign.Center) + Text(listenCount.toString(), color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp), textAlign = TextAlign.Center) Text("songs so far", color = app_bg_mid, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(30.dp)) } @@ -648,14 +650,14 @@ private fun FollowersInformation(followersCount: Int?, followingCount: Int?){ .fillMaxWidth() .padding(top = 30.dp, bottom = 30.dp)) { Column (horizontalAlignment = Alignment.CenterHorizontally) { - Text((followersCount ?:0).toString(), style = MaterialTheme.typography.bodyLarge, color = ListenBrainzTheme.colorScheme.textColor) + Text((followersCount ?:0).toString(), style = MaterialTheme.typography.bodyLarge, color = ListenBrainzTheme.colorScheme.text) Spacer(modifier = Modifier.height(10.dp)) - Text("Followers", style = MaterialTheme.typography.bodyLarge, color = ListenBrainzTheme.colorScheme.textColor) + Text("Followers", style = MaterialTheme.typography.bodyLarge, color = ListenBrainzTheme.colorScheme.text) } Column (horizontalAlignment = Alignment.CenterHorizontally) { - Text((followingCount ?: 0).toString(), style = MaterialTheme.typography.bodyLarge, color = ListenBrainzTheme.colorScheme.textColor) + Text((followingCount ?: 0).toString(), style = MaterialTheme.typography.bodyLarge, color = ListenBrainzTheme.colorScheme.text) Spacer(modifier = Modifier.height(10.dp)) - Text("Following", style = MaterialTheme.typography.bodyLarge, color = ListenBrainzTheme.colorScheme.textColor) + Text("Following", style = MaterialTheme.typography.bodyLarge, color = ListenBrainzTheme.colorScheme.text) } } Spacer(modifier = Modifier.height(10.dp)) @@ -689,7 +691,7 @@ private fun FollowersCard(followersCount: Int?, followingCount: Int?, followers: Column(modifier = Modifier.padding(start = 16.dp , top = 30.dp)) { Text( "Followers", - color = ListenBrainzTheme.colorScheme.textColor, + color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp) ) Spacer(modifier = Modifier.height(10.dp)) @@ -775,7 +777,7 @@ private fun FollowersCard(followersCount: Int?, followingCount: Int?, followers: @Composable private fun SimilarUsersCard(similarUsers: List, goToUserPage: (String?) -> Unit){ Spacer(modifier = Modifier.height(20.dp)) - Text("Similar Users", color = ListenBrainzTheme.colorScheme.textColor, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp), modifier = Modifier.padding(horizontal = 16.dp)) + Text("Similar Users", color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp), modifier = Modifier.padding(horizontal = 16.dp)) Spacer(modifier = Modifier.height(20.dp)) similarUsers.mapIndexed{ index , item -> diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/stats/StatsScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/stats/StatsScreen.kt index 37b89a66..20e39345 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/stats/StatsScreen.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/stats/StatsScreen.kt @@ -356,7 +356,7 @@ fun StatsScreen( } else{ - Text("There are no statistics available for this user for this period", color = ListenBrainzTheme.colorScheme.textColor, modifier = Modifier.padding(start = 10.dp)) + Text("There are no statistics available for this user for this period", color = ListenBrainzTheme.colorScheme.text, modifier = Modifier.padding(start = 10.dp)) } } diff --git a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/taste/TasteScreen.kt b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/taste/TasteScreen.kt index 45b026b5..9df48d5e 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/screens/profile/taste/TasteScreen.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/screens/profile/taste/TasteScreen.kt @@ -259,7 +259,7 @@ fun TasteScreen( Column { Text( text = "Pins", - color = ListenBrainzTheme.colorScheme.textColor, + color = ListenBrainzTheme.colorScheme.text, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 22.sp) ) Spacer(modifier = Modifier.height(20.dp)) @@ -268,12 +268,13 @@ fun TasteScreen( ListenCardSmall( enableBlurbContent = true, blurbContent = { - Text( - ('"' + (recording.blurbContent - ?: "No content specified") + '"'), - color = ListenBrainzTheme.colorScheme.textColor, - modifier = Modifier.padding(8.dp) - ) + if (!recording.blurbContent.isNullOrEmpty()) { + Text( + modifier = it, + text = recording.blurbContent, + color = ListenBrainzTheme.colorScheme.text, + ) + } }, modifier = Modifier .padding( diff --git a/app/src/main/java/org/listenbrainz/android/ui/theme/Theme.kt b/app/src/main/java/org/listenbrainz/android/ui/theme/Theme.kt index 4f402f4a..5c8dd326 100644 --- a/app/src/main/java/org/listenbrainz/android/ui/theme/Theme.kt +++ b/app/src/main/java/org/listenbrainz/android/ui/theme/Theme.kt @@ -64,7 +64,6 @@ data class ColorScheme( val placeHolderColor: Color, /** Used for User Pages **/ val dividerColor: Color, - val textColor: Color, val songsListenedToBG: Color, val userPageGradient: Brush, val followerChipSelected: Color, @@ -122,7 +121,6 @@ private val colorSchemeDark = ColorScheme( gradientBrush = brainzPlayerDarkGradientsBrush, placeHolderColor = Color(0xFF1E1E1E), dividerColor = app_bg_secondary_dark, - textColor = new_app_bg_light, songsListenedToBG = app_bg_dark, userPageGradient = Brush.verticalGradient( listOf( @@ -161,7 +159,6 @@ private val colorSchemeLight = ColorScheme( gradientBrush = brainzPlayerLightGradientsBrush, placeHolderColor = Color(0xFFEBEBEB), dividerColor = app_bg_secondary_light, - textColor = app_bg_dark, songsListenedToBG = new_app_bg_light, userPageGradient = Brush.verticalGradient( listOf( From 8a22f0a393c02a0bcf1b7b7d8181a154381d8cca Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:47:08 +0530 Subject: [PATCH 07/12] Nomenclature and duration fix for TimerHandler --- .../main/java/org/listenbrainz/android/util/Timer.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/listenbrainz/android/util/Timer.kt b/app/src/main/java/org/listenbrainz/android/util/Timer.kt index 9182614d..7e139c66 100644 --- a/app/src/main/java/org/listenbrainz/android/util/Timer.kt +++ b/app/src/main/java/org/listenbrainz/android/util/Timer.kt @@ -46,7 +46,7 @@ abstract class TimerBase: Timer { protected fun startOrResume( delay: Long = 0L, - postDelayed: (duration: Long, token: Int, block: () -> Unit) -> Unit + postDelayed: (durationLeft: Long, token: Int, block: () -> Unit) -> Unit ) { when (mState) { TimerState.RUNNING -> return @@ -131,8 +131,8 @@ abstract class TimerBase: Timer { class TimerJQ( private val jobQueue: JobQueue ): TimerBase() { - override fun startOrResume(delay: Long) = startOrResume(delay) { duration, token, block -> - jobQueue.postDelayed(duration, token) { block() } + override fun startOrResume(delay: Long) = startOrResume(delay) { durationLeft, token, block -> + jobQueue.postDelayed(durationLeft, token) { block() } } override fun stop() = stop { jobQueue.removePosts(it) } @@ -152,11 +152,11 @@ class TimerJQ( class TimerHandler( private val handler: Handler ): TimerBase() { - override fun startOrResume(delay: Long) = startOrResume(delay) { duration, token, block -> + override fun startOrResume(delay: Long) = startOrResume(delay) { durationLeft, token, block -> handler.postAtTime( { block() }, token, - duration, + SystemClock.uptimeMillis() + durationLeft, ) } @@ -169,7 +169,7 @@ class TimerHandler( handler.postAtTime( { block() }, token, - duration, + SystemClock.uptimeMillis() + duration, ) } ) From 0f10b78213c4ab5a07a1a16a6f8240acf34ffe2e Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:42:19 +0530 Subject: [PATCH 08/12] Change nomenclature --- .../android/util/ListenSessionListener.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt b/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt index 4f497445..1ba818af 100644 --- a/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt +++ b/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.listenbrainz.android.repository.listenservicemanager.ListenServiceManager import org.listenbrainz.android.repository.preferences.AppPreferences +import org.listenbrainz.android.util.ListenSessionListener.Companion.isPlaying import java.util.concurrent.ConcurrentHashMap class ListenSessionListener( @@ -89,7 +90,7 @@ class ListenSessionListener( private fun updateAppsList(controllers: List) { // Adding any new app packages found in the notification. serviceScope.launch(Dispatchers.Default) { - val shouldScrobbleNewPlayer = appPreferences.shouldListenNewPlayers.get() + val shouldListenNewPlayer = appPreferences.shouldListenNewPlayers.get() fun addToWhiteList(packageName: String) { launch { appPreferences.listeningWhitelist.getAndUpdate { whitelist -> @@ -102,7 +103,7 @@ class ListenSessionListener( val appList = it.toMutableList() controllers.forEach { controller -> if (controller.packageName !in appList){ - if (shouldScrobbleNewPlayer) + if (shouldListenNewPlayer) addToWhiteList(controller.packageName) appList.add(controller.packageName) } @@ -132,6 +133,12 @@ class ListenSessionListener( override fun onPlaybackStateChanged(state: PlaybackState?) { listenServiceManager.onPlaybackStateChanged(state) } - + } + + val isMediaPlaying get() = activeSessions.any { it.key.playbackState?.isPlaying == true } + + companion object { + inline val PlaybackState.isPlaying: Boolean + get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING } } From 3848d3f78b3abb8be9cbccaa163eff54af98304b Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:42:39 +0530 Subject: [PATCH 09/12] Use playback state from all registered apps --- .../ListenServiceManager.kt | 2 +- .../ListenServiceManagerImpl.kt | 15 +++----------- .../service/BrainzPlayerServiceConnection.kt | 3 +-- .../service/ListenSubmissionService.kt | 2 +- .../android/util/ListenSubmissionState.kt | 20 +++++++------------ 5 files changed, 13 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManager.kt b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManager.kt index 6f1bb72d..6a70f648 100644 --- a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManager.kt +++ b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManager.kt @@ -10,7 +10,7 @@ interface ListenServiceManager { fun onPlaybackStateChanged(state: PlaybackState?) - fun onNotificationPosted(sbn: StatusBarNotification?) + fun onNotificationPosted(sbn: StatusBarNotification?, mediaPlaying: Boolean) fun onNotificationRemoved(sbn: StatusBarNotification?) diff --git a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt index 63a6c36e..fe805e23 100644 --- a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt +++ b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt @@ -7,9 +7,6 @@ import android.media.session.PlaybackState import android.os.Handler import android.os.Looper import android.service.notification.StatusBarNotification -import android.text.Spannable -import android.text.SpannableString -import androidx.core.os.HandlerCompat import androidx.work.WorkManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -20,7 +17,6 @@ import org.listenbrainz.android.di.DefaultDispatcher import org.listenbrainz.android.model.PlayingTrack import org.listenbrainz.android.model.PlayingTrack.Companion.toPlayingTrack import org.listenbrainz.android.repository.preferences.AppPreferences -import org.listenbrainz.android.util.JobQueue import org.listenbrainz.android.util.ListenSubmissionState import org.listenbrainz.android.util.ListenSubmissionState.Companion.extractTitle import org.listenbrainz.android.util.Log @@ -102,16 +98,12 @@ class ListenServiceManagerImpl @Inject constructor( override fun onPlaybackStateChanged(state: PlaybackState?) { // No need of this right now. - /*scope.launch { - timerMutex.withLock { - listenSubmissionState.toggleTimer(state?.state) - } - }*/ + //listenSubmissionState.alertPlaybackStateChanged() } /** NOTE FOR FUTURE USE: When onNotificationPosted is called twice within 300..600ms delay, it usually * means the track has been changed.*/ - override fun onNotificationPosted(sbn: StatusBarNotification?) { + override fun onNotificationPosted(sbn: StatusBarNotification?, mediaPlaying: Boolean) { handler.post { if (!isListeningAllowed) return@post @@ -163,9 +155,8 @@ class ListenServiceManagerImpl @Inject constructor( lastNotificationPostTs = newTrack.timestamp // Alert submission state - alertMediaNotificationUpdate(newTrack) + alertMediaNotificationUpdate(newTrack, mediaPlaying) } - Log.e("NOTI") } } diff --git a/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt b/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt index ea904ea9..c6bd9003 100644 --- a/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt +++ b/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt @@ -122,8 +122,7 @@ class BrainzPlayerServiceConnection( runBlocking { appPreferences.isListeningAllowed.get() } ) return - listenSubmissionState.alertPlaybackStateChanged() - + listenSubmissionState.alertPlaybackStateChanged(state?.isPlaying == true) } override fun onRepeatModeChanged(repeatMode: Int) { diff --git a/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionService.kt b/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionService.kt index dc6076ec..2e059d5f 100644 --- a/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionService.kt +++ b/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionService.kt @@ -79,7 +79,7 @@ class ListenSubmissionService : NotificationListenerService() { } override fun onNotificationPosted(sbn: StatusBarNotification?) { - serviceManager.onNotificationPosted(sbn) + serviceManager.onNotificationPosted(sbn, sessionListener!!.isMediaPlaying) } override fun onNotificationRemoved( diff --git a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt index 3c1c429e..7e24e754 100644 --- a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt +++ b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt @@ -16,12 +16,6 @@ import org.listenbrainz.android.service.ListenSubmissionWorker.Companion.buildWo class ListenSubmissionState { var playingTrack: PlayingTrack = PlayingTrack() private set - private val audioManager by lazy { - ContextCompat.getSystemService( - context, - AudioManager::class.java - )!! - } private val timer: Timer private val workManager: WorkManager private val context: Context @@ -148,7 +142,7 @@ class ListenSubmissionState { // No need to toggle timer here since we can rely on onNotificationPosted to do that. } - fun alertMediaNotificationUpdate(newTrack: PlayingTrack) { + fun alertMediaNotificationUpdate(newTrack: PlayingTrack, isMediaPlaying: Boolean) { newTrack.updatePlayingTrack( onTrackIsOutdated = { track -> beforeMetadataSet() @@ -161,18 +155,18 @@ class ListenSubmissionState { } afterMetadataSet() - alertPlaybackStateChanged() + alertPlaybackStateChanged(isMediaPlaying) Log.d("notificationPosted: Updated current track") }, onTrackIsSimilarCallbackTrack = { track -> // We definitely know that whenever the notification bar changes a bit, we will get a state // update which means we have a valid reason to query if music is playing or not. - alertPlaybackStateChanged() + alertPlaybackStateChanged(isMediaPlaying) Log.d("notificationPosted: metadata is already updated, playback state changed.") }, onTrackIsSimilarNotificationTrack = { track -> // Same as above. - alertPlaybackStateChanged() + alertPlaybackStateChanged(isMediaPlaying) Log.d("notificationPosted: track is similar, metadata is about the same, playback state changed.") } ) @@ -183,10 +177,10 @@ class ListenSubmissionState { } /** Toggle timer based on state. */ - fun alertPlaybackStateChanged() { + fun alertPlaybackStateChanged(isMediaPlaying: Boolean) { if (playingTrack.isSubmitted()) return - - if (audioManager.isMusicActive) { + + if (isMediaPlaying) { timer.startOrResume() Log.d("Play") } else { From abc65ab6ad6de64339cb4a5605ace6355f00fa84 Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:44:35 +0530 Subject: [PATCH 10/12] Move isMediaPlaying variable upwards --- .../android/util/ListenSessionListener.kt | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt b/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt index 1ba818af..a9f94b97 100644 --- a/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt +++ b/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt @@ -22,6 +22,7 @@ class ListenSessionListener( ) : OnActiveSessionsChangedListener { private val availableSessions: ConcurrentHashMap = ConcurrentHashMap() private val activeSessions: ConcurrentHashMap = ConcurrentHashMap() + val isMediaPlaying get() = activeSessions.any { it.key.playbackState?.isPlaying == true } @Synchronized override fun onActiveSessionsChanged(controllers: List?) { @@ -30,7 +31,7 @@ class ListenSessionListener( clearSessions() registerControllers(controllers) } - + init { serviceScope.launch { appPreferences @@ -43,20 +44,20 @@ class ListenSessionListener( if (entry.key.packageName !in whitelist) { // Unregister listen callback entry.key.unregisterCallback(entry.value!!) - + // remove the active session. activeSessions.remove(entry.key) Log.d("### UNREGISTERED MediaController Callback for ${entry.key.packageName}.") } } } - + // Registering callback is reactive. for (entry in availableSessions) { if (!activeSessions.contains(entry.key.packageName) && entry.key.packageName in whitelist) { // register listen callback entry.key.registerCallback(entry.value!!) - + // add to active sessions. activeSessions[entry.key] = entry.value!! Log.d("### REGISTERED MediaController Callback for ${entry.key.packageName}.") @@ -66,12 +67,12 @@ class ListenSessionListener( } } } - + private fun registerControllers(controllers: List) { val whitelist = runBlocking { appPreferences.listeningWhitelist.get() } - + fun MediaController.shouldListen(): Boolean = packageName in whitelist - + for (controller in controllers) { // BlackList if (!controller.shouldListen()){ @@ -86,7 +87,7 @@ class ListenSessionListener( updateAppsList(controllers) } - + private fun updateAppsList(controllers: List) { // Adding any new app packages found in the notification. serviceScope.launch(Dispatchers.Default) { @@ -98,7 +99,7 @@ class ListenSessionListener( } } } - + appPreferences.listeningApps.getAndUpdate { val appList = it.toMutableList() controllers.forEach { controller -> @@ -123,20 +124,18 @@ class ListenSessionListener( } private inner class ListenCallback(private val player: String) : MediaController.Callback() { - + @Synchronized override fun onMetadataChanged(metadata: MediaMetadata?) { listenServiceManager.onMetadataChanged(metadata, player) } - + @Synchronized override fun onPlaybackStateChanged(state: PlaybackState?) { listenServiceManager.onPlaybackStateChanged(state) } } - val isMediaPlaying get() = activeSessions.any { it.key.playbackState?.isPlaying == true } - companion object { inline val PlaybackState.isPlaying: Boolean get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING From 46c857ed991b39f29a87f87fc9984bd9ef0d28d1 Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:51:00 +0530 Subject: [PATCH 11/12] Used onListenerConnected instead of onCreate as docs suggested. --- .../service/ListenSubmissionService.kt | 45 +++++++++++++------ .../android/util/ListenSessionListener.kt | 5 +++ 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionService.kt b/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionService.kt index 2e059d5f..465bbca9 100644 --- a/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionService.kt +++ b/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionService.kt @@ -29,9 +29,14 @@ class ListenSubmissionService : NotificationListenerService() { @Inject lateinit var serviceManager: ListenServiceManager - private var sessionListener: ListenSessionListener? = null - private var listenServiceComponent: ComponentName? = null private val scope = MainScope() + + private var _sessionListener: ListenSessionListener? = null + private val sessionListener: ListenSessionListener + get() = _sessionListener!! + + private var listenServiceComponent: ComponentName? = null + private var isConnected = false private val nm: NotificationManager? by lazy { val manager = ContextCompat.getSystemService(this, NotificationManager::class.java) @@ -45,22 +50,33 @@ class ListenSubmissionService : NotificationListenerService() { Log.e("MediaSessionManager is not available in this context.") manager } - - override fun onCreate() { - super.onCreate() - initialize() + + override fun onListenerConnected() { + // Called more times than onListenerDisconnected for some reason. + if (!isConnected) { + initialize() + isConnected = true + } + } + + override fun onListenerDisconnected() { + if (isConnected) { + destroy() + Log.d("onListenerDisconnected: Listen Service paused.") + isConnected = false + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = Service.START_STICKY private fun initialize() { Log.d("Initializing Listener Service") - sessionListener = ListenSessionListener(appPreferences, serviceManager, scope) + _sessionListener = ListenSessionListener(appPreferences, serviceManager, scope) listenServiceComponent = ComponentName(this, this.javaClass) createNotificationChannel() try { - sessionManager?.addOnActiveSessionsChangedListener(sessionListener!!, listenServiceComponent) + sessionManager?.addOnActiveSessionsChangedListener(sessionListener, listenServiceComponent) } catch (e: SecurityException) { Log.e(message = "Could not add session listener due to security exception: ${e.message}") } catch (e: Exception) { @@ -68,18 +84,21 @@ class ListenSubmissionService : NotificationListenerService() { } } - override fun onDestroy() { + private fun destroy() { deleteNotificationChannel() - sessionListener?.clearSessions() - sessionListener?.let { sessionManager?.removeOnActiveSessionsChangedListener(it) } + sessionListener.clearSessions() + sessionListener.let { sessionManager?.removeOnActiveSessionsChangedListener(it) } + } + + override fun onDestroy() { serviceManager.close() scope.cancel() - Log.d("onDestroy: Listen Scrobble Service stopped.") + Log.d("onDestroy: Listen Service stopped.") super.onDestroy() } override fun onNotificationPosted(sbn: StatusBarNotification?) { - serviceManager.onNotificationPosted(sbn, sessionListener!!.isMediaPlaying) + serviceManager.onNotificationPosted(sbn, sessionListener.isMediaPlaying) } override fun onNotificationRemoved( diff --git a/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt b/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt index a9f94b97..f4f6cf14 100644 --- a/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt +++ b/app/src/main/java/org/listenbrainz/android/util/ListenSessionListener.kt @@ -82,6 +82,11 @@ class ListenSessionListener( activeSessions[controller] = callback availableSessions[controller] = callback controller.registerCallback(callback) + + // Force call the callback for the first time a controller is registered. + callback.onMetadataChanged(controller.metadata) + callback.onPlaybackStateChanged(controller.playbackState) + Log.d("### REGISTERED MediaController callback for ${controller.packageName}.") } From 0eacad3eba223da265b3cb18d93e73115f80755e Mon Sep 17 00:00:00 2001 From: Jasjeet Singh <98077881+07jasjeet@users.noreply.github.com> Date: Thu, 26 Sep 2024 15:01:04 +0530 Subject: [PATCH 12/12] Used work manager for setting minimum delay --- .../org/listenbrainz/android/di/AppModule.kt | 13 ++ .../di/RemotePlayerRepositoryModule.kt | 4 - .../ListenServiceManager.kt | 5 + .../ListenServiceManagerImpl.kt | 10 +- .../service/BrainzPlayerServiceConnection.kt | 2 +- .../android/service/ListenSubmissionWorker.kt | 7 +- .../android/util/ListenSubmissionState.kt | 19 ++- .../org/listenbrainz/android/util/Timer.kt | 151 +++++++++++++++++- 8 files changed, 192 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/org/listenbrainz/android/di/AppModule.kt b/app/src/main/java/org/listenbrainz/android/di/AppModule.kt index bfabc6fb..2c90b9b5 100644 --- a/app/src/main/java/org/listenbrainz/android/di/AppModule.kt +++ b/app/src/main/java/org/listenbrainz/android/di/AppModule.kt @@ -7,6 +7,9 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import org.listenbrainz.android.repository.listenservicemanager.ListenServiceManager +import org.listenbrainz.android.repository.listenservicemanager.ListenServiceManagerImpl import org.listenbrainz.android.repository.preferences.AppPreferences import org.listenbrainz.android.repository.preferences.AppPreferencesImpl import org.listenbrainz.android.service.BrainzPlayerServiceConnection @@ -19,6 +22,16 @@ object AppModule { @Provides fun providesAppPreferences(@ApplicationContext context: Context) : AppPreferences = AppPreferencesImpl(context) + + @Singleton + @Provides + fun providesListenServiceManager( + workManager: WorkManager, + appPreferences: AppPreferences, + @DefaultDispatcher defaultDispatcher: CoroutineDispatcher, + @ApplicationContext context: Context + ): ListenServiceManager = + ListenServiceManagerImpl(workManager, appPreferences, defaultDispatcher, context) @Provides fun providesWorkManager(@ApplicationContext context: Context): WorkManager = diff --git a/app/src/main/java/org/listenbrainz/android/di/RemotePlayerRepositoryModule.kt b/app/src/main/java/org/listenbrainz/android/di/RemotePlayerRepositoryModule.kt index 3f276f8b..70bb35f4 100644 --- a/app/src/main/java/org/listenbrainz/android/di/RemotePlayerRepositoryModule.kt +++ b/app/src/main/java/org/listenbrainz/android/di/RemotePlayerRepositoryModule.kt @@ -15,8 +15,4 @@ abstract class RemotePlayerRepositoryModule { @Binds abstract fun bindsRemotePlayerRepository(repository: RemotePlaybackHandlerImpl?): RemotePlaybackHandler? - - - @Binds - abstract fun bindsListenServiceManager(listenServiceManager: ListenServiceManagerImpl): ListenServiceManager } \ No newline at end of file diff --git a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManager.kt b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManager.kt index 6a70f648..b5271289 100644 --- a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManager.kt +++ b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManager.kt @@ -3,8 +3,13 @@ package org.listenbrainz.android.repository.listenservicemanager import android.media.MediaMetadata import android.media.session.PlaybackState import android.service.notification.StatusBarNotification +import org.listenbrainz.android.util.ListenSubmissionState +import javax.inject.Singleton +@Singleton interface ListenServiceManager { + + val listenSubmissionState: ListenSubmissionState fun onMetadataChanged(metadata: MediaMetadata?, player: String) diff --git a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt index fe805e23..97bdfe8e 100644 --- a/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt +++ b/app/src/main/java/org/listenbrainz/android/repository/listenservicemanager/ListenServiceManagerImpl.kt @@ -21,12 +21,14 @@ import org.listenbrainz.android.util.ListenSubmissionState import org.listenbrainz.android.util.ListenSubmissionState.Companion.extractTitle import org.listenbrainz.android.util.Log import javax.inject.Inject +import javax.inject.Singleton /** The sole responsibility of this layer is to maintain mutual exclusion between [onMetadataChanged] and * [onNotificationPosted], filter out repetitive submissions and handle changes in settings which concern - * listen scrobbing. + * listening. * * FUTURE: Call notification popups here as well.*/ +@Singleton class ListenServiceManagerImpl @Inject constructor( workManager: WorkManager, private val appPreferences: AppPreferences, @@ -35,7 +37,7 @@ class ListenServiceManagerImpl @Inject constructor( ): ListenServiceManager { private val handler: Handler = Handler(Looper.getMainLooper()) - private val listenSubmissionState = ListenSubmissionState(handler, workManager, context) + override val listenSubmissionState = ListenSubmissionState(workManager, context) //private val jobQueue: JobQueue by lazy { JobQueue(defaultDispatcher) } //private val listenSubmissionState = ListenSubmissionState(jobQueue, workManager, context) private val scope = MainScope() @@ -132,7 +134,9 @@ class ListenServiceManagerImpl @Inject constructor( return@post }, pkgName = sbn.packageName, - timestamp = sbn.notification.`when` + timestamp = sbn.notification.`when`.let { + if (it == 0L) System.currentTimeMillis() else it + } ) // Avoid repetitive submissions diff --git a/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt b/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt index c6bd9003..8bc63fc9 100644 --- a/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt +++ b/app/src/main/java/org/listenbrainz/android/service/BrainzPlayerServiceConnection.kt @@ -101,7 +101,7 @@ class BrainzPlayerServiceConnection( } private inner class MediaControllerCallback(context: Context) : MediaControllerCompat.Callback() { val listenSubmissionState: ListenSubmissionState by lazy { - ListenSubmissionState(handler = HandlerCompat.createAsync(Looper.getMainLooper()), workManager = workManager, context = context) + ListenSubmissionState(workManager = workManager, context = context) } override fun onPlaybackStateChanged(state: PlaybackStateCompat?) { diff --git a/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionWorker.kt b/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionWorker.kt index a5014a5f..1b9d0c43 100644 --- a/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionWorker.kt +++ b/app/src/main/java/org/listenbrainz/android/service/ListenSubmissionWorker.kt @@ -121,9 +121,12 @@ class ListenSubmissionWorker @AssistedInject constructor( if (inputData.getString("TYPE") == "single"){ // We don't want to submit playing nows later. if (response.error?.ordinal == ResponseError.BAD_REQUEST.ordinal) { - Log.d("Submission failed, not saving listen because metadata is faulty.") + Log.e( + "Submission failed, not saving listen because metadata is faulty." + + "\n Server response: ${response.error.toast()}" + "\n POST Request Body: $body" + ) } else { - Log.d("Submission failed, listen saved.") + Log.e("Submission failed, listen saved.") pendingListensDao.addListen(listen) } } diff --git a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt index 7e24e754..84caee5d 100644 --- a/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt +++ b/app/src/main/java/org/listenbrainz/android/util/ListenSubmissionState.kt @@ -1,11 +1,9 @@ package org.listenbrainz.android.util import android.content.Context -import android.media.AudioManager import android.media.MediaMetadata import android.os.Handler import android.service.notification.StatusBarNotification -import androidx.core.content.ContextCompat import androidx.work.WorkManager import kotlinx.coroutines.Dispatchers import org.listenbrainz.android.model.ListenType @@ -16,7 +14,7 @@ import org.listenbrainz.android.service.ListenSubmissionWorker.Companion.buildWo class ListenSubmissionState { var playingTrack: PlayingTrack = PlayingTrack() private set - private val timer: Timer + val timer: Timer private val workManager: WorkManager private val context: Context @@ -35,6 +33,14 @@ class ListenSubmissionState { init() } + + constructor(workManager: WorkManager, context: Context) { + this.workManager = workManager + this.context = context + this.timer = TimerWorkManager(workManager) + + init() + } fun init() { // Setting listener @@ -147,7 +153,7 @@ class ListenSubmissionState { onTrackIsOutdated = { track -> beforeMetadataSet() - playingTrack = if (playingTrack.isSimilarTo(track)){ + playingTrack = if (playingTrack.isSimilarTo(track)) { // Old track has useful metadata like duration, so smartly retrieve. track.apply { duration = playingTrack.duration } } else { @@ -207,9 +213,8 @@ class ListenSubmissionState { // Utility functions - private fun roundDuration(duration: Long): Long { - return (duration / 1000) * 1000 - } + private fun roundDuration(duration: Long): Long = + (duration / 1000) * 1000 private fun submitListen(listenType: ListenType) = workManager.enqueue(buildWorkRequest(playingTrack, listenType)) diff --git a/app/src/main/java/org/listenbrainz/android/util/Timer.kt b/app/src/main/java/org/listenbrainz/android/util/Timer.kt index 7e139c66..d17e630c 100644 --- a/app/src/main/java/org/listenbrainz/android/util/Timer.kt +++ b/app/src/main/java/org/listenbrainz/android/util/Timer.kt @@ -1,10 +1,30 @@ package org.listenbrainz.android.util +import android.content.Context +import android.media.MediaMetadata +import android.os.Build import android.os.Handler import android.os.SystemClock import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkRequest +import androidx.work.Worker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject import org.listenbrainz.android.model.OnTimerListener import org.listenbrainz.android.model.TimerState +import org.listenbrainz.android.repository.listenservicemanager.ListenServiceManager +import org.listenbrainz.android.service.ListenSubmissionWorker +import org.listenbrainz.android.util.ListenSubmissionState.Companion +import org.listenbrainz.android.util.ListenSubmissionState.Companion.DEFAULT_DURATION +import java.util.concurrent.TimeUnit interface Timer { fun startOrResume(delay: Long = 0L) @@ -18,6 +38,8 @@ interface Timer { fun pause() fun stop() + + fun end() } /** **NOT** thread safe.*/ @@ -52,7 +74,7 @@ abstract class TimerBase: Timer { TimerState.RUNNING -> return TimerState.PAUSED -> { mResumeTs = SystemClock.uptimeMillis() - + postDelayed( mDurationLeft, MESSAGE_TOKEN, @@ -78,7 +100,7 @@ abstract class TimerBase: Timer { } } - private fun end() { + override fun end() { if (mState == TimerState.ENDED) { return } @@ -175,4 +197,129 @@ class TimerHandler( ) override fun pause() = pause { handler.removeCallbacksAndMessages(it) } +} + +class TimerWorkManager(private val workManager: WorkManager): TimerBase() { + + companion object { + private const val TAG = "Timer" + private const val TIMER_STATE = "TimerState" + } + + private var mState: TimerState = TimerState.ENDED + private var mListener: OnTimerListener? = null + + private var mInitialDuration = 0L + private var mResumeTs: Long = 0 + private var mDurationLeft: Long = 0L + + override fun setDuration(duration: Long) { + mInitialDuration = duration + mDurationLeft = duration + } + + override fun setOnTimerListener(listener: OnTimerListener) { + mListener = listener + Log.d(TAG, "setOnTimerListener: ") + } + + override fun startOrResume(delay: Long) { + when (mState) { + TimerState.RUNNING -> return + TimerState.PAUSED -> { + mResumeTs = SystemClock.uptimeMillis() + + scheduleRequest() + Log.d(TAG,"Timer resumed") + + mListener?.onTimerResumed() + mState = TimerState.RUNNING + } + TimerState.ENDED -> { + mResumeTs = SystemClock.uptimeMillis() + mDurationLeft += delay + + scheduleRequest() + + Log.d(TAG,"Timer started") + + mListener?.onTimerStarted() + mState = TimerState.RUNNING + } + } + } + + private fun scheduleRequest() { + val constraints = Constraints.Builder().run { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + setTriggerContentMaxDelay(mDurationLeft, TimeUnit.MILLISECONDS) + } else this + }.build() + + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(mDurationLeft, TimeUnit.MILLISECONDS) + .setConstraints(constraints) + .build() + + workManager + .beginUniqueWork(TAG, ExistingWorkPolicy.APPEND_OR_REPLACE, request) + .enqueue() + } + + override fun end() { + if (mState == TimerState.ENDED) { + return + } + mState = TimerState.ENDED + mListener?.onTimerEnded() + reset() + } + + /** Discard current listen post and stop timer.*/ + override fun stop() { + if (mState == TimerState.ENDED) { + return + } + mState = TimerState.ENDED + workManager.cancelUniqueWork(TAG) + reset() + } + + override fun extendDuration(extensionSeconds: (passedSeconds: Long) -> Long) { + pause() + mDurationLeft = extensionSeconds(/*passedSeconds =*/mInitialDuration - mDurationLeft) + startOrResume() + } + + override fun pause() { + Log.d(TAG,"Timer paused") + if (mState == TimerState.PAUSED || mState == TimerState.ENDED) { + return + } + mState = TimerState.PAUSED + workManager.cancelUniqueWork(TAG) + + val durationLeft = mDurationLeft - (SystemClock.uptimeMillis() - mResumeTs) + mListener?.onTimerPaused(durationLeft) + mDurationLeft = durationLeft + } + + private fun reset() { + mResumeTs = 0L + mDurationLeft = 0L + mInitialDuration = 0L + } +} + +@HiltWorker +class TimerWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + private val listenServiceManager: ListenServiceManager +): Worker(context, workerParams) { + override fun doWork(): Result { + //end() + listenServiceManager.listenSubmissionState.timer.end() + return Result.success() + } } \ No newline at end of file