Skip to content

Commit

Permalink
Prompt for notification access when opening archives if not granted
Browse files Browse the repository at this point in the history
  • Loading branch information
arkon committed May 19, 2024
1 parent dd378e7 commit 11dc21b
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 56 deletions.
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.livetl.android.data.media
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.media.session.MediaSessionManager
import android.os.Build
import android.provider.Settings
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 timber.log.Timber
Expand Down Expand Up @@ -32,8 +34,19 @@ class YouTubeNotificationListenerService : NotificationListenerService() {
}

companion object {
fun isNotificationAccessGranted(context: Context): Boolean =
NotificationManagerCompat.getEnabledListenerPackages(context)
.any { it == context.packageName }
fun getPermissionScreenIntent(context: Context): 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.name,
)
putExtra(
Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
componentName.flattenToString(),
)
}
} else {
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import androidx.core.content.getSystemService
import com.livetl.android.data.stream.StreamService
import com.livetl.android.util.isNotificationAccessGranted
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -71,7 +72,7 @@ class YouTubeSessionService @Inject constructor(
}

fun attach() {
if (YouTubeNotificationListenerService.isNotificationAccessGranted(context)) {
if (context.isNotificationAccessGranted()) {
Timber.d("Starting media session listener")
val mediaSessionManager = context.getSystemService<MediaSessionManager>()
mediaSessionManager?.addOnActiveSessionsChangedListener(this, component)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
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 Down Expand Up @@ -31,7 +27,6 @@ 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
Expand Down Expand Up @@ -124,34 +119,6 @@ fun AboutScreen(onBackPressed: () -> Unit, navigateToLicenses: () -> Unit, navig
onClick = { navigateToWelcome() },
)
}

// TODO: prompt this better
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.name,
)
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 @@ -75,17 +75,17 @@ fun StreamInfo(urlOrId: String, viewModel: StreamInfoViewModel = hiltViewModel()
text = state.stream!!.title,
style = MaterialTheme.typography.headlineMedium,
)
Spacer(modifier = Modifier.requiredHeight(8.dp))
Spacer(Modifier.requiredHeight(8.dp))
Text(
text = state.stream!!.channel.name,
style = MaterialTheme.typography.bodyMedium,
)

HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
HorizontalDivider(Modifier.padding(vertical = 8.dp))

StreamActions(state.stream!!.id)

HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
HorizontalDivider(Modifier.padding(vertical = 8.dp))

state.description?.let {
Text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.livetl.android.data.chat.ChatFilterService
import com.livetl.android.data.chat.ChatMessage
import com.livetl.android.data.media.YouTubeSession
import com.livetl.android.data.media.YouTubeSessionService
import com.livetl.android.data.stream.StreamInfo
Expand All @@ -12,6 +13,8 @@ import com.livetl.android.data.stream.VideoIdParser
import com.livetl.android.ui.screen.player.composable.chat.EmojiCache
import com.livetl.android.util.AppPreferences
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
Expand All @@ -34,11 +37,22 @@ class PlayerViewModel @Inject constructor(
) : ViewModel() {
val state = MutableStateFlow(State())

val filteredMessages = chatFilterService.messages

init {
youTubeSessionService.attach()
viewModelScope.launch {
chatFilterService.messages
.collectLatest { messages ->
state.update { it.copy(filteredMessages = messages) }
}
}

viewModelScope.launch {
prefs.tlScale().asFlow()
.collectLatest { tlScale ->
state.update { it.copy(fontScale = tlScale) }
}
}

youTubeSessionService.attach()
viewModelScope.launch(Dispatchers.IO) {
youTubeSessionService.session
.filterNotNull()
Expand Down Expand Up @@ -81,6 +95,8 @@ class PlayerViewModel @Inject constructor(
@Immutable
data class State(
val chatState: ChatState = ChatState.LOADING,
val filteredMessages: ImmutableList<ChatMessage> = persistentListOf(),
val fontScale: Float = 1f,
val streamInfo: StreamInfo? = null,
// TODO: show message if playing video seems to have changed
val youTubeSession: YouTubeSession? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
Expand All @@ -23,6 +26,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
Expand All @@ -35,19 +39,21 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.livetl.android.R
import com.livetl.android.data.chat.ChatMessage
import com.livetl.android.data.media.YouTubeNotificationListenerService
import com.livetl.android.data.stream.StreamInfo
import com.livetl.android.ui.screen.player.ChatState
import com.livetl.android.ui.screen.player.PlayerViewModel
import com.livetl.android.util.collectAsStateWithLifecycle
import com.livetl.android.util.findActivity
import com.livetl.android.util.rememberIsInPipMode
import com.livetl.android.util.rememberIsInSplitScreenMode
import com.livetl.android.util.rememberIsNotificationAccessGranted
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch

Expand All @@ -67,36 +73,36 @@ fun PlayerTabs(
val isInPipMode = rememberIsInPipMode()
val isInSplitScreenMode = rememberIsInSplitScreenMode()

val tlScale by viewModel.prefs.tlScale().collectAsStateWithLifecycle()
val filteredMessages by viewModel.filteredMessages.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()

if (isInPipMode || isInSplitScreenMode) {
ChatTab(
filteredMessages = filteredMessages,
fontScale = tlScale,
filteredMessages = state.filteredMessages,
fontScale = state.fontScale,
state = chatState,
)
return
}

FullPlayerTab(
streamInfo,
filteredMessages,
tlScale,
chatState,
modifier,
streamInfo = streamInfo,
filteredMessages = state.filteredMessages,
fontScale = state.fontScale,
chatState = chatState,
modifier = modifier,
)
}

@Composable
private fun FullPlayerTab(
streamInfo: StreamInfo?,
filteredMessages: ImmutableList<ChatMessage>,
tlScale: Float,
fontScale: Float,
chatState: ChatState,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val isNotificationAccessGranted = rememberIsNotificationAccessGranted()
val context = LocalContext.current
val uriHandler = LocalUriHandler.current

Expand All @@ -120,6 +126,34 @@ private fun FullPlayerTab(
)
}

if (streamInfo?.isLive == false && !isNotificationAccessGranted) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.error_no_notification_access),
textAlign = TextAlign.Center,
)

Spacer(Modifier.requiredHeight(8.dp))

Button(
onClick = {
val intent = YouTubeNotificationListenerService.getPermissionScreenIntent(context)
context.startActivity(intent)
},
) {
Text(text = stringResource(R.string.action_grant_notification_access))
}
}

return
}

Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Expand Down Expand Up @@ -188,7 +222,7 @@ private fun FullPlayerTab(
ChatTab(
modifier = Modifier.fillMaxSize(),
filteredMessages = filteredMessages,
fontScale = tlScale,
fontScale = fontScale,
state = chatState,
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.livetl.android.util

import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner

fun Context.isNotificationAccessGranted(): Boolean = NotificationManagerCompat.getEnabledListenerPackages(this)
.any { it == packageName }

@Composable
fun rememberIsNotificationAccessGranted(initialValue: Boolean = true): Boolean {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current

var accessGranted by remember { mutableStateOf(initialValue) }

DisposableEffect(lifecycleOwner.lifecycle) {
val observer = object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
accessGranted = context.isNotificationAccessGranted()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}

return accessGranted
}
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
<string name="chat_no_messages">No chat messages to show yet</string>
<string name="error_chat_load">Failed to connect to chat</string>
<string name="new_member">New member</string>
<string name="error_no_notification_access">To sync progress with YouTube, LiveTL needs notification access.</string>
<string name="action_grant_notification_access">Grant LiveTL notification access</string>

<string name="setting_tl_languages">Languages</string>
<string name="setting_show_all_messages">Show all messages</string>
Expand Down

0 comments on commit 11dc21b

Please sign in to comment.