From e221b508d31ae628482bdf7d53547f07056f4c50 Mon Sep 17 00:00:00 2001 From: Kelvin Date: Tue, 21 Nov 2023 23:31:26 +0100 Subject: [PATCH] Improved notifications, experimental scheduled notifications --- app/src/main/AndroidManifest.xml | 3 + .../java/com/futo/platformplayer/Settings.kt | 24 +-- .../com/futo/platformplayer/SettingsDev.kt | 9 ++ .../futo/platformplayer/UISlideOverlays.kt | 103 ++++++------ .../background/BackgroundWorker.kt | 70 +++------ .../cache/ChannelContentCache.kt | 8 + .../receivers/PlannedNotificationReceiver.kt | 48 ++++++ .../states/StateNotifications.kt | 147 ++++++++++++++++++ .../stores/FragmentedStorage.kt | 2 +- app/src/main/res/values/strings.xml | 3 + 10 files changed, 313 insertions(+), 104 deletions(-) create mode 100644 app/src/main/java/com/futo/platformplayer/receivers/PlannedNotificationReceiver.kt create mode 100644 app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2fd04972..a659758a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + + (); - var menu: SlideUpMenuOverlay? = null; val originalNotif = subscription.doNotifications; val originalLive = subscription.doFetchLive; @@ -62,54 +62,69 @@ class UISlideOverlays { val originalVideo = subscription.doFetchVideos; val originalPosts = subscription.doFetchPosts; - items.addAll(listOf( - SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", { - subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; - }, false), - SlideUpMenuGroup(container.context, "Fetch Settings", - "Depending on the platform you might not need to enable a type for it to be available.", - -1, listOf()), - SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", { - subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive; - }, false), - SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for finished streams", "fetchStreams", { - subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchLive; - }, false), - SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", { - subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchLive; - }, false), - SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", { - subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchLive; - }, false))); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){ + val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url); + val capabilities = plugin.getChannelCapabilities(); - menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items); + withContext(Dispatchers.Main) { - if(subscription.doNotifications) - menu.selectOption(null, "notifications", true, true); - if(subscription.doFetchLive) - menu.selectOption(null, "fetchLive", true, true); - if(subscription.doFetchStreams) - menu.selectOption(null, "fetchStreams", true, true); - if(subscription.doFetchVideos) - menu.selectOption(null, "fetchVideos", true, true); - if(subscription.doFetchPosts) - menu.selectOption(null, "fetchPosts", true, true); + var menu: SlideUpMenuOverlay? = null; - menu.onOK.subscribe { - subscription.save(); - menu.hide(true); - }; - menu.onCancel.subscribe { - subscription.doNotifications = originalNotif; - subscription.doFetchLive = originalLive; - subscription.doFetchStreams = originalStream; - subscription.doFetchVideos = originalVideo; - subscription.doFetchPosts = originalPosts; - }; - menu.setOk("Save"); + items.addAll(listOf( + SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", { + subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; + }, false), + SlideUpMenuGroup(container.context, "Fetch Settings", + "Depending on the platform you might not need to enable a type for it to be available.", + -1, listOf()), + if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", { + subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive; + }, false) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", { + subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams; + }, false) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS)) + SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", { + subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; + }, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty()) + SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", { + subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos; + }, false) else null, + if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", { + subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts; + }, false) else null).filterNotNull()); - menu.show(); + menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items); + + if(subscription.doNotifications) + menu.selectOption(null, "notifications", true, true); + if(subscription.doFetchLive) + menu.selectOption(null, "fetchLive", true, true); + if(subscription.doFetchStreams) + menu.selectOption(null, "fetchStreams", true, true); + if(subscription.doFetchVideos) + menu.selectOption(null, "fetchVideos", true, true); + if(subscription.doFetchPosts) + menu.selectOption(null, "fetchPosts", true, true); + + menu.onOK.subscribe { + subscription.save(); + menu.hide(true); + }; + menu.onCancel.subscribe { + subscription.doNotifications = originalNotif; + subscription.doFetchLive = originalLive; + subscription.doFetchStreams = originalStream; + subscription.doFetchVideos = originalVideo; + subscription.doFetchPosts = originalPosts; + }; + + menu.setOk("Save"); + + menu.show(); + } + } } fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? { diff --git a/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt b/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt index e2566c40..fbebe2b2 100644 --- a/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt +++ b/app/src/main/java/com/futo/platformplayer/background/BackgroundWorker.kt @@ -6,33 +6,25 @@ import android.app.PendingIntent import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.Drawable -import android.media.MediaSession2Service.MediaNotification -import androidx.concurrent.futures.CallbackToFutureAdapter -import androidx.concurrent.futures.ResolvableFuture import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker -import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.Settings import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.video.IPlatformVideo -import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.getNowDiffSeconds import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.states.StateApp -import com.futo.platformplayer.states.StatePlatform +import com.futo.platformplayer.states.StateNotifications import com.futo.platformplayer.states.StateSubscriptions -import com.futo.platformplayer.views.adapters.viewholders.TabViewHolder -import com.google.common.util.concurrent.ListenableFuture +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNowDiffStringMinDay import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.time.OffsetDateTime @@ -54,8 +46,10 @@ class BackgroundWorker(private val appContext: Context, private val workerParams this.setSound(null, null); }; notificationManager.createNotificationChannel(notificationChannel); + val contentChannel = StateNotifications.instance.contentNotifChannel + notificationManager.createNotificationChannel(contentChannel); try { - doSubscriptionUpdating(notificationManager, notificationChannel); + doSubscriptionUpdating(notificationManager, notificationChannel, contentChannel); } catch(ex: Throwable) { exception = ex; @@ -77,13 +71,13 @@ class BackgroundWorker(private val appContext: Context, private val workerParams } - suspend fun doSubscriptionUpdating(manager: NotificationManager, notificationChannel: NotificationChannel) { - val notif = NotificationCompat.Builder(appContext, notificationChannel.id) + suspend fun doSubscriptionUpdating(manager: NotificationManager, backgroundChannel: NotificationChannel, contentChannel: NotificationChannel) { + val notif = NotificationCompat.Builder(appContext, backgroundChannel.id) .setSmallIcon(com.futo.platformplayer.R.drawable.foreground) .setContentTitle("Grayjay") .setContentText("Updating subscriptions...") .setSilent(true) - .setChannelId(notificationChannel.id) + .setChannelId(backgroundChannel.id) .setProgress(1, 0, true); manager.notify(12, notif.build()); @@ -94,6 +88,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams val newItems = mutableListOf(); val now = OffsetDateTime.now(); + val threeDays = now.minusDays(4); val contentNotifs = mutableListOf>(); withContext(Dispatchers.IO) { val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total -> @@ -111,8 +106,14 @@ class BackgroundWorker(private val appContext: Context, private val workerParams synchronized(newSubChanges) { if(!newSubChanges.contains(sub)) { newSubChanges.add(sub); - if(sub.doNotifications && content.datetime?.let { it < now } == true) - contentNotifs.add(Pair(sub, content)); + if(sub.doNotifications) { + if(content.datetime != null) { + if(content.datetime!! <= now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && content.datetime!! > threeDays) + contentNotifs.add(Pair(sub, content)); + else if(content.datetime!! > now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && Settings.instance.notifications.plannedContentNotification) + StateNotifications.instance.scheduleContentNotification(applicationContext, content); + } + } } newItems.add(content); } @@ -135,22 +136,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams val items = contentNotifs.take(5).toList() for(i in items.indices) { val contentNotif = items.get(i); - val thumbnail = if(contentNotif.second is IPlatformVideo) (contentNotif.second as IPlatformVideo).thumbnails.getHQThumbnail() - else null; - if(thumbnail != null) - Glide.with(appContext).asBitmap() - .load(thumbnail) - .into(object: CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, resource); - } - override fun onLoadCleared(placeholder: Drawable?) {} - override fun onLoadFailed(errorDrawable: Drawable?) { - notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null); - } - }) - else - notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null); + StateNotifications.instance.notifyNewContentWithThumbnail(appContext, manager, contentChannel, 13 + i, contentNotif.second); } } catch(ex: Throwable) { @@ -165,20 +151,4 @@ class BackgroundWorker(private val appContext: Context, private val workerParams .setSilent(true) .setChannelId(notificationChannel.id).build());*/ } - - fun notifyNewContent(manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, sub: Subscription, content: IPlatformContent, thumbnail: Bitmap? = null) { - val notifBuilder = NotificationCompat.Builder(appContext, notificationChannel.id) - .setSmallIcon(com.futo.platformplayer.R.drawable.foreground) - .setContentTitle("New by [${sub.channel.name}]") - .setContentText("${content.name}") - .setSilent(true) - .setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, content.url), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) - .setChannelId(notificationChannel.id); - if(thumbnail != null) { - //notifBuilder.setLargeIcon(thumbnail); - notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?)); - } - manager.notify(id, notifBuilder.build()); - } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt b/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt index 5a5faa6b..87614cc0 100644 --- a/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt +++ b/app/src/main/java/com/futo/platformplayer/cache/ChannelContentCache.kt @@ -58,6 +58,14 @@ class ChannelContentCache { uncacheContent(content); } } + fun clearToday() { + val yesterday = OffsetDateTime.now().minusDays(1); + synchronized(_channelContents) { + for(channel in _channelContents) + for(content in channel.value.getItems().filter { it.datetime?.isAfter(yesterday) == true }) + uncacheContent(content); + } + } fun getChannelCachePager(channelUrl: String): PlatformContentPager { val validID = channelUrl.toSafeFileName(); diff --git a/app/src/main/java/com/futo/platformplayer/receivers/PlannedNotificationReceiver.kt b/app/src/main/java/com/futo/platformplayer/receivers/PlannedNotificationReceiver.kt new file mode 100644 index 00000000..e1805c23 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/receivers/PlannedNotificationReceiver.kt @@ -0,0 +1,48 @@ +package com.futo.platformplayer.receivers + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.futo.platformplayer.Settings +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateNotifications + + +class PlannedNotificationReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + try { + Logger.i(TAG, "Planned Notification received"); + if(!Settings.instance.notifications.plannedContentNotification) + return; + if(StateApp.instance.contextOrNull == null) + StateApp.instance.initializeFiles(); + + val notifs = StateNotifications.instance.getScheduledNotifications(60 * 15, true); + if(!notifs.isEmpty() && context != null) { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; + val channel = StateNotifications.instance.contentNotifChannel; + notificationManager.createNotificationChannel(channel); + var i = 0; + for (notif in notifs) { + StateNotifications.instance.notifyNewContentWithThumbnail(context, notificationManager, channel, 110 + i, notif); + i++; + } + } + } + catch(ex: Throwable) { + Logger.e(TAG, "Failed PlannedNotificationReceiver.onReceive", ex); + } + } + + companion object { + private val TAG = "PlannedNotificationReceiver" + + fun getIntent(context: Context): PendingIntent { + return PendingIntent.getBroadcast(context, 110, Intent(context, PlannedNotificationReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt b/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt new file mode 100644 index 00000000..1ce15a20 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/states/StateNotifications.kt @@ -0,0 +1,147 @@ +package com.futo.platformplayer.states + +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.os.Build +import androidx.core.app.NotificationCompat +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.api.media.models.contents.IPlatformContent +import com.futo.platformplayer.api.media.models.video.IPlatformVideo +import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent +import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo +import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.receivers.PlannedNotificationReceiver +import com.futo.platformplayer.serializers.PlatformContentSerializer +import com.futo.platformplayer.stores.FragmentedStorage +import com.futo.platformplayer.toHumanNowDiffString +import com.futo.platformplayer.toHumanNowDiffStringMinDay +import java.time.OffsetDateTime + +class StateNotifications { + private val _alarmManagerLock = Object(); + private var _alarmManager: AlarmManager? = null; + val plannedWarningMinutesEarly: Long = 10; + + val contentNotifChannel = NotificationChannel("contentChannel", "Content Notifications", + NotificationManager.IMPORTANCE_HIGH).apply { + this.enableVibration(false); + this.setSound(null, null); + }; + + private val _plannedContent = FragmentedStorage.storeJson("planned_content_notifs", PlatformContentSerializer()) + .load(); + + private fun getAlarmManager(context: Context): AlarmManager { + synchronized(_alarmManagerLock) { + if(_alarmManager == null) + _alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager; + return _alarmManager!!; + } + } + + fun scheduleContentNotification(context: Context, content: IPlatformContent) { + try { + var existing = _plannedContent.findItem { it.url == content.url }; + if(existing != null) { + _plannedContent.delete(existing); + existing = null; + } + if(existing == null && content.datetime != null) { + val item = SerializedPlatformContent.fromContent(content); + _plannedContent.saveAsync(item); + + val manager = getAlarmManager(context); + val notifyDateTime = content.datetime!!.minusMinutes(plannedWarningMinutesEarly); + if(Build.VERSION.SDK_INT >= 31 && !manager.canScheduleExactAlarms()) { + Logger.i(TAG, "Scheduling in-exact notification for [${content.name}] at ${notifyDateTime.toHumanNowDiffString()}") + manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, notifyDateTime.toEpochSecond().times(1000), PlannedNotificationReceiver.getIntent(context)); + } + else { + Logger.i(TAG, "Scheduling exact notification for [${content.name}] at ${notifyDateTime.toHumanNowDiffString()}") + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, notifyDateTime.toEpochSecond().times(1000), PlannedNotificationReceiver.getIntent(context)) + } + } + } + catch(ex: Throwable) { + Logger.e(TAG, "scheduleContentNotification failed for [${content.name}]", ex); + } + } + fun removeChannelPlannedContent(channelUrl: String) { + val toDeletes = _plannedContent.findItems { it.author.url == channelUrl }; + for(toDelete in toDeletes) + _plannedContent.delete(toDelete); + } + + fun getScheduledNotifications(secondsFuture: Long, deleteReturned: Boolean = false): List { + val minDate = OffsetDateTime.now().plusSeconds(secondsFuture); + val toNotify = _plannedContent.findItems { it.datetime?.let { it.isBefore(minDate) } == true } + + if(deleteReturned) { + for(toDelete in toNotify) + _plannedContent.delete(toDelete); + } + return toNotify; + } + + fun notifyNewContentWithThumbnail(context: Context, manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, content: IPlatformContent) { + val thumbnail = if(content is IPlatformVideo) (content as IPlatformVideo).thumbnails.getHQThumbnail() + else null; + if(thumbnail != null) + Glide.with(context).asBitmap() + .load(thumbnail) + .into(object: CustomTarget() { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + notifyNewContent(context, manager, notificationChannel, id, content, resource); + } + override fun onLoadCleared(placeholder: Drawable?) {} + override fun onLoadFailed(errorDrawable: Drawable?) { + notifyNewContent(context, manager, notificationChannel, id, content, null); + } + }) + else + notifyNewContent(context, manager, notificationChannel, id, content, null); + } + + fun notifyNewContent(context: Context, manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, content: IPlatformContent, thumbnail: Bitmap? = null) { + val notifBuilder = NotificationCompat.Builder(context, notificationChannel.id) + .setSmallIcon(com.futo.platformplayer.R.drawable.foreground) + .setContentTitle("New by [${content.author.name}]") + .setContentText("${content.name}") + .setSubText(content.datetime?.toHumanNowDiffStringMinDay()) + .setSilent(true) + .setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.getVideoIntent(context, content.url), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + .setChannelId(notificationChannel.id); + if(thumbnail != null) { + //notifBuilder.setLargeIcon(thumbnail); + notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?)); + } + manager.notify(id, notifBuilder.build()); + } + + + companion object { + val TAG = "StateNotifications"; + private var _instance : StateNotifications? = null; + val instance : StateNotifications + get(){ + if(_instance == null) + _instance = StateNotifications(); + return _instance!!; + }; + + fun finish() { + _instance?.let { + _instance = null; + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt index a996a9c8..6426e948 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/FragmentedStorage.kt @@ -31,7 +31,7 @@ class FragmentedStorage { fun initialize(filesDir: File) { _filesDir = filesDir; } - + inline fun storeJson(name: String, serializer: KSerializer? = null): ManagedStore = store(name, JsonStoreSerializer.create(serializer), null, null); inline fun storeJson(parentDir: File, name: String, serializer: KSerializer? = null): ManagedStore = store(name, JsonStoreSerializer.create(serializer), null, parentDir); inline fun storeJson(name: String, prettyName: String? = null, parentDir: File? = null): ManagedStore = store(name, JsonStoreSerializer.create(), prettyName, parentDir); inline fun store(name: String, serializer: StoreSerializer, prettyName: String? = null, parentDir: File? = null): ManagedStore { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 069318e6..c6514a13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -267,6 +267,9 @@ A list of user-reported and self-reported issues Also removes any data related plugin like login or settings Announcement + Notifications + Planned Content Notifications + Schedules discovered planned content as notifications, resulting in more accurate notifications for this content. Attempt to utilize byte ranges Auto Update Auto-Rotate