Let users pick media player apps
This commit is contained in:
parent
c686fa25c1
commit
c59fa5b667
@ -29,4 +29,31 @@ fun CheckboxPreference(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CheckboxPreference(
|
||||||
|
title: String,
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
|
iconPadding: Boolean = true,
|
||||||
|
summary: String? = null,
|
||||||
|
value: Boolean,
|
||||||
|
onValueChanged: (Boolean) -> Unit,
|
||||||
|
enabled: Boolean = true
|
||||||
|
) {
|
||||||
|
Preference(
|
||||||
|
title = title,
|
||||||
|
icon = icon,
|
||||||
|
iconPadding = iconPadding,
|
||||||
|
summary = summary,
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = {
|
||||||
|
onValueChanged(!value)
|
||||||
|
},
|
||||||
|
controls = {
|
||||||
|
Checkbox(
|
||||||
|
enabled = enabled, checked = value, onCheckedChange = onValueChanged,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@ -2,52 +2,87 @@ package de.mm20.launcher2.ui.settings.media
|
|||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import de.mm20.launcher2.ui.BuildConfig
|
import de.mm20.launcher2.ui.BuildConfig
|
||||||
import de.mm20.launcher2.ui.R
|
import de.mm20.launcher2.ui.R
|
||||||
import de.mm20.launcher2.ui.component.MissingPermissionBanner
|
import de.mm20.launcher2.ui.component.MissingPermissionBanner
|
||||||
|
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
|
||||||
|
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference
|
||||||
import de.mm20.launcher2.ui.component.preferences.Preference
|
import de.mm20.launcher2.ui.component.preferences.Preference
|
||||||
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
|
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
|
||||||
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
|
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
|
||||||
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaIntegrationSettingsScreen() {
|
fun MediaIntegrationSettingsScreen() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val viewModel: MediaIntegrationSettingsScreenVM = viewModel()
|
val viewModel: MediaIntegrationSettingsScreenVM = viewModel()
|
||||||
val hasPermission by viewModel.hasPermission.observeAsState()
|
val hasPermission by viewModel.hasPermission.collectAsStateWithLifecycle(null)
|
||||||
|
val loading by viewModel.loading
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
|
LaunchedEffect(null) {
|
||||||
|
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
|
viewModel.onResume(density = density.density)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PreferenceScreen(
|
PreferenceScreen(
|
||||||
stringResource(R.string.preference_screen_musicwidget),
|
stringResource(R.string.preference_media_integration),
|
||||||
helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/widgets/clock"
|
|
||||||
) {
|
) {
|
||||||
|
if (loading) {
|
||||||
|
item {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
PreferenceCategory {
|
AnimatedVisibility(hasPermission == false) {
|
||||||
AnimatedVisibility(hasPermission == false) {
|
MissingPermissionBanner(
|
||||||
MissingPermissionBanner(
|
text = stringResource(R.string.missing_permission_music_widget),
|
||||||
text = stringResource(R.string.missing_permission_music_widget),
|
onClick = {
|
||||||
onClick = {
|
viewModel.requestNotificationPermission(context as AppCompatActivity)
|
||||||
viewModel.requestNotificationPermission(context as AppCompatActivity)
|
},
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PreferenceCategory(
|
||||||
|
stringResource(R.string.preference_category_media_apps)
|
||||||
|
) {
|
||||||
|
val apps by viewModel.appList
|
||||||
|
for (app in apps) {
|
||||||
|
val icon by app.icon.collectAsState(null)
|
||||||
|
CheckboxPreference(
|
||||||
|
icon = {
|
||||||
|
ShapedLauncherIcon(size = 32.dp, icon = { icon })
|
||||||
},
|
},
|
||||||
modifier = Modifier.padding(16.dp)
|
title = app.label,
|
||||||
|
value = app.isChecked,
|
||||||
|
onValueChanged = {
|
||||||
|
viewModel.onAppChecked(app, it)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val filterSources by viewModel.filterSources.observeAsState(false)
|
|
||||||
SwitchPreference(
|
|
||||||
title = stringResource(R.string.preference_music_filter_sources),
|
|
||||||
summary = stringResource(R.string.preference_music_filter_sources_summary),
|
|
||||||
value = filterSources,
|
|
||||||
onValueChanged = {
|
|
||||||
viewModel.setFilterSources(it)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
|
|||||||
@ -1,46 +1,117 @@
|
|||||||
package de.mm20.launcher2.ui.settings.media
|
package de.mm20.launcher2.ui.settings.media
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.asLiveData
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import de.mm20.launcher2.applications.AppRepository
|
||||||
|
import de.mm20.launcher2.icons.IconService
|
||||||
|
import de.mm20.launcher2.icons.LauncherIcon
|
||||||
|
import de.mm20.launcher2.ktx.normalize
|
||||||
import de.mm20.launcher2.music.MusicService
|
import de.mm20.launcher2.music.MusicService
|
||||||
import de.mm20.launcher2.permissions.PermissionGroup
|
import de.mm20.launcher2.permissions.PermissionGroup
|
||||||
import de.mm20.launcher2.permissions.PermissionsManager
|
import de.mm20.launcher2.permissions.PermissionsManager
|
||||||
import de.mm20.launcher2.preferences.LauncherDataStore
|
import de.mm20.launcher2.preferences.LauncherDataStore
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.shareIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class MediaIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
|
class MediaIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
|
||||||
private val permissionsManager: PermissionsManager by inject()
|
private val permissionsManager: PermissionsManager by inject()
|
||||||
private val musicService: MusicService by inject()
|
private val musicService: MusicService by inject()
|
||||||
|
private val appRepository: AppRepository by inject()
|
||||||
|
private val iconService: IconService by inject()
|
||||||
private val dataStore: LauncherDataStore by inject()
|
private val dataStore: LauncherDataStore by inject()
|
||||||
val hasPermission =
|
val hasPermission =
|
||||||
permissionsManager.hasPermission(PermissionGroup.Notifications).asLiveData()
|
permissionsManager.hasPermission(PermissionGroup.Notifications)
|
||||||
|
|
||||||
|
val loading = mutableStateOf(false)
|
||||||
|
|
||||||
|
|
||||||
fun requestNotificationPermission(activity: AppCompatActivity) {
|
fun requestNotificationPermission(activity: AppCompatActivity) {
|
||||||
permissionsManager.requestPermission(activity, PermissionGroup.Notifications)
|
permissionsManager.requestPermission(activity, PermissionGroup.Notifications)
|
||||||
}
|
}
|
||||||
|
|
||||||
val filterSources = dataStore.data.map { it.musicWidget.filterSources }.asLiveData()
|
val appList = mutableStateOf(emptyList<AppListItem>())
|
||||||
fun setFilterSources(filterSources: Boolean) {
|
|
||||||
|
fun resetWidget() {
|
||||||
|
musicService.resetPlayer()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onResume(density: Float) {
|
||||||
|
loading.value = true
|
||||||
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
|
val musicApps = musicService.getInstalledPlayerPackages()
|
||||||
|
val allApps = appRepository.getAllInstalledApps().first().filter { it.isMainProfile }
|
||||||
|
.distinctBy { it.`package` }
|
||||||
|
val settings = dataStore.data.map { it.musicWidget }.first()
|
||||||
|
val allowList = settings.allowListList
|
||||||
|
val denyList = settings.denyListList
|
||||||
|
|
||||||
|
appList.value = allApps.map {
|
||||||
|
AppListItem(
|
||||||
|
label = it.label,
|
||||||
|
packageName = it.`package`,
|
||||||
|
isMusicApp = musicApps.contains(it.`package`),
|
||||||
|
isChecked = allowList.contains(it.`package`) || (!denyList.contains(it.`package`) && musicApps.contains(
|
||||||
|
it.`package`
|
||||||
|
)),
|
||||||
|
icon = iconService.getIcon(it, (32 * density).roundToInt())
|
||||||
|
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(10000))
|
||||||
|
)
|
||||||
|
}.sortedBy { it.label.normalize() }
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAppChecked(app: AppListItem, checked: Boolean) {
|
||||||
|
val list = appList.value.toMutableList()
|
||||||
|
val index = list.indexOf(app)
|
||||||
|
list[index] = app.copy(isChecked = checked)
|
||||||
|
appList.value = list
|
||||||
|
saveState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveState() {
|
||||||
|
val allowList = mutableListOf<String>()
|
||||||
|
val denyList = mutableListOf<String>()
|
||||||
|
val appList = appList.value
|
||||||
|
|
||||||
|
for (app in appList) {
|
||||||
|
if (app.isChecked && !app.isMusicApp) {
|
||||||
|
allowList.add(app.packageName)
|
||||||
|
} else if (!app.isChecked && app.isMusicApp) {
|
||||||
|
denyList.add(app.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dataStore.updateData {
|
dataStore.updateData {
|
||||||
it.toBuilder()
|
it.toBuilder()
|
||||||
.setMusicWidget(
|
.setMusicWidget(
|
||||||
it.musicWidget.toBuilder()
|
it.musicWidget.toBuilder()
|
||||||
.setFilterSources(filterSources)
|
.clearAllowList()
|
||||||
|
.addAllAllowList(allowList)
|
||||||
|
.clearDenyList()
|
||||||
|
.addAllDenyList(denyList)
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetWidget() {
|
}
|
||||||
musicService.resetPlayer()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
data class AppListItem(
|
||||||
|
val label: String,
|
||||||
|
val packageName: String,
|
||||||
|
val isMusicApp: Boolean,
|
||||||
|
val isChecked: Boolean,
|
||||||
|
val icon: Flow<LauncherIcon>,
|
||||||
|
)
|
||||||
@ -550,6 +550,7 @@
|
|||||||
<string name="preference_screen_widgets_summary">Configure widgets</string>
|
<string name="preference_screen_widgets_summary">Configure widgets</string>
|
||||||
<string name="preference_screen_weatherwidget">Weather</string>
|
<string name="preference_screen_weatherwidget">Weather</string>
|
||||||
<string name="preference_screen_musicwidget">Music</string>
|
<string name="preference_screen_musicwidget">Music</string>
|
||||||
|
<string name="preference_category_media_apps">Media apps</string>
|
||||||
<string name="preference_screen_clockwidget">Clock</string>
|
<string name="preference_screen_clockwidget">Clock</string>
|
||||||
<string name="preference_screen_clockwidget_summary">Configure clock style and components</string>
|
<string name="preference_screen_clockwidget_summary">Configure clock style and components</string>
|
||||||
<string name="preference_clockwidget_layout">Layout</string>
|
<string name="preference_clockwidget_layout">Layout</string>
|
||||||
|
|||||||
@ -34,7 +34,6 @@ fun createFactorySettings(context: Context): Settings {
|
|||||||
.setMusicWidget(
|
.setMusicWidget(
|
||||||
Settings.MusicWidgetSettings
|
Settings.MusicWidgetSettings
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.setFilterSources(true)
|
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.setCalendarWidget(
|
.setCalendarWidget(
|
||||||
|
|||||||
@ -97,7 +97,9 @@ message Settings {
|
|||||||
WeatherSettings weather = 5;
|
WeatherSettings weather = 5;
|
||||||
|
|
||||||
message MusicWidgetSettings {
|
message MusicWidgetSettings {
|
||||||
bool filter_sources = 1;
|
reserved 1;
|
||||||
|
repeated string allow_list = 2;
|
||||||
|
repeated string deny_list = 3;
|
||||||
}
|
}
|
||||||
MusicWidgetSettings music_widget = 6;
|
MusicWidgetSettings music_widget = 6;
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import android.app.PendingIntent
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.MediaMetadata
|
import android.media.MediaMetadata
|
||||||
import android.media.Rating
|
|
||||||
import android.media.session.MediaController
|
import android.media.session.MediaController
|
||||||
import android.media.session.MediaSession
|
import android.media.session.MediaSession
|
||||||
import android.media.session.PlaybackState.CustomAction
|
import android.media.session.PlaybackState.CustomAction
|
||||||
@ -17,13 +17,11 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.service.notification.StatusBarNotification
|
import android.service.notification.StatusBarNotification
|
||||||
import android.util.Log
|
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import coil.imageLoader
|
import coil.imageLoader
|
||||||
import coil.request.ErrorResult
|
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.Scale
|
import coil.size.Scale
|
||||||
import de.mm20.launcher2.crashreporter.CrashReporter
|
import de.mm20.launcher2.crashreporter.CrashReporter
|
||||||
@ -75,6 +73,8 @@ interface MusicService {
|
|||||||
|
|
||||||
fun openPlayerChooser(context: Context)
|
fun openPlayerChooser(context: Context)
|
||||||
|
|
||||||
|
suspend fun getInstalledPlayerPackages(): Set<String>
|
||||||
|
|
||||||
fun resetPlayer()
|
fun resetPlayer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,13 +107,15 @@ internal class MusicServiceImpl(
|
|||||||
private val currentMediaController: SharedFlow<MediaController?> =
|
private val currentMediaController: SharedFlow<MediaController?> =
|
||||||
combine(
|
combine(
|
||||||
notificationRepository.notifications,
|
notificationRepository.notifications,
|
||||||
dataStore.data.map { it.musicWidget.filterSources }
|
dataStore.data.map { it.musicWidget }
|
||||||
) { notifications, filter ->
|
) { notifications, settings ->
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val musicApps = if (filter) getMusicApps() else null
|
val musicApps = getEnabledPlayerPackages(
|
||||||
|
settings.allowListList.toSet(),
|
||||||
|
settings.denyListList.toSet()
|
||||||
|
)
|
||||||
val sbn: StatusBarNotification? = notifications.filter {
|
val sbn: StatusBarNotification? = notifications.filter {
|
||||||
it.notification.extras.getParcelable(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token != null &&
|
it.notification.extras.getParcelable(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token != null && musicApps.contains(it.packageName)
|
||||||
(musicApps?.contains(it.packageName) != false)
|
|
||||||
}.maxByOrNull { it.postTime }
|
}.maxByOrNull { it.postTime }
|
||||||
|
|
||||||
return@withContext (sbn?.notification?.extras?.get(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token)
|
return@withContext (sbn?.notification?.extras?.get(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token)
|
||||||
@ -567,25 +569,27 @@ internal class MusicServiceImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMusicApps(): Set<String> {
|
override suspend fun getInstalledPlayerPackages(): Set<String> {
|
||||||
// List of known music apps that don't have the correct intent filter
|
val apps = mutableSetOf<String>()
|
||||||
val apps = mutableSetOf(
|
withContext(Dispatchers.IO) {
|
||||||
"com.aspiro.tidal", // Tidal
|
var intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) }
|
||||||
"com.bandcamp.android", // Bandcamp
|
apps.addAll(context.packageManager.queryIntentActivities(intent, 0)
|
||||||
"com.qobuz.music", // Qobuz
|
.map { it.activityInfo.applicationInfo.packageName })
|
||||||
"tv.plex.labs.plexamp", // Plexamp
|
intent = Intent("android.intent.action.MUSIC_PLAYER")
|
||||||
"de.ph1b.audiobook", // Voice
|
apps.addAll(context.packageManager.queryIntentActivities(intent, 0)
|
||||||
"de.eindm.boum", // Boum
|
.map { it.activityInfo.applicationInfo.packageName })
|
||||||
)
|
}
|
||||||
var intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) }
|
|
||||||
apps.addAll(context.packageManager.queryIntentActivities(intent, 0)
|
|
||||||
.map { it.activityInfo.packageName })
|
|
||||||
intent = Intent("android.intent.action.MUSIC_PLAYER")
|
|
||||||
apps.addAll(context.packageManager.queryIntentActivities(intent, 0)
|
|
||||||
.map { it.activityInfo.packageName })
|
|
||||||
return apps
|
return apps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun getEnabledPlayerPackages(
|
||||||
|
allowList: Set<String>,
|
||||||
|
denyList: Set<String>
|
||||||
|
): Set<String> {
|
||||||
|
val installed = getInstalledPlayerPackages()
|
||||||
|
return installed.union(allowList).subtract(denyList).toSet()
|
||||||
|
}
|
||||||
|
|
||||||
override fun resetPlayer() {
|
override fun resetPlayer() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user