Merge pull request #1775 from Bnyro/master

Notification bell to configurate notifications by certain channels
This commit is contained in:
Bnyro 2022-11-06 11:31:27 +01:00 committed by GitHub
commit 240915a6dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 296 additions and 213 deletions

View File

@ -52,7 +52,8 @@ class LibreTubeApp : Application() {
/**
* Initialize the notification listener in the background
*/
NotificationHelper(this).enqueueWork(
NotificationHelper.enqueueWork(
context = this,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP
)

View File

@ -93,6 +93,7 @@ object PreferenceKeys {
const val CHECKING_FREQUENCY = "checking_frequency"
const val REQUIRED_NETWORK = "required_network"
const val LAST_STREAM_VIDEO_ID = "last_stream_video_id"
const val IGNORED_NOTIFICATION_CHANNELS = "ignored_notification_channels"
/**
* Advanced

View File

@ -0,0 +1,18 @@
package com.github.libretube.extensions
import android.util.Log
import com.github.libretube.R
import com.github.libretube.util.PreferenceHelper
import com.google.android.material.button.MaterialButton
fun MaterialButton.setupNotificationBell(channelId: String) {
var isIgnorable = PreferenceHelper.isChannelNotificationIgnorable(channelId)
Log.e(channelId, isIgnorable.toString())
setIconResource(if (isIgnorable) R.drawable.ic_bell else R.drawable.ic_notification)
setOnClickListener {
isIgnorable = !isIgnorable
PreferenceHelper.toggleIgnorableNotificationChannel(channelId)
setIconResource(if (isIgnorable) R.drawable.ic_bell else R.drawable.ic_notification)
}
}

View File

@ -5,6 +5,7 @@ import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@ -71,8 +72,7 @@ class CommentsAdapter(
if (comment.hearted == true) heartedImageView.visibility = View.VISIBLE
if (comment.repliesPage != null) repliesAvailable.visibility = View.VISIBLE
if ((comment.replyCount ?: -1L) > 0L) {
repliesCount.text =
comment.replyCount?.formatShort()
repliesCount.text = comment.replyCount?.formatShort()
}
commentorImage.setOnClickListener {
@ -89,33 +89,7 @@ class CommentsAdapter(
repliesRecView.adapter = repliesAdapter
if (!isRepliesAdapter && comment.repliesPage != null) {
root.setOnClickListener {
when {
repliesAdapter.itemCount.equals(0) -> {
fetchReplies(comment.repliesPage) {
repliesAdapter.updateItems(it.comments)
if (repliesPage.nextpage == null) {
showMore.visibility = View.GONE
return@fetchReplies
}
showMore.visibility = View.VISIBLE
showMore.setOnClickListener {
if (repliesPage.nextpage == null) {
it.visibility = View.GONE
return@setOnClickListener
}
fetchReplies(
repliesPage.nextpage!!
) {
repliesAdapter.updateItems(repliesPage.comments)
}
}
}
}
else -> {
repliesAdapter.clear()
showMore.visibility = View.GONE
}
}
showMoreReplies(comment.repliesPage, showMore, repliesAdapter)
}
}
@ -127,6 +101,36 @@ class CommentsAdapter(
}
}
private fun showMoreReplies(nextPage: String, showMoreBtn: Button, repliesAdapter: CommentsAdapter) {
when {
repliesAdapter.itemCount.equals(0) -> {
fetchReplies(nextPage) {
repliesAdapter.updateItems(it.comments)
if (repliesPage.nextpage == null) {
showMoreBtn.visibility = View.GONE
return@fetchReplies
}
showMoreBtn.visibility = View.VISIBLE
showMoreBtn.setOnClickListener {
if (repliesPage.nextpage == null) {
it.visibility = View.GONE
return@setOnClickListener
}
fetchReplies(
repliesPage.nextpage!!
) {
repliesAdapter.updateItems(repliesPage.comments)
}
}
}
}
else -> {
repliesAdapter.clear()
showMoreBtn.visibility = View.GONE
}
}
}
override fun getItemCount(): Int {
return comments.size
}

View File

@ -1,18 +1,22 @@
package com.github.libretube.ui.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.R
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.api.obj.Subscription
import com.github.libretube.databinding.ChannelSubscriptionRowBinding
import com.github.libretube.extensions.setupNotificationBell
import com.github.libretube.extensions.toID
import com.github.libretube.ui.viewholders.SubscriptionChannelViewHolder
import com.github.libretube.util.ImageHelper
import com.github.libretube.util.NavigationHelper
class SubscriptionChannelAdapter(private val subscriptions: MutableList<com.github.libretube.api.obj.Subscription>) :
RecyclerView.Adapter<SubscriptionChannelViewHolder>() {
class SubscriptionChannelAdapter(
private val subscriptions: MutableList<Subscription>
) : RecyclerView.Adapter<SubscriptionChannelViewHolder>() {
override fun getItemCount(): Int {
return subscriptions.size
@ -27,25 +31,30 @@ class SubscriptionChannelAdapter(private val subscriptions: MutableList<com.gith
override fun onBindViewHolder(holder: SubscriptionChannelViewHolder, position: Int) {
val subscription = subscriptions[position]
var subscribed = true
var isSubscribed = true
holder.binding.apply {
subscriptionChannelName.text = subscription.name
ImageHelper.loadImage(subscription.avatar, subscriptionChannelImage)
subscription.url?.toID()?.let { notificationBell.setupNotificationBell(it) }
root.setOnClickListener {
NavigationHelper.navigateChannel(root.context, subscription.url)
}
subscriptionSubscribe.setOnClickListener {
val channelId = subscription.url!!.toID()
if (subscribed) {
if (isSubscribed) {
SubscriptionHelper.handleUnsubscribe(root.context, channelId, subscription.name ?: "") {
subscriptionSubscribe.text = root.context.getString(R.string.subscribe)
subscribed = false
notificationBell.visibility = View.GONE
isSubscribed = false
}
} else {
SubscriptionHelper.subscribe(channelId)
subscriptionSubscribe.text = root.context.getString(R.string.unsubscribe)
subscribed = true
notificationBell.visibility = View.VISIBLE
isSubscribed = true
}
}
}

View File

@ -17,6 +17,7 @@ import com.github.libretube.databinding.FragmentChannelBinding
import com.github.libretube.enums.ShareObjectType
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.formatShort
import com.github.libretube.extensions.setupNotificationBell
import com.github.libretube.extensions.toID
import com.github.libretube.obj.ShareData
import com.github.libretube.ui.adapters.SearchAdapter
@ -128,16 +129,21 @@ class ChannelFragment : BaseFragment() {
binding.channelSubscribe.text = getString(R.string.unsubscribe)
}
channelId?.let { binding.notificationBell.setupNotificationBell(it) }
if (isSubscribed == false) binding.notificationBell.visibility = View.GONE
binding.channelSubscribe.setOnClickListener {
if (isSubscribed == true) {
SubscriptionHelper.handleUnsubscribe(requireContext(), channelId!!, channelName) {
isSubscribed = false
binding.channelSubscribe.text = getString(R.string.subscribe)
binding.notificationBell.visibility = View.GONE
}
} else {
SubscriptionHelper.subscribe(channelId!!)
isSubscribed = true
binding.channelSubscribe.text = getString(R.string.unsubscribe)
binding.notificationBell.visibility = View.VISIBLE
}
}

View File

@ -45,8 +45,9 @@ class NotificationSettings : BasePreferenceFragment() {
private fun updateNotificationPrefs() {
// replace the previous queued work request
NotificationHelper(requireContext())
NotificationHelper
.enqueueWork(
context = requireContext(),
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE
)
}

View File

@ -30,11 +30,11 @@ class PlaybackSpeedSheet(
binding.speed.value = player.playbackParameters.speed
binding.pitch.value = player.playbackParameters.pitch
binding.speed.addOnChangeListener { _, value, _ ->
binding.speed.addOnChangeListener { _, _, _ ->
onChange()
}
binding.pitch.addOnChangeListener { _, value, _ ->
binding.pitch.addOnChangeListener { _, _, _ ->
onChange()
}

View File

@ -1,46 +1,21 @@
package com.github.libretube.util
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.constants.NOTIFICATION_WORK_NAME
import com.github.libretube.constants.PUSH_CHANNEL_ID
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.extensions.toID
import com.github.libretube.ui.activities.MainActivity
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import java.util.concurrent.TimeUnit
class NotificationHelper(
private val context: Context
) {
val NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// the id where notification channels start
private var notificationId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
NotificationManager.activeNotifications.size + 5
} else {
5
}
object NotificationHelper {
/**
* Enqueue the work manager task
*/
fun enqueueWork(
context: Context,
existingPeriodicWorkPolicy: ExistingPeriodicWorkPolicy
) {
// get the notification preferences
@ -95,115 +70,4 @@ class NotificationHelper(
.cancelUniqueWork(NOTIFICATION_WORK_NAME)
}
}
/**
* check whether new streams are available in subscriptions
*/
fun checkForNewStreams(): Boolean {
var success = true
val token = PreferenceHelper.getToken()
runBlocking {
val task = async {
if (token != "") {
RetrofitInstance.authApi.getFeed(token)
} else {
RetrofitInstance.authApi.getUnauthenticatedFeed(
SubscriptionHelper.getFormattedLocalSubscriptions()
)
}
}
// fetch the users feed
val videoFeed = try {
task.await()
} catch (e: Exception) {
success = false
return@runBlocking
}
val lastSeenStreamId = PreferenceHelper.getLastSeenVideoId()
val latestFeedStreamId = videoFeed[0].url!!.toID()
// first time notifications enabled or no new video available
if (lastSeenStreamId == "" || lastSeenStreamId == latestFeedStreamId) {
PreferenceHelper.setLatestVideoId(lastSeenStreamId)
return@runBlocking
}
// filter the new videos out
val lastSeenStreamItem = videoFeed.filter { it.url!!.toID() == lastSeenStreamId }
// previous video not found
if (lastSeenStreamItem.isEmpty()) return@runBlocking
val lastStreamIndex = videoFeed.indexOf(lastSeenStreamItem[0])
val newVideos = videoFeed.filterIndexed { index, _ ->
index < lastStreamIndex
}
// group the new streams by the uploader
val channelGroups = newVideos.groupBy { it.uploaderUrl }
// create a notification for each new stream
channelGroups.forEach { (_, streams) ->
createNotification(
group = streams[0].uploaderUrl!!.toID(),
title = streams[0].uploaderName.toString(),
isSummary = true
)
streams.forEach { streamItem ->
notificationId += 1
createNotification(
title = streamItem.title.toString(),
description = streamItem.uploaderName.toString(),
group = streamItem.uploaderUrl!!.toID()
)
}
}
// save the latest streams that got notified about
PreferenceHelper.setLatestVideoId(videoFeed[0].url!!.toID())
}
// return whether the work succeeded
return success
}
/**
* Notification that is created when new streams are found
*/
private fun createNotification(
title: String,
group: String,
description: String? = null,
isSummary: Boolean = false
) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
val builder = NotificationCompat.Builder(context, PUSH_CHANNEL_ID)
.setContentTitle(title)
.setGroup(group)
.setSmallIcon(R.drawable.ic_notification)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
// Set the intent that will fire when the user taps the notification
.setContentIntent(pendingIntent)
.setAutoCancel(true)
if (isSummary) {
builder.setGroupSummary(true)
} else {
builder.setContentText(description)
}
with(NotificationManagerCompat.from(context)) {
// notificationId is a unique int for each notification that you must define
notify(notificationId, builder.build())
}
}
}

View File

@ -1,8 +1,22 @@
package com.github.libretube.util
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.constants.PUSH_CHANNEL_ID
import com.github.libretube.extensions.toID
import com.github.libretube.ui.activities.MainActivity
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
/**
* The notification worker which checks for new streams in a certain frequency
@ -10,11 +24,139 @@ import androidx.work.WorkerParameters
class NotificationWorker(appContext: Context, parameters: WorkerParameters) :
Worker(appContext, parameters) {
private val notificationManager =
appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// the id where notification channels start
private var notificationId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
notificationManager.activeNotifications.size + 5
} else {
5
}
override fun doWork(): Result {
// check whether there are new streams and notify if there are some
val result = NotificationHelper(applicationContext)
.checkForNewStreams()
val result = checkForNewStreams()
// return success if the API request succeeded
return if (result) Result.success() else Result.retry()
}
/**
* check whether new streams are available in subscriptions
*/
private fun checkForNewStreams(): Boolean {
var success = true
val token = PreferenceHelper.getToken()
runBlocking {
val task = async {
if (token != "") {
RetrofitInstance.authApi.getFeed(token)
} else {
RetrofitInstance.authApi.getUnauthenticatedFeed(
SubscriptionHelper.getFormattedLocalSubscriptions()
)
}
}
// fetch the users feed
val videoFeed = try {
task.await()
} catch (e: Exception) {
success = false
return@runBlocking
}
val lastSeenStreamId = PreferenceHelper.getLastSeenVideoId()
val latestFeedStreamId = videoFeed[0].url!!.toID()
// first time notifications enabled or no new video available
if (lastSeenStreamId == "" || lastSeenStreamId == latestFeedStreamId) {
PreferenceHelper.setLatestVideoId(lastSeenStreamId)
return@runBlocking
}
// filter the new videos out
val lastSeenStreamItem = videoFeed.filter { it.url!!.toID() == lastSeenStreamId }
// previous video not found
if (lastSeenStreamItem.isEmpty()) return@runBlocking
val lastStreamIndex = videoFeed.indexOf(lastSeenStreamItem[0])
val newVideos = videoFeed.filterIndexed { index, _ ->
index < lastStreamIndex
}
// hide for notifications unsubscribed channels
val channelsToIgnore = PreferenceHelper.getIgnorableNotificationChannels()
val filteredVideos = newVideos.filter {
channelsToIgnore.none { channelId ->
channelId == it.uploaderUrl?.toID()
}
}
// group the new streams by the uploader
val channelGroups = filteredVideos.groupBy { it.uploaderUrl }
// create a notification for each new stream
channelGroups.forEach { (_, streams) ->
createNotification(
group = streams[0].uploaderUrl!!.toID(),
title = streams[0].uploaderName.toString(),
isSummary = true
)
streams.forEach { streamItem ->
notificationId += 1
createNotification(
title = streamItem.title.toString(),
description = streamItem.uploaderName.toString(),
group = streamItem.uploaderUrl!!.toID()
)
}
}
// save the latest streams that got notified about
PreferenceHelper.setLatestVideoId(videoFeed[0].url!!.toID())
}
// return whether the work succeeded
return success
}
/**
* Notification that is created when new streams are found
*/
private fun createNotification(
title: String,
group: String,
description: String? = null,
isSummary: Boolean = false
) {
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(
applicationContext,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
val builder = NotificationCompat.Builder(applicationContext, PUSH_CHANNEL_ID)
.setContentTitle(title)
.setGroup(group)
.setSmallIcon(R.drawable.ic_notification)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
// Set the intent that will fire when the user taps the notification
.setContentIntent(pendingIntent)
.setAutoCancel(true)
if (isSummary) {
builder.setGroupSummary(true)
} else {
builder.setContentText(description)
}
with(NotificationManagerCompat.from(applicationContext)) {
// notificationId is a unique int for each notification that you must define
notify(notificationId, builder.build())
}
}
}

View File

@ -85,6 +85,23 @@ object PreferenceHelper {
return getString(PreferenceKeys.ERROR_LOG, "")
}
fun getIgnorableNotificationChannels(): List<String> {
return getString(PreferenceKeys.IGNORED_NOTIFICATION_CHANNELS, "").split(",")
}
fun isChannelNotificationIgnorable(channelId: String): Boolean {
return getIgnorableNotificationChannels().any { it == channelId }
}
fun toggleIgnorableNotificationChannel(channelId: String) {
val ignorableChannels = getIgnorableNotificationChannels().toMutableList()
if (ignorableChannels.contains(channelId)) ignorableChannels.remove(channelId) else ignorableChannels.add(channelId)
editor.putString(
PreferenceKeys.IGNORED_NOTIFICATION_CHANNELS,
ignorableChannels.joinToString(",")
).apply()
}
private fun getDefaultSharedPreferences(context: Context): SharedPreferences {
return PreferenceManager.getDefaultSharedPreferences(context)
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#FF000000"
android:pathData="M8,38v-3h4.2L12.2,19.7q0,-4.2 2.475,-7.475Q17.15,8.95 21.2,8.1L21.2,6.65q0,-1.15 0.825,-1.9T24,4q1.15,0 1.975,0.75 0.825,0.75 0.825,1.9L26.8,8.1q4.05,0.85 6.55,4.125t2.5,7.475L35.85,35L40,35v3ZM24,23.25ZM24,44q-1.6,0 -2.8,-1.175Q20,41.65 20,40h8q0,1.65 -1.175,2.825Q25.65,44 24,44ZM15.2,35h17.65L32.85,19.7q0,-3.7 -2.55,-6.3 -2.55,-2.6 -6.25,-2.6t-6.275,2.6Q15.2,16 15.2,19.7Z" />
</vector>

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
@ -12,32 +12,38 @@
android:id="@+id/subscription_channel_image"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_centerVertical="true"
android:layout_gravity="center"
android:layout_marginStart="8dp" />
<TextView
android:id="@+id/subscription_channel_name"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="10dp"
android:layout_toEndOf="@id/subscription_channel_image"
android:layout_gravity="center"
android:layout_marginHorizontal="10dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
tools:text="Channel Name" />
<com.google.android.material.button.MaterialButton
android:id="@+id/notification_bell"
style="@style/ElevatedIconButton"
android:layout_gravity="center"
app:icon="@drawable/ic_notification"
tools:targetApi="m" />
<com.google.android.material.button.MaterialButton
android:id="@+id/subscription_subscribe"
style="@style/Widget.Material3.Button.ElevatedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_gravity="center"
android:layout_marginEnd="8dp"
android:stateListAnimator="@null"
android:text="@string/unsubscribe"
android:textColor="?android:attr/textColorPrimary"
android:textSize="12sp"
app:cornerRadius="20dp" />
</RelativeLayout>
</LinearLayout>

View File

@ -45,30 +45,25 @@
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
<TextView
android:id="@+id/channel_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="3.5dp">
<TextView
android:id="@+id/channel_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:drawablePadding="3dip"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
android:textStyle="bold"
tools:text="Channel Name" />
</LinearLayout>
android:layout_gravity="start"
android:layout_marginTop="3.5dp"
android:drawablePadding="3dp"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
android:textStyle="bold"
tools:text="Channel Name" />
<TextView
android:id="@+id/channel_subs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:maxLines="1"
android:text="@string/app_name"
android:textSize="12sp" />
@ -76,18 +71,16 @@
<com.google.android.material.button.MaterialButton
android:id="@+id/channel_share"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="5dp"
android:drawableTint="?android:attr/textColorPrimary"
android:paddingHorizontal="15dp"
android:stateListAnimator="@null"
app:cornerRadius="20dp"
app:elevation="20dp"
style="@style/ElevatedIconButton"
app:icon="@drawable/ic_share"
tools:targetApi="m" />
<com.google.android.material.button.MaterialButton
android:id="@+id/notification_bell"
style="@style/ElevatedIconButton"
app:icon="@drawable/ic_notification"
tools:targetApi="m" />
<com.google.android.material.button.MaterialButton
android:id="@+id/channel_subscribe"
style="@style/Widget.Material3.Button.ElevatedButton"

View File

@ -195,4 +195,15 @@
</style>
<style name="ElevatedIconButton" parent="@style/Widget.Material3.Button.IconButton.Filled.Tonal">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:paddingStart">15dp</item>
<item name="android:paddingEnd">15dp</item>
<item name="android:stateListAnimator">@null</item>
<item name="cardCornerRadius">25dp</item>
<item name="android:drawableTint" tools:targetApi="m">?android:attr/textColorPrimary</item>
</style>
</resources>