Skip to content

Commit cac815c

Browse files
authored
Merge pull request #2196 from maxrave-dev/dev
v1.5.0: New version
2 parents c4c4ddb + b3576c5 commit cac815c

30 files changed

Lines changed: 1562 additions & 651 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
}

composeApp/build.gradle.kts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,13 @@ fun downloadIfMissing(url: String, target: java.io.File) {
279279
val curlExit = ProcessBuilder(
280280
"curl",
281281
"-fsSL",
282-
"--retry", "3",
283-
"--retry-delay", "2",
282+
// `--retry` alone does NOT retry curl exit 56 (mid-transfer receive failure) — it only
283+
// retries HTTP 5xx/408/429 and connection errors. The get.videolan.org mirrors flake with
284+
// exit 56 mid-download, so `--retry-all-errors` is required to retry those too. Count/delay
285+
// bumped a bit for the occasionally-slow mirror.
286+
"--retry", "5",
287+
"--retry-delay", "5",
288+
"--retry-all-errors",
284289
"-o", target.absolutePath,
285290
url,
286291
).inheritIO().start().waitFor()

composeApp/proguard-desktop-rules.pro

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,13 @@
341341
# Skiko. Class is gone (can't be kept/added), so suppress the unresolved-reference warning.
342342
-dontwarn io.github.alexzhirkevich.compottie.**
343343

344+
# com.kyant.backdrop (liquid glass) was compiled against Skiko 0.144.x and references
345+
# RuntimeShaderBuilder.makeShader$default, whose signature changed in Skiko 0.148.2 (pulled by
346+
# compose-bom 2026.06 / coil3 3.5.0 / compottie 2.2.4). The method is gone, so ProGuard can't
347+
# resolve it and aborts. Liquid glass is not rendered on desktop, so the code path is never hit —
348+
# suppress the unresolved-reference warning. (Same approach as compottie/haze above.)
349+
-dontwarn com.kyant.backdrop.**
350+
344351
# JNA references the signature-polymorphic java.lang.invoke.MethodHandle.invoke(...) overloads, which
345352
# ProGuard can't resolve as concrete methods. JNA itself is kept above; suppress these warnings.
346353
-dontwarn com.sun.jna.**

composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/expect/ui/MediaPlayerView.android.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ import com.maxrave.simpmusic.ui.theme.typo
1919
actual fun MediaPlayerView(
2020
url: String,
2121
modifier: Modifier,
22+
cropToBounds: Boolean,
2223
) {
2324
MediaPlayerView(
2425
modifier = modifier,
2526
context = LocalContext.current,
2627
density = LocalDensity.current,
2728
url = url,
2829
screenSize = getScreenSizeInfo(),
30+
cropToBounds = cropToBounds,
2931
)
3032
}
3133

composeApp/src/androidMain/kotlin/com/maxrave/simpmusic/ui/component/LiquidGlassTabBar.android.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,9 @@ fun LiquidGlassTabBar(
151151
.drop(1)
152152
.collectLatest { index ->
153153
dampedDrag.animateToValue(index.toFloat())
154-
onTabSelected(index)
154+
// Taps already navigate directly from onClick; only drag-snap navigates here
155+
// (prevents a double onTabSelected and avoids the dropped-tap race).
156+
if (draggedFlag[0]) onTabSelected(index)
155157
}
156158
}
157159

@@ -248,7 +250,11 @@ fun LiquidGlassTabBar(
248250
// so call onTabSelected directly to keep the reload / scroll-to-top behaviour.
249251
onTabSelected(position)
250252
} else {
253+
// Navigate immediately on tap. Don't route this through snapshotFlow: a
254+
// concurrent drag-stop on the same Row can reset currentIndex back before the
255+
// flow emits, which silently drops the tap (observed: currentIndex stuck at 0).
251256
currentIndex = position
257+
onTabSelected(position)
252258
}
253259
}
254260
}
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/di/ViewModelModule.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ val viewModelModule =
9292
ArtistViewModel(
9393
get(),
9494
get(),
95+
get(),
9596
)
9697
}
9798
viewModel {

composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/expect/ui/MediaPlayerView.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import com.maxrave.domain.data.model.streams.TimeLine
1010
expect fun MediaPlayerView(
1111
url: String,
1212
modifier: Modifier,
13+
// When true, the video center scales-to-cover the given frame (ContentScale.Crop)
14+
// and clips the overflow. Default false keeps the legacy fit-height-by-screen
15+
// behavior used by NowPlaying / Fullscreen.
16+
cropToBounds: Boolean = false,
1317
)
1418

1519
@Composable

0 commit comments

Comments
 (0)