Skip to content

Commit

Permalink
Add back notification listener for permissions to get media sessions
Browse files Browse the repository at this point in the history
  • Loading branch information
arkon committed May 19, 2024
1 parent f9ed323 commit b75071f
Show file tree
Hide file tree
Showing 15 changed files with 235 additions and 104 deletions.
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@
<data android:host="translate" />
</intent-filter>
</activity>

<service
android:name=".data.media.YouTubeNotificationListenerService"
android:exported="false"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class ChatFilterService @Inject constructor(
}
}

fun seekTo(videoId: String, second: Long) {
suspend fun seekTo(videoId: String, second: Long) {
chatService.seekTo(videoId, second)
}

Expand Down
28 changes: 17 additions & 11 deletions app/src/main/kotlin/com/livetl/android/data/chat/ChatService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
Expand Down Expand Up @@ -79,18 +80,20 @@ class ChatService @Inject constructor(
webview.loadUrl(chatUrl)
}

fun seekTo(videoId: String, second: Long) {
if (second != currentSecond) {
Timber.d("$videoId: seeking to $second")
webview.runJS("window.postMessage({ 'yt-player-video-progress': $second, video: '$videoId'}, '*');")
suspend fun seekTo(videoId: String, second: Long) {
withContext(Dispatchers.Main) {
if (second != currentSecond) {
Timber.d("$videoId: seeking to $second")
webview.runJS("window.postMessage({ 'yt-player-video-progress': $second, video: '$videoId'}, '*');")

// Clear out messages if we seem to be manually seeking
if (currentSecond - 10 > second || second > currentSecond + 10) {
Timber.d("$videoId: manual seek")
clearMessages()
}
// Clear out messages if we seem to be manually seeking
if (currentSecond - 10 > second || second > currentSecond + 10) {
Timber.d("$videoId: manual seek")
clearMessages()
}

currentSecond = second
currentSecond = second
}
}
}

Expand Down Expand Up @@ -126,7 +129,10 @@ class ChatService @Inject constructor(
}

val message = it.toChatMessage()
_messages.value = (_messages.value + message).takeLast(MAX_MESSAGE_QUEUE_SIZE).toImmutableList()
_messages.value = (_messages.value + message)
.distinct()
.takeLast(MAX_MESSAGE_QUEUE_SIZE)
.toImmutableList()
}
}

Expand Down
3 changes: 1 addition & 2 deletions app/src/main/kotlin/com/livetl/android/data/chat/Models.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.livetl.android.data.chat

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.util.fastFilter
import androidx.compose.ui.util.fastMap
import kotlinx.serialization.Serializable

Expand Down Expand Up @@ -142,7 +141,7 @@ data class YTChatMessage(
milestone = headerRuns
.fastMap { it.toChatMessageContent() }
.filterIsInstance<ChatMessageContent.Text>()
.joinToString { it.text },
.joinToString(" ") { it.text },
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.livetl.android.data.media
import android.content.ComponentName
import android.content.Context
import android.media.session.MediaSessionManager
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class YouTubeNotificationListenerService : NotificationListenerService() {

@Inject
lateinit var youTubeSessionService: YouTubeSessionService

override fun onNotificationPosted(sbn: StatusBarNotification) {
super.onNotificationPosted(sbn)

if (sbn.packageName != YOUTUBE_PACKAGE_NAME) {
return
}

val mediaSessionManager = getSystemService<MediaSessionManager>() ?: return

val component = ComponentName(this, YouTubeNotificationListenerService::class.java)
val sessions = mediaSessionManager.getActiveSessions(component)
youTubeSessionService.onActiveSessionsChanged(sessions)
}

companion object {
fun isNotificationAccessGranted(context: Context): Boolean =
NotificationManagerCompat.getEnabledListenerPackages(context)
.any { it == context.packageName }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,54 @@ import com.livetl.android.data.stream.StreamService
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds

// TODO: clean this up
@Singleton
class YouTubeMediaSessionService @Inject constructor(
class YouTubeSessionService @Inject constructor(
@ApplicationContext private val context: Context,
private val streamService: StreamService,
) : MediaSessionManager.OnActiveSessionsChangedListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

private val _session = MutableStateFlow<YouTubeSession?>(null)
val session: SharedFlow<YouTubeSession?>
get() = _session.asSharedFlow()

private val component = ComponentName(context, YouTubeMediaSessionService::class.java)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val component = ComponentName(context, YouTubeSessionService::class.java)

private var mediaController: MediaController? = null
private var progressJob: Job? = null
private val mediaControllerCallback = object : MediaController.Callback() {
override fun onSessionDestroyed() {
mediaController = null
}

override fun onPlaybackStateChanged(state: PlaybackState?) {
scope.launch {
_session.value = getYouTubeSession()

if (state?.state == PlaybackState.STATE_PLAYING && _session.value?.isLive == false) {
progressJob = launch {
while (true) {
delay(1.seconds)
_session.value = _session.value?.copy(
positionInMs = (_session.value?.positionInMs ?: 0L) + 1000L,
)
Timber.d("Updating progress to: ${_session.value?.positionInMs}")
}
}
} else {
progressJob?.cancel()
progressJob = null
}
}
}

Expand All @@ -50,20 +67,45 @@ class YouTubeMediaSessionService @Inject constructor(
_session.value = getYouTubeSession()
}
}

override fun onSessionDestroyed() {
mediaController?.unregisterCallback(this)
mediaController = null
}
}

init {
Timber.i("Listening for YouTube media sessions")
// TODO: stop listening at some point?
context.getSystemService<MediaSessionManager>()?.addOnActiveSessionsChangedListener(this, component)
fun attach() {
if (YouTubeNotificationListenerService.isNotificationAccessGranted(context)) {
Timber.d("Starting media session listener")
val mediaSessionManager = context.getSystemService<MediaSessionManager>()
mediaSessionManager?.addOnActiveSessionsChangedListener(this, component)
mediaSessionManager?.getActiveSessions(component)?.let(::listenToYouTubeMediaSession)
} else {
Timber.d("Can't start media session listener due to missing notification listener permissions")
}
}

override fun onActiveSessionsChanged(controllers: MutableList<MediaController>?) {
fun detach() {
Timber.d("Stopping media session listener")
context.getSystemService<MediaSessionManager>()?.removeOnActiveSessionsChangedListener(this)
}

override fun onActiveSessionsChanged(controllers: List<MediaController>?) {
Timber.d("Active media sessions updated")
listenToYouTubeMediaSession(controllers)
}

private fun listenToYouTubeMediaSession(controllers: List<MediaController>?) {
controllers
?.find { it.packageName == YOUTUBE_PACKAGE_NAME }
?.let {
it.registerCallback(mediaControllerCallback)
if (mediaController != null) {
mediaController?.unregisterCallback(mediaControllerCallback)
mediaController = null
}

mediaController = it
it.registerCallback(mediaControllerCallback)
}
}

Expand All @@ -87,7 +129,8 @@ class YouTubeMediaSessionService @Inject constructor(
title = title,
channelName = channelName,
playbackState = state,
position = position,
isLive = position == 0L || streamInfo?.isLive == true,
positionInMs = position,
videoId = streamInfo?.videoId,
)
}
Expand All @@ -97,7 +140,8 @@ data class YouTubeSession(
val title: String,
val channelName: String,
val playbackState: YouTubeVideoPlaybackState,
val position: Long?,
val isLive: Boolean,
val positionInMs: Long?,
val videoId: String?,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ typealias StringAnnotation = AnnotatedString.Range<String>
// Pair returning styled content and annotation for ClickableText when matching syntax token
typealias SymbolAnnotation = Pair<AnnotatedString, StringAnnotation?>

// TODO: default emoji don't seem to appear properly
/**
* Parses a string so that it's renderable with its content.
*
* http(s)://... -> clickable link, opening in a browser
* :text: -> default chat emote
* :_text: -> custom chat emote
* #text -> hashtag
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
Expand All @@ -23,7 +22,6 @@ import com.livetl.android.ui.screen.home.HomeScreen
import com.livetl.android.ui.screen.home.composable.StreamInfo
import com.livetl.android.ui.screen.home.settings.SettingsScreen
import com.livetl.android.ui.screen.player.PlayerScreen
import com.livetl.android.ui.screen.player.PlayerViewModel
import com.livetl.android.ui.screen.welcome.WelcomeScreen

fun NavHostController.navigateToPlayer(urlOrId: String) {
Expand All @@ -49,7 +47,10 @@ fun mainNavHost(startRoute: Route): NavHostController {
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
NavHost(navController, startDestination = startRoute) {
NavHost(
navController = navController,
startDestination = startRoute,
) {
composable<Route.Home> {
HomeScreen(
navigateToStreamInfo = { navController.navigate("${Route.StreamInfo}?urlOrId=$it") },
Expand Down Expand Up @@ -78,12 +79,9 @@ fun mainNavHost(startRoute: Route): NavHostController {
}

composable<Route.Player> { backStackEntry ->
val playerViewModel = hiltViewModel<PlayerViewModel>()

val urlOrId = backStackEntry.toRoute<Route.Player>().urlOrId
val videoId = playerViewModel.getVideoId(urlOrId)

PlayerScreen(videoId, playerViewModel)
PlayerScreen(urlOrId)
}

composable<Route.Settings> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.livetl.android.ui.screen.about

import android.content.ComponentName
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
Expand All @@ -19,19 +23,22 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.livetl.android.BuildConfig
import com.livetl.android.R
import com.livetl.android.data.media.YouTubeNotificationListenerService
import com.livetl.android.ui.common.LinkIcon
import com.livetl.android.ui.common.preference.PreferenceGroupHeader
import com.livetl.android.ui.common.preference.PreferenceRow

@Composable
fun AboutScreen(onBackPressed: () -> Unit, navigateToLicenses: () -> Unit, navigateToWelcome: () -> Unit) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current

Scaffold(
Expand Down Expand Up @@ -117,6 +124,33 @@ fun AboutScreen(onBackPressed: () -> Unit, navigateToLicenses: () -> Unit, navig
onClick = { navigateToWelcome() },
)
}

if (BuildConfig.DEBUG) {
item {
PreferenceRow(
title = "Grant notification listener permissions",
onClick = {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).apply {
val componentName =
ComponentName(
context.packageName,
YouTubeNotificationListenerService::class.java.getName(),
)
putExtra(
Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
componentName.flattenToString(),
)
}
} else {
Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS")
}

context.startActivity(intent)
},
)
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,6 @@ fun HomeScreen(
},
text = {
Column {
// if (viewModel.youTubeSession != null) {
// Text(text = viewModel.youTubeSession!!.title)
// }

Text(
modifier = Modifier.padding(bottom = 16.dp),
text = stringResource(R.string.open_video_hint, stringResource(R.string.app_name)),
Expand Down
Loading

0 comments on commit b75071f

Please sign in to comment.