Skip to content

Commit d228f3e

Browse files
committed
feat(notification): scan blog RSS feed daily and push local notifications
1 parent 419ad3c commit d228f3e

6 files changed

Lines changed: 324 additions & 2 deletions

File tree

androidApp/src/main/java/com/maxrave/simpmusic/MainActivity.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import com.maxrave.domain.mediaservice.handler.ToastType
3535
import com.maxrave.logger.Logger
3636
import com.maxrave.media3.di.setServiceActivitySession
3737
import com.maxrave.simpmusic.di.viewModelModule
38+
import com.maxrave.simpmusic.service.rss.RssFeedNotifyWork
3839
import com.maxrave.simpmusic.service.test.notification.NotifyWork
3940
import com.maxrave.simpmusic.utils.ComposeResUtils
4041
import com.maxrave.simpmusic.utils.VersionManager
@@ -199,6 +200,22 @@ class MainActivity : AppCompatActivity() {
199200
ExistingPeriodicWorkPolicy.KEEP,
200201
request,
201202
)
203+
val rssRequest =
204+
PeriodicWorkRequestBuilder<RssFeedNotifyWork>(
205+
24L,
206+
TimeUnit.HOURS,
207+
).addTag("Blog RSS Worker")
208+
.setConstraints(
209+
Constraints
210+
.Builder()
211+
.setRequiredNetworkType(NetworkType.CONNECTED)
212+
.build(),
213+
).build()
214+
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
215+
"Blog RSS Worker",
216+
ExistingPeriodicWorkPolicy.KEEP,
217+
rssRequest,
218+
)
202219

203220
if (!EasyPermissions.hasPermissions(this, Manifest.permission.POST_NOTIFICATIONS)) {
204221
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package com.maxrave.simpmusic.service.rss
2+
3+
import android.content.Context
4+
import android.util.Xml
5+
import androidx.work.CoroutineWorker
6+
import androidx.work.WorkerParameters
7+
import com.maxrave.domain.data.entities.NotificationEntity
8+
import com.maxrave.domain.extension.epochMillisToLocalDateTime
9+
import com.maxrave.domain.extension.now
10+
import com.maxrave.domain.repository.CommonRepository
11+
import com.maxrave.logger.Logger
12+
import com.maxrave.simpmusic.service.test.notification.NotificationHandler
13+
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.withContext
15+
import org.koin.core.component.KoinComponent
16+
import org.koin.core.component.inject
17+
import org.xmlpull.v1.XmlPullParser
18+
import java.net.HttpURLConnection
19+
import java.net.URL
20+
import java.text.SimpleDateFormat
21+
import java.util.Locale
22+
23+
/**
24+
* Periodically scans the personal blog RSS feed and pushes a local notification for each
25+
* post that is (a) published within the [WINDOW_MS] window relative to the run time AND
26+
* (b) not already stored in the notification DB.
27+
*
28+
* The DB is the source of truth for "already pushed" — see [CommonRepository.isNotificationExists].
29+
* The time window is intentionally wider than the scheduling interval so a delayed WorkManager
30+
* run (Doze, constraints) does not skip a post; the DB check still guarantees one push per post.
31+
*/
32+
class RssFeedNotifyWork(
33+
context: Context,
34+
params: WorkerParameters,
35+
) : CoroutineWorker(context, params),
36+
KoinComponent {
37+
private val commonRepository: CommonRepository by inject()
38+
39+
override suspend fun doWork(): Result =
40+
withContext(Dispatchers.IO) {
41+
try {
42+
Logger.w(TAG, "doWork: fetching $FEED_URL")
43+
val items = parseRss(fetchFeed(FEED_URL))
44+
val nowMillis = System.currentTimeMillis()
45+
46+
// Oldest first so notifications arrive in chronological order.
47+
items.sortedBy { it.pubMillis }.forEach { item ->
48+
val withinWindow = item.pubMillis > 0L && (nowMillis - item.pubMillis) <= WINDOW_MS
49+
if (withinWindow && !commonRepository.isNotificationExists(item.link)) {
50+
NotificationHandler.createBlogNotificationChannel(applicationContext)
51+
NotificationHandler.createBlogNotification(
52+
context = applicationContext,
53+
title = item.title,
54+
text = item.description,
55+
url = item.link,
56+
)
57+
commonRepository.insertNotification(
58+
NotificationEntity(
59+
channelId = "",
60+
name = item.title,
61+
type = NotificationEntity.TYPE_BLOG,
62+
link = item.link,
63+
description = item.description,
64+
time = if (item.pubMillis > 0L) epochMillisToLocalDateTime(item.pubMillis) else now(),
65+
),
66+
)
67+
Logger.w(TAG, "Pushed blog notification: ${item.title}")
68+
}
69+
}
70+
Result.success()
71+
} catch (e: Exception) {
72+
Logger.e(TAG, "doWork failed: ${e.message}")
73+
Result.retry()
74+
}
75+
}
76+
77+
private fun fetchFeed(urlStr: String): String {
78+
val connection =
79+
(URL(urlStr).openConnection() as HttpURLConnection).apply {
80+
connectTimeout = 15_000
81+
readTimeout = 15_000
82+
requestMethod = "GET"
83+
setRequestProperty("User-Agent", "SimpMusic")
84+
}
85+
return try {
86+
connection.inputStream.bufferedReader().use { it.readText() }
87+
} finally {
88+
connection.disconnect()
89+
}
90+
}
91+
92+
private fun parseRss(xml: String): List<RssItem> {
93+
val parser = Xml.newPullParser()
94+
parser.setInput(xml.reader())
95+
val items = mutableListOf<RssItem>()
96+
var insideItem = false
97+
var title = ""
98+
var link = ""
99+
var description = ""
100+
var pubDate = ""
101+
var event = parser.eventType
102+
while (event != XmlPullParser.END_DOCUMENT) {
103+
when (event) {
104+
XmlPullParser.START_TAG ->
105+
when (parser.name) {
106+
"item" -> {
107+
insideItem = true
108+
title = ""
109+
link = ""
110+
description = ""
111+
pubDate = ""
112+
}
113+
"title" -> if (insideItem) title = parser.nextText().trim()
114+
"link" -> if (insideItem) link = parser.nextText().trim()
115+
"description" -> if (insideItem) description = parser.nextText().trim()
116+
"pubDate" -> if (insideItem) pubDate = parser.nextText().trim()
117+
}
118+
119+
XmlPullParser.END_TAG ->
120+
if (parser.name == "item") {
121+
insideItem = false
122+
if (link.isNotEmpty()) {
123+
items.add(RssItem(title, link, description, parsePubMillis(pubDate)))
124+
}
125+
}
126+
}
127+
event = parser.next()
128+
}
129+
return items
130+
}
131+
132+
private fun parsePubMillis(pubDate: String): Long =
133+
try {
134+
// RFC-822, e.g. "Fri, 26 Jun 2026 19:05:06 GMT"
135+
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.ENGLISH).parse(pubDate)?.time ?: 0L
136+
} catch (e: Exception) {
137+
0L
138+
}
139+
140+
private data class RssItem(
141+
val title: String,
142+
val link: String,
143+
val description: String,
144+
val pubMillis: Long,
145+
)
146+
147+
companion object {
148+
private const val TAG = "RssFeedNotifyWork"
149+
const val FEED_URL = "https://www.maxrave.dev/rss.xml"
150+
151+
// 48h — wider than the 24h schedule so a delayed run still catches recent posts;
152+
// the DB dedup prevents any double push.
153+
private const val WINDOW_MS = 48L * 60L * 60L * 1000L
154+
}
155+
}

androidApp/src/main/java/com/maxrave/simpmusic/service/test/notification/NotificationHandler.kt

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,71 @@ object NotificationHandler {
122122
notificationManager.createNotificationChannel(channel)
123123
}
124124
}
125+
126+
private const val BLOG_CHANNEL_ID = "blog_updates_channel"
127+
128+
/**
129+
* Separate channel so users can silence blog updates without affecting artist-release
130+
* notifications.
131+
*/
132+
fun createBlogNotificationChannel(context: Context) {
133+
val notificationManager: NotificationManager =
134+
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
135+
if (notificationManager.getNotificationChannel(BLOG_CHANNEL_ID) == null) {
136+
val channel =
137+
NotificationChannel(
138+
BLOG_CHANNEL_ID,
139+
"Blog updates",
140+
NotificationManager.IMPORTANCE_DEFAULT,
141+
).apply {
142+
description = "Notifies when a new blog post is published"
143+
}
144+
notificationManager.createNotificationChannel(channel)
145+
}
146+
}
147+
148+
/**
149+
* Posts a local notification for a new blog post. Tapping opens [url] in the browser
150+
* (ACTION_VIEW). No image is loaded — the feed carries no per-item thumbnail.
151+
* The notification id is derived from [url] so the same post never stacks duplicates.
152+
*/
153+
fun createBlogNotification(
154+
context: Context,
155+
title: String,
156+
text: String?,
157+
url: String,
158+
) {
159+
val action =
160+
Intent(Intent.ACTION_VIEW, url.toUri()).apply {
161+
flags = Intent.FLAG_ACTIVITY_NEW_TASK
162+
}
163+
val pendingIntent =
164+
PendingIntent.getActivity(
165+
context,
166+
url.hashCode(),
167+
action,
168+
PendingIntent.FLAG_IMMUTABLE,
169+
)
170+
val builder =
171+
NotificationCompat
172+
.Builder(context, BLOG_CHANNEL_ID)
173+
.setSmallIcon(R.drawable.mono)
174+
.setContentTitle(title)
175+
.setContentText(text)
176+
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
177+
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
178+
.setContentIntent(pendingIntent)
179+
.setAutoCancel(true)
180+
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
181+
with(NotificationManagerCompat.from(context)) {
182+
if (ActivityCompat.checkSelfPermission(
183+
context,
184+
Manifest.permission.POST_NOTIFICATIONS,
185+
) != PackageManager.PERMISSION_GRANTED
186+
) {
187+
return
188+
}
189+
notify(url.hashCode(), builder.build())
190+
}
191+
}
125192
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:height="24dp"
3+
android:tint="#FFFFFF"
4+
android:viewportHeight="24"
5+
android:viewportWidth="24"
6+
android:width="24dp">
7+
<path
8+
android:fillColor="#ffffff"
9+
android:pathData="M6.18,15.64c-1.18,0 -2.13,0.96 -2.13,2.13 0,1.18 0.96,2.13 2.13,2.13s2.13,-0.96 2.13,-2.13c0,-1.18 -0.96,-2.13 -2.13,-2.13zM4,4.44L4,7.31c6.45,0 11.69,5.24 11.69,11.69h2.87C18.56,10.96 12.04,4.44 4,4.44zM4,9.11L4,11.98c3.86,0 7,3.14 7,7h2.87C13.87,13.62 9.42,9.11 4,9.11z" />
10+
</vector>

composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/NotificationScreen.kt

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package com.maxrave.simpmusic.ui.screen.home
22

33
import androidx.compose.animation.Crossfade
44
import androidx.compose.foundation.MarqueeAnimationMode
5+
import androidx.compose.foundation.background
56
import androidx.compose.foundation.basicMarquee
67
import androidx.compose.foundation.clickable
78
import androidx.compose.foundation.focusable
9+
import androidx.compose.foundation.layout.Arrangement
810
import androidx.compose.foundation.layout.Box
911
import androidx.compose.foundation.layout.Column
1012
import androidx.compose.foundation.layout.Row
@@ -22,6 +24,7 @@ import androidx.compose.foundation.lazy.items
2224
import androidx.compose.foundation.shape.CircleShape
2325
import androidx.compose.foundation.shape.RoundedCornerShape
2426
import androidx.compose.material3.ExperimentalMaterial3Api
27+
import androidx.compose.material3.Icon
2528
import androidx.compose.material3.Text
2629
import androidx.compose.material3.TopAppBar
2730
import androidx.compose.runtime.Composable
@@ -31,7 +34,9 @@ import androidx.compose.ui.Modifier
3134
import androidx.compose.ui.draw.clip
3235
import androidx.compose.ui.graphics.Color
3336
import androidx.compose.ui.layout.ContentScale
37+
import androidx.compose.ui.platform.LocalUriHandler
3438
import androidx.compose.ui.text.style.TextAlign
39+
import androidx.compose.ui.text.style.TextOverflow
3540
import androidx.compose.ui.unit.dp
3641
import androidx.lifecycle.compose.collectAsStateWithLifecycle
3742
import androidx.navigation.NavController
@@ -56,6 +61,7 @@ import simpmusic.composeapp.generated.resources.Res
5661
import simpmusic.composeapp.generated.resources.album
5762
import simpmusic.composeapp.generated.resources.baseline_arrow_back_ios_new_24
5863
import simpmusic.composeapp.generated.resources.holder
64+
import simpmusic.composeapp.generated.resources.ic_rss_feed_24
5965
import simpmusic.composeapp.generated.resources.new_release
6066
import simpmusic.composeapp.generated.resources.no_notification
6167
import simpmusic.composeapp.generated.resources.notification
@@ -90,7 +96,10 @@ fun NotificationScreen(
9096
CenterLoadingBox(modifier = Modifier.align(Alignment.Center))
9197
}
9298
} else if (it.isNotEmpty()) {
93-
LazyColumn(modifier = Modifier.padding(15.dp)) {
99+
LazyColumn(
100+
modifier = Modifier.padding(15.dp),
101+
verticalArrangement = Arrangement.spacedBy(16.dp),
102+
) {
94103
items(it) { notification ->
95104
NotificationItem(
96105
notification = notification,
@@ -122,6 +131,10 @@ fun NotificationItem(
122131
notification: NotificationEntity,
123132
navController: NavController,
124133
) {
134+
if (notification.type == NotificationEntity.TYPE_BLOG) {
135+
BlogNotificationItem(notification)
136+
return
137+
}
125138
Box(
126139
modifier =
127140
Modifier
@@ -202,6 +215,66 @@ fun NotificationItem(
202215
}
203216
}
204217

218+
@Composable
219+
fun BlogNotificationItem(notification: NotificationEntity) {
220+
val uriHandler = LocalUriHandler.current
221+
val link = notification.link
222+
Box(
223+
modifier =
224+
Modifier
225+
.padding(5.dp)
226+
.fillMaxWidth(),
227+
) {
228+
Row(
229+
Modifier
230+
.fillMaxWidth()
231+
.clickable(enabled = !link.isNullOrEmpty()) {
232+
link?.let { uriHandler.openUri(it) }
233+
},
234+
) {
235+
Box(
236+
modifier =
237+
Modifier
238+
.align(Alignment.Top)
239+
.size(50.dp)
240+
.clip(CircleShape)
241+
.background(Color.White.copy(alpha = 0.08f)),
242+
contentAlignment = Alignment.Center,
243+
) {
244+
Icon(
245+
painter = painterResource(Res.drawable.ic_rss_feed_24),
246+
contentDescription = null,
247+
tint = Color.White,
248+
modifier = Modifier.size(26.dp),
249+
)
250+
}
251+
Spacer(modifier = Modifier.width(12.dp))
252+
Column(Modifier.padding(end = 56.dp)) {
253+
Text(text = "New blog post", style = typo().titleSmall)
254+
Spacer(modifier = Modifier.height(3.dp))
255+
Text(text = notification.name, style = typo().titleMedium)
256+
notification.description?.takeIf { it.isNotBlank() }?.let { desc ->
257+
Spacer(modifier = Modifier.height(4.dp))
258+
Text(
259+
text = desc,
260+
style = typo().bodySmall,
261+
maxLines = 2,
262+
overflow = TextOverflow.Ellipsis,
263+
)
264+
}
265+
}
266+
}
267+
Text(
268+
text = notification.time.formatTimeAgo(),
269+
style = typo().titleSmall,
270+
modifier =
271+
Modifier
272+
.align(Alignment.TopEnd)
273+
.padding(end = 15.dp),
274+
)
275+
}
276+
}
277+
205278
@Composable
206279
fun ItemAlbumNotification(
207280
isAlbum: Boolean,

0 commit comments

Comments
 (0)