feat: implement cross-platform features and UI integration
- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker, BookmarkViewModel, FeedViewModel - iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker services - Linux: Add settings-store.vala, State.vala signals, view widgets (FeedList, FeedDetail, AddFeed, Search, Settings, Bookmark) - Linux: Add bookmark-store.vala, bookmark vala model, search-service.vala - Android: Add NotificationService, NotificationManager, NotificationPreferencesStore - Android: Add BookmarkDao, BookmarkRepository, SettingsStore - Add unit tests for iOS, Android, Linux - Add integration tests - Add performance benchmarks - Update tasks and documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.rssuper.database.RssDatabase
|
||||
import com.rssuper.database.entities.NotificationPreferencesEntity
|
||||
import com.rssuper.database.entities.toEntity
|
||||
import com.rssuper.models.NotificationPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class NotificationManager(private val context: Context) {
|
||||
|
||||
private val notificationService: NotificationService = NotificationService(context)
|
||||
private val database: RssDatabase = RssDatabase.getDatabase(context)
|
||||
|
||||
private var unreadCount: Int = 0
|
||||
|
||||
suspend fun initialize() {
|
||||
val preferences = notificationService.getPreferences()
|
||||
if (!preferences.badgeCount) {
|
||||
clearBadge()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
type: NotificationType = NotificationType.NEW_ARTICLE
|
||||
) {
|
||||
val preferences = notificationService.getPreferences()
|
||||
|
||||
if (!shouldShowNotification(type, preferences)) {
|
||||
return
|
||||
}
|
||||
|
||||
val shouldAddBadge = preferences.badgeCount && type != NotificationType.LOW_PRIORITY
|
||||
|
||||
if (shouldAddBadge) {
|
||||
incrementBadgeCount()
|
||||
}
|
||||
|
||||
val priority = when (type) {
|
||||
NotificationType.NEW_ARTICLE -> NotificationCompat.PRIORITY_DEFAULT
|
||||
NotificationType.PODCAST_EPISODE -> NotificationCompat.PRIORITY_HIGH
|
||||
NotificationType.LOW_PRIORITY -> NotificationCompat.PRIORITY_LOW
|
||||
NotificationType.CRITICAL -> NotificationCompat.PRIORITY_MAX
|
||||
}
|
||||
|
||||
notificationService.showNotification(title, body, priority)
|
||||
}
|
||||
|
||||
suspend fun showLocalNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
delayMillis: Long = 0
|
||||
) {
|
||||
notificationService.showLocalNotification(title, body, delayMillis)
|
||||
}
|
||||
|
||||
suspend fun showPushNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
data: Map<String, String> = emptyMap()
|
||||
) {
|
||||
notificationService.showPushNotification(title, body, data)
|
||||
}
|
||||
|
||||
suspend fun incrementBadgeCount() {
|
||||
unreadCount++
|
||||
updateBadge()
|
||||
}
|
||||
|
||||
suspend fun clearBadge() {
|
||||
unreadCount = 0
|
||||
updateBadge()
|
||||
}
|
||||
|
||||
suspend fun getBadgeCount(): Int {
|
||||
return unreadCount
|
||||
}
|
||||
|
||||
private suspend fun updateBadge() {
|
||||
notificationService.updateBadgeCount(unreadCount)
|
||||
}
|
||||
|
||||
private suspend fun shouldShowNotification(type: NotificationType, preferences: NotificationPreferences): Boolean {
|
||||
return when (type) {
|
||||
NotificationType.NEW_ARTICLE -> preferences.newArticles
|
||||
NotificationType.PODCAST_EPISODE -> preferences.episodeReleases
|
||||
NotificationType.LOW_PRIORITY, NotificationType.CRITICAL -> true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPreferences(preferences: NotificationPreferences) {
|
||||
notificationService.savePreferences(preferences)
|
||||
}
|
||||
|
||||
suspend fun getPreferences(): NotificationPreferences {
|
||||
return notificationService.getPreferences()
|
||||
}
|
||||
|
||||
fun hasPermission(): Boolean {
|
||||
return notificationService.hasNotificationPermission()
|
||||
}
|
||||
|
||||
fun requestPermission() {
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
// Request permission from UI
|
||||
// This should be called from an Activity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class NotificationType {
|
||||
NEW_ARTICLE,
|
||||
PODCAST_EPISODE,
|
||||
LOW_PRIORITY,
|
||||
CRITICAL
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import android.content.Context
|
||||
import com.rssuper.database.RssDatabase
|
||||
import com.rssuper.database.entities.NotificationPreferencesEntity
|
||||
import com.rssuper.database.entities.toEntity
|
||||
import com.rssuper.models.NotificationPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class NotificationPreferencesStore(private val context: Context) {
|
||||
|
||||
private val database: RssDatabase = RssDatabase.getDatabase(context)
|
||||
|
||||
suspend fun getPreferences(): NotificationPreferences {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val entity = database.notificationPreferencesDao().getSync("default")
|
||||
entity?.toModel() ?: NotificationPreferences()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun savePreferences(preferences: NotificationPreferences) {
|
||||
withContext(Dispatchers.IO) {
|
||||
database.notificationPreferencesDao().insert(preferences.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePreference(
|
||||
newArticles: Boolean? = null,
|
||||
episodeReleases: Boolean? = null,
|
||||
customAlerts: Boolean? = null,
|
||||
badgeCount: Boolean? = null,
|
||||
sound: Boolean? = null,
|
||||
vibration: Boolean? = null
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val current = database.notificationPreferencesDao().getSync("default")
|
||||
val preferences = current?.toModel() ?: NotificationPreferences()
|
||||
|
||||
val updated = preferences.copy(
|
||||
newArticles = newArticles ?: preferences.newArticles,
|
||||
episodeReleases = episodeReleases ?: preferences.episodeReleases,
|
||||
customAlerts = customAlerts ?: preferences.customAlerts,
|
||||
badgeCount = badgeCount ?: preferences.badgeCount,
|
||||
sound = sound ?: preferences.sound,
|
||||
vibration = vibration ?: preferences.vibration
|
||||
)
|
||||
|
||||
database.notificationPreferencesDao().insert(updated.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isNotificationEnabled(type: NotificationType): Boolean {
|
||||
val preferences = getPreferences()
|
||||
return when (type) {
|
||||
NotificationType.NEW_ARTICLE -> preferences.newArticles
|
||||
NotificationType.PODCAST_EPISODE -> preferences.episodeReleases
|
||||
NotificationType.LOW_PRIORITY, NotificationType.CRITICAL -> true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isSoundEnabled(): Boolean {
|
||||
return getPreferences().sound
|
||||
}
|
||||
|
||||
suspend fun isVibrationEnabled(): Boolean {
|
||||
return getPreferences().vibration
|
||||
}
|
||||
|
||||
suspend fun isBadgeEnabled(): Boolean {
|
||||
return getPreferences().badgeCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.rssuper.services
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
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 com.rssuper.R
|
||||
import com.rssuper.database.RssDatabase
|
||||
import com.rssuper.database.entities.NotificationPreferencesEntity
|
||||
import com.rssuper.database.entities.toEntity
|
||||
import com.rssuper.models.NotificationPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
|
||||
const val NOTIFICATION_CHANNEL_ID = "rssuper_notifications"
|
||||
const val NOTIFICATION_CHANNEL_NAME = "RSSuper Notifications"
|
||||
|
||||
class NotificationService(private val context: Context) {
|
||||
|
||||
private val database: RssDatabase = RssDatabase.getDatabase(context)
|
||||
private var notificationManager: NotificationManager? = null
|
||||
|
||||
init {
|
||||
notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
NOTIFICATION_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Notifications for new articles and episode releases"
|
||||
enableVibration(true)
|
||||
enableLights(true)
|
||||
}
|
||||
|
||||
notificationManager?.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPreferences(): NotificationPreferences {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val entity = database.notificationPreferencesDao().getSync("default")
|
||||
entity?.toModel() ?: NotificationPreferences()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun savePreferences(preferences: NotificationPreferences) {
|
||||
withContext(Dispatchers.IO) {
|
||||
database.notificationPreferencesDao().insert(preferences.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
fun showNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
priority: NotificationCompat.Priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
): Boolean {
|
||||
if (!hasNotificationPermission()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val notification = createNotification(title, body, priority)
|
||||
val notificationId = generateNotificationId()
|
||||
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
fun showLocalNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
delayMillis: Long = 0
|
||||
): Boolean {
|
||||
if (!hasNotificationPermission()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val notification = createNotification(title, body)
|
||||
val notificationId = generateNotificationId()
|
||||
|
||||
if (delayMillis > 0) {
|
||||
// For delayed notifications, we would use AlarmManager or WorkManager
|
||||
// This is a simplified version that shows immediately
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
} else {
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun showPushNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
data: Map<String, String> = emptyMap()
|
||||
): Boolean {
|
||||
if (!hasNotificationPermission()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val notification = createNotification(title, body)
|
||||
val notificationId = generateNotificationId()
|
||||
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
fun showNotificationWithAction(
|
||||
title: String,
|
||||
body: String,
|
||||
actionLabel: String,
|
||||
actionIntent: PendingIntent
|
||||
): Boolean {
|
||||
if (!hasNotificationPermission()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.addAction(android.R.drawable.ic_menu_share, actionLabel, actionIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
val notificationId = generateNotificationId()
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
fun updateBadgeCount(count: Int) {
|
||||
// On Android, badge count is handled by the system based on notifications
|
||||
// For launcher icons that support badges, we can use NotificationManagerCompat
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// Android 8.0+ handles badge counts automatically
|
||||
// No explicit action needed
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAllNotifications() {
|
||||
notificationManager?.cancelAll()
|
||||
}
|
||||
|
||||
fun hasNotificationPermission(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
return context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun createNotification(
|
||||
title: String,
|
||||
body: String,
|
||||
priority: Int = NotificationCompat.PRIORITY_DEFAULT
|
||||
): Notification {
|
||||
return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setPriority(priority)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun generateNotificationId(): Int {
|
||||
return UUID.randomUUID().hashCode()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user