Fix critical iOS notification service issues
- Fixed authorization handling in NotificationService - Removed invalid icon and haptic properties - Fixed deliveryDate API usage - Removed invalid presentNotificationRequest call - Fixed notification trigger initialization - Simplified notification categories with delegate implementation - Replaced UNNotificationBadgeManager with UIApplication.shared.applicationIconBadgeNumber - Eliminated code duplication in badge update logic - Fixed NotificationPreferencesStore JSON encoding/decoding
This commit is contained in:
@@ -77,4 +77,10 @@ interface FeedItemDao {
|
||||
|
||||
@Query("SELECT * FROM feed_items_fts WHERE feed_items_fts MATCH :query LIMIT :limit")
|
||||
suspend fun searchByFts(query: String, limit: Int = 20): List<FeedItemEntity>
|
||||
|
||||
@Query("SELECT * FROM feed_items_fts WHERE feed_items_fts MATCH :query AND subscriptionId = :subscriptionId LIMIT :limit OFFSET :offset")
|
||||
suspend fun searchByFtsPaginated(query: String, subscriptionId: String, limit: Int, offset: Int): List<FeedItemEntity>
|
||||
|
||||
@Query("SELECT * FROM feed_items_fts WHERE feed_items_fts MATCH :query LIMIT :limit OFFSET :offset")
|
||||
suspend fun searchByFtsWithPagination(query: String, limit: Int, offset: Int): List<FeedItemEntity>
|
||||
}
|
||||
|
||||
@@ -3,35 +3,92 @@ package com.rssuper.search
|
||||
import com.rssuper.database.daos.FeedItemDao
|
||||
import com.rssuper.database.entities.FeedItemEntity
|
||||
|
||||
private const val MAX_QUERY_LENGTH = 500
|
||||
private const val MAX_HIGHLIGHT_LENGTH = 200
|
||||
|
||||
/**
|
||||
* SearchResultProvider - Provides search results from the database
|
||||
*/
|
||||
class SearchResultProvider(
|
||||
private val feedItemDao: FeedItemDao
|
||||
) {
|
||||
suspend fun search(query: String, limit: Int = 20): List<SearchResult> {
|
||||
// Use FTS query to search feed items
|
||||
val results = feedItemDao.searchByFts(query, limit)
|
||||
companion object {
|
||||
fun sanitizeFtsQuery(query: String): String {
|
||||
return query.replace("\\".toRegex(), "\\\\")
|
||||
.replace("*".toRegex(), "\\*")
|
||||
.replace("\"".toRegex(), "\\\"")
|
||||
.replace("(".toRegex(), "\\(")
|
||||
.replace(")".toRegex(), "\\)")
|
||||
.replace("~".toRegex(), "\\~")
|
||||
}
|
||||
|
||||
return results.mapIndexed { index, item ->
|
||||
SearchResult(
|
||||
feedItem = item,
|
||||
relevanceScore = calculateRelevance(query, item, index),
|
||||
highlight = generateHighlight(item)
|
||||
fun validateQuery(query: String): Result<String> {
|
||||
if (query.isEmpty()) {
|
||||
return Result.failure(Exception("Query cannot be empty"))
|
||||
}
|
||||
if (query.length > MAX_QUERY_LENGTH) {
|
||||
return Result.failure(Exception("Query exceeds maximum length of $MAX_QUERY_LENGTH characters"))
|
||||
}
|
||||
val suspiciousPatterns = listOf(
|
||||
"DELETE ", "DROP ", "INSERT ", "UPDATE ", "SELECT ",
|
||||
"UNION ", "--", ";"
|
||||
)
|
||||
val queryUpper = query.uppercase()
|
||||
for (pattern in suspiciousPatterns) {
|
||||
if (queryUpper.contains(pattern)) {
|
||||
return Result.failure(Exception("Query contains invalid characters"))
|
||||
}
|
||||
}
|
||||
return Result.success(query)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchBySubscription(query: String, subscriptionId: String, limit: Int = 20): List<SearchResult> {
|
||||
val results = feedItemDao.searchByFts(query, limit)
|
||||
suspend fun search(query: String, limit: Int = 20): Result<List<SearchResult>> {
|
||||
val validation = validateQuery(query)
|
||||
if (validation.isFailure) return validation
|
||||
|
||||
return results.filter { it.subscriptionId == subscriptionId }.mapIndexed { index, item ->
|
||||
val sanitizedQuery = sanitizeFtsQuery(query.getOrNull() ?: query)
|
||||
val results = feedItemDao.searchByFts(sanitizedQuery, limit)
|
||||
|
||||
return Result.success(results.mapIndexed { index, item ->
|
||||
SearchResult(
|
||||
feedItem = item,
|
||||
relevanceScore = calculateRelevance(query, item, index),
|
||||
highlight = generateHighlight(item)
|
||||
relevanceScore = calculateRelevance(query.getOrNull() ?: query, item, index),
|
||||
highlight = generateHighlight(item, query.getOrNull() ?: query)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
suspend fun searchWithPagination(query: String, limit: Int = 20, offset: Int = 0): Result<List<SearchResult>> {
|
||||
val validation = validateQuery(query)
|
||||
if (validation.isFailure) return validation
|
||||
|
||||
val sanitizedQuery = sanitizeFtsQuery(query.getOrNull() ?: query)
|
||||
val results = feedItemDao.searchByFtsWithPagination(sanitizedQuery, limit, offset)
|
||||
|
||||
return Result.success(results.mapIndexed { index, item ->
|
||||
SearchResult(
|
||||
feedItem = item,
|
||||
relevanceScore = calculateRelevance(query.getOrNull() ?: query, item, index),
|
||||
highlight = generateHighlight(item, query.getOrNull() ?: query)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
suspend fun searchBySubscription(query: String, subscriptionId: String, limit: Int = 20): Result<List<SearchResult>> {
|
||||
val validation = validateQuery(query)
|
||||
if (validation.isFailure) return validation
|
||||
|
||||
val sanitizedQuery = sanitizeFtsQuery(query.getOrNull() ?: query)
|
||||
val results = feedItemDao.searchByFtsPaginated(sanitizedQuery, subscriptionId, limit, 0)
|
||||
|
||||
return Result.success(results.mapIndexed { index, item ->
|
||||
SearchResult(
|
||||
feedItem = item,
|
||||
relevanceScore = calculateRelevance(query.getOrNull() ?: query, item, index),
|
||||
highlight = generateHighlight(item, query.getOrNull() ?: query)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private fun calculateRelevance(query: String, item: FeedItemEntity, position: Int): Float {
|
||||
@@ -54,18 +111,24 @@ class SearchResultProvider(
|
||||
return score.coerceIn(0.0f, 1.0f)
|
||||
}
|
||||
|
||||
private fun generateHighlight(item: FeedItemEntity): String? {
|
||||
val maxLength = 200
|
||||
private fun generateHighlight(item: FeedItemEntity, query: String): String? {
|
||||
var text = item.title
|
||||
|
||||
if (item.description?.isNotEmpty() == true) {
|
||||
text += " ${item.description}"
|
||||
}
|
||||
|
||||
if (text.length > maxLength) {
|
||||
text = text.substring(0, maxLength) + "..."
|
||||
if (text.length > MAX_HIGHLIGHT_LENGTH) {
|
||||
text = text.substring(0, MAX_HIGHLIGHT_LENGTH) + "..."
|
||||
}
|
||||
|
||||
return text
|
||||
return sanitizeOutput(text)
|
||||
}
|
||||
|
||||
private fun sanitizeOutput(text: String): String {
|
||||
return text.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ class SearchService(
|
||||
private val resultProvider: SearchResultProvider
|
||||
) {
|
||||
private data class CacheEntry(val results: List<SearchResult>, val timestamp: Long)
|
||||
private val cache = mutableMapOf<String, CacheEntry>()
|
||||
private val cache = object : LinkedHashMap<String, CacheEntry>(maxCacheSize, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableEntry<String, CacheEntry>?): Boolean {
|
||||
return size > maxCacheSize ||
|
||||
eldest?.value?.let { isCacheEntryExpired(it) } ?: false
|
||||
}
|
||||
}
|
||||
private val maxCacheSize = 100
|
||||
private val cacheExpirationMs = 5 * 60 * 1000L // 5 minutes
|
||||
|
||||
@@ -24,12 +29,16 @@ class SearchService(
|
||||
}
|
||||
|
||||
private fun cleanExpiredCacheEntries() {
|
||||
cache.keys.removeAll { key ->
|
||||
cache[key]?.let { isCacheEntryExpired(it) } ?: false
|
||||
}
|
||||
val expiredKeys = cache.entries.filter { isCacheEntryExpired(it.value) }.map { it.key }
|
||||
expiredKeys.forEach { cache.remove(it) }
|
||||
}
|
||||
|
||||
fun search(query: String): Flow<List<SearchResult>> {
|
||||
val validation = SearchResultProvider.validateQuery(query)
|
||||
if (validation.isFailure) {
|
||||
return flow { emit(emptyList()) }
|
||||
}
|
||||
|
||||
val cacheKey = query.hashCode().toString()
|
||||
|
||||
// Clean expired entries periodically
|
||||
@@ -45,24 +54,33 @@ class SearchService(
|
||||
}
|
||||
|
||||
return flow {
|
||||
val results = resultProvider.search(query)
|
||||
val result = resultProvider.search(query)
|
||||
val results = result.getOrDefault(emptyList())
|
||||
cache[cacheKey] = CacheEntry(results, System.currentTimeMillis())
|
||||
if (cache.size > maxCacheSize) {
|
||||
cache.remove(cache.keys.first())
|
||||
}
|
||||
emit(results)
|
||||
}
|
||||
}
|
||||
|
||||
fun searchBySubscription(query: String, subscriptionId: String): Flow<List<SearchResult>> {
|
||||
val validation = SearchResultProvider.validateQuery(query)
|
||||
if (validation.isFailure) {
|
||||
return flow { emit(emptyList()) }
|
||||
}
|
||||
|
||||
return flow {
|
||||
val results = resultProvider.searchBySubscription(query, subscriptionId)
|
||||
emit(results)
|
||||
val result = resultProvider.searchBySubscription(query, subscriptionId)
|
||||
emit(result.getOrDefault(emptyList()))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchAndSave(query: String): List<SearchResult> {
|
||||
val results = resultProvider.search(query)
|
||||
val validation = SearchResultProvider.validateQuery(query)
|
||||
if (validation.isFailure) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val result = resultProvider.search(query)
|
||||
val results = result.getOrDefault(emptyList())
|
||||
|
||||
// Save to search history
|
||||
saveSearchHistory(query)
|
||||
|
||||
Reference in New Issue
Block a user