Skip to content

Commit 95b4df1

Browse files
authored
chore: Merge branch dev to main (#600)
2 parents 968f73c + 0b63aff commit 95b4df1

55 files changed

Lines changed: 359 additions & 165 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/main/java/app/morphe/manager/domain/bundles/RemotePatchBundle.kt

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import app.morphe.manager.network.service.HttpService
88
import app.morphe.manager.network.utils.getOrThrow
99
import app.morphe.manager.util.ChangelogEntry
1010
import app.morphe.manager.util.compareVersions
11+
import app.morphe.manager.util.releasePageUrl
1112
import io.ktor.client.request.header
1213
import io.ktor.client.request.prepareGet
1314
import io.ktor.client.request.url
@@ -361,14 +362,7 @@ class JsonPatchBundle(
361362
if (asset.pageUrl == null) {
362363
val repoUrl = inferPageUrlFromEndpoint(endpoint)
363364
val inferredPageUrl = if (repoUrl != null && asset.version.isNotBlank()) {
364-
// Normalize version to ensure it starts with 'v'
365-
val normalizedVersion = if (asset.version.startsWith("v")) asset.version else "v${asset.version}"
366-
// Create proper release page URL:
367-
// GitHub: https://github.com/owner/repo/releases/tag/v1.0.0-dev.1
368-
// GitLab: https://gitlab.com/owner/repo/-/releases/v1.0.0-dev.1
369-
val isGitLab = endpoint.contains("gitlab.com", ignoreCase = true)
370-
if (isGitLab) "$repoUrl/-/releases/$normalizedVersion"
371-
else "$repoUrl/releases/tag/$normalizedVersion"
365+
releasePageUrl(repoUrl, asset.version)
372366
} else {
373367
// Fallback to repository URL if version is missing
374368
repoUrl
@@ -385,7 +379,7 @@ class JsonPatchBundle(
385379
val activeEndpoint = resolveBranchUrl(endpoint)
386380
val changelogUrl = api.changelogUrlFromBundleEndpoint(activeEndpoint) ?: return emptyList()
387381
return fetchAndCacheEntries("$uid|$changelogUrl", sinceVersion) {
388-
api.fetchChangelogFromUrl(changelogUrl)
382+
api.fetchChangelogFromUrl(changelogUrl, stopAfterFirstStable = usePrerelease)
389383
}
390384
}
391385

@@ -467,7 +461,9 @@ class APIPatchBundle(
467461

468462
override suspend fun fetchChangelogEntries(sinceVersion: String?): List<ChangelogEntry> {
469463
val branch = if (usePrerelease) BRANCH_DEV else BRANCH_STABLE
470-
return fetchAndCacheEntries("$uid|$branch", sinceVersion) { api.fetchPatchesChangelog(branch) }
464+
return fetchAndCacheEntries("$uid|$branch", sinceVersion) {
465+
api.fetchPatchesChangelog(branch, stopAfterFirstStable = usePrerelease)
466+
}
471467
}
472468

473469
override fun copy(

app/src/main/java/app/morphe/manager/network/api/MorpheAPI.kt

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import app.morphe.manager.network.utils.getOrNull
1111
import app.morphe.manager.util.*
1212
import io.ktor.client.request.header
1313
import io.ktor.client.request.url
14+
import kotlinx.coroutines.Dispatchers
15+
import kotlinx.coroutines.withContext
1416
import kotlinx.datetime.LocalDateTime
1517
import kotlinx.datetime.TimeZone
1618
import kotlinx.datetime.toLocalDateTime
@@ -156,7 +158,7 @@ class MorpheAPI(
156158
downloadUrl = asset.downloadUrl,
157159
createdAt = parseTimestamp(timestamp),
158160
signatureDownloadUrl = findSignatureUrl(release, asset),
159-
pageUrl = "${config.htmlUrl}/releases/tag/${release.tagName}",
161+
pageUrl = releasePageUrl(config.htmlUrl, release.tagName),
160162
description = release.body?.ifBlank { release.name.orEmpty() } ?: release.name.orEmpty(),
161163
version = release.tagName
162164
)
@@ -171,7 +173,7 @@ class MorpheAPI(
171173
downloadUrl = releaseInfo.downloadUrl,
172174
createdAt = parseTimestamp(releaseInfo.createdAt),
173175
signatureDownloadUrl = releaseInfo.signatureDownloadUrl,
174-
pageUrl = "${config.htmlUrl}/releases/tag/$version",
176+
pageUrl = releasePageUrl(config.htmlUrl, version),
175177
description = releaseInfo.description,
176178
version = version
177179
)
@@ -187,7 +189,7 @@ class MorpheAPI(
187189
createdAt = parseTimestamp(releaseInfo.createdAt),
188190
// Treat empty string the same as absent — some JSON files emit ""
189191
signatureDownloadUrl = releaseInfo.signatureDownloadUrl?.ifBlank { null },
190-
pageUrl = "${config.htmlUrl}/releases/tag/$version",
192+
pageUrl = releasePageUrl(config.htmlUrl, version),
191193
description = releaseInfo.description,
192194
version = version
193195
)
@@ -421,17 +423,19 @@ class MorpheAPI(
421423
}
422424

423425
/** Fetches and parses CHANGELOG.md from the first-party patches repository. */
424-
suspend fun fetchPatchesChangelog(branch: String = "main"): List<ChangelogEntry> =
425-
fetchChangelogFromRepo(patchesConfig, branch)
426+
suspend fun fetchPatchesChangelog(branch: String = "main", stopAfterFirstStable: Boolean = false): List<ChangelogEntry> =
427+
fetchChangelogFromRepo(patchesConfig, branch, stopAfterFirstStable = stopAfterFirstStable)
426428

427429
/**
428430
* Fetches and parses CHANGELOG.md from an arbitrary raw URL.
429431
* Used for third-party bundles that follow the Morphe changelog format.
430432
*/
431-
suspend fun fetchChangelogFromUrl(changelogUrl: String): List<ChangelogEntry> {
433+
suspend fun fetchChangelogFromUrl(changelogUrl: String, stopAfterFirstStable: Boolean = false): List<ChangelogEntry> {
432434
Log.d(tag, "fetchChangelogFromUrl: $changelogUrl")
433435
return when (val r = client.request<String> { url(changelogUrl) }) {
434-
is APIResponse.Success -> ChangelogParser.parse(r.data)
436+
is APIResponse.Success -> withContext(Dispatchers.Default) {
437+
ChangelogParser.parse(r.data, stopAfterFirstStable)
438+
}
435439
is APIResponse.Error, is APIResponse.Failure -> {
436440
Log.w(tag, "Failed to fetch changelog from $changelogUrl")
437441
emptyList()
@@ -442,15 +446,18 @@ class MorpheAPI(
442446
private suspend fun fetchChangelogFromRepo(
443447
config: RepoConfig,
444448
branch: String,
445-
path: String = "CHANGELOG.md"
449+
path: String = "CHANGELOG.md",
450+
stopAfterFirstStable: Boolean = false
446451
): List<ChangelogEntry> {
447452
val url = config.rawFileUrl(branch, path)
448453
Log.d(tag, "fetchChangelog: $url")
449454
return when (val r = client.request<String> {
450455
url(url)
451456
header("Cache-Control", "no-cache")
452457
}) {
453-
is APIResponse.Success -> ChangelogParser.parse(r.data)
458+
is APIResponse.Success -> withContext(Dispatchers.Default) {
459+
ChangelogParser.parse(r.data, stopAfterFirstStable)
460+
}
454461
is APIResponse.Error, is APIResponse.Failure -> {
455462
Log.w(tag, "Failed to fetch $path for ${config.name}@$branch")
456463
emptyList()

app/src/main/java/app/morphe/manager/ui/screen/home/SourceManagementDialogs.kt

Lines changed: 124 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import android.annotation.SuppressLint
99
import android.graphics.Color.argb
1010
import android.graphics.Color.colorToHSV
1111
import android.net.Uri
12-
import androidx.compose.animation.*
12+
import androidx.compose.animation.AnimatedContent
13+
import androidx.compose.animation.AnimatedVisibility
14+
import androidx.compose.animation.Crossfade
15+
import androidx.compose.animation.ExperimentalAnimationApi
1316
import androidx.compose.animation.core.animateFloatAsState
1417
import androidx.compose.animation.core.spring
1518
import androidx.compose.animation.core.tween
@@ -18,6 +21,7 @@ import androidx.compose.foundation.clickable
1821
import androidx.compose.foundation.layout.*
1922
import androidx.compose.foundation.lazy.LazyColumn
2023
import androidx.compose.foundation.lazy.items
24+
import androidx.compose.foundation.lazy.itemsIndexed
2125
import androidx.compose.foundation.shape.CircleShape
2226
import androidx.compose.foundation.shape.RoundedCornerShape
2327
import androidx.compose.foundation.text.KeyboardActions
@@ -54,12 +58,16 @@ import app.morphe.manager.domain.repository.PatchBundleRepository
5458
import app.morphe.manager.patcher.patch.PatchInfo
5559
import app.morphe.manager.ui.screen.shared.*
5660
import app.morphe.manager.util.*
61+
import com.mikepenz.markdown.model.parseMarkdownFlow
5762
import compose.icons.FontAwesomeIcons
5863
import compose.icons.fontawesomeicons.Brands
5964
import compose.icons.fontawesomeicons.brands.Github
6065
import compose.icons.fontawesomeicons.brands.Gitlab
66+
import kotlinx.coroutines.*
67+
import kotlinx.coroutines.flow.first
6168
import kotlinx.coroutines.flow.mapNotNull
6269
import org.koin.compose.koinInject
70+
import com.mikepenz.markdown.model.State as MarkdownRenderState
6371

6472
private val ColorValid = Color(0xFF4CAF50)
6573

@@ -1142,67 +1150,80 @@ fun BundleChangelogDialog(
11421150
onDismissRequest: () -> Unit
11431151
) {
11441152
var state: BundleChangelogState by remember { mutableStateOf(BundleChangelogState.Loading) }
1153+
// 0 = waiting for dialog enter; incremented to trigger fetch, again on retry
1154+
var fetchTrigger by remember { mutableIntStateOf(0) }
11451155

1146-
LaunchedEffect(Unit) {
1156+
LaunchedEffect(fetchTrigger) {
1157+
if (fetchTrigger == 0) return@LaunchedEffect
11471158
state = BundleChangelogState.Loading
1148-
state = try {
1149-
val usePrerelease = (src as? APIPatchBundle)?.usePrerelease == true
1150-
|| (src as? JsonPatchBundle)?.usePrerelease == true
1151-
1152-
val allEntries = src.fetchChangelogEntries(sinceVersion = null)
1153-
1154-
val entries = if (usePrerelease) {
1155-
// Prerelease: from the last stable release onwards
1156-
val lastStable = allEntries.firstOrNull { !it.version.contains("-") }
1157-
if (lastStable != null)
1158-
ChangelogParser.entriesNewerThan(allEntries, lastStable.version) + lastStable
1159-
else allEntries
1160-
} else {
1161-
// Stable: from the installed version onwards
1162-
val installed = src.installedVersionSignature
1163-
val installedEntry = installed?.let {
1164-
ChangelogParser.findVersion(allEntries, it)
1159+
state = withContext(Dispatchers.Default) {
1160+
try {
1161+
val usePrerelease = (src as? APIPatchBundle)?.usePrerelease == true
1162+
|| (src as? JsonPatchBundle)?.usePrerelease == true
1163+
1164+
val allEntries = src.fetchChangelogEntries(sinceVersion = null)
1165+
1166+
val entries = if (usePrerelease) {
1167+
// Prerelease: from the last stable release onwards
1168+
val lastStable = allEntries.firstOrNull { !it.version.contains("-") }
1169+
if (lastStable != null)
1170+
ChangelogParser.entriesNewerThan(allEntries, lastStable.version) + lastStable
1171+
else allEntries.take(30)
1172+
} else {
1173+
// Stable: from the installed version onwards
1174+
val installed = src.installedVersionSignature
1175+
val installedEntry = installed?.let {
1176+
ChangelogParser.findVersion(allEntries, it)
1177+
}
1178+
val newer = if (installed != null)
1179+
ChangelogParser.entriesNewerThan(allEntries, installed)
1180+
else allEntries
1181+
if (installedEntry != null) newer + installedEntry else newer
11651182
}
1166-
val newer = if (installed != null)
1167-
ChangelogParser.entriesNewerThan(allEntries, installed)
1168-
else allEntries
1169-
if (installedEntry != null) newer + installedEntry else newer
1170-
}
11711183

1172-
// APIPatchBundle has endpoint="api" - use SOURCE_REPO_URL directly
1173-
val repoUrl = when (src) {
1174-
is APIPatchBundle -> SOURCE_REPO_URL
1175-
else -> RemotePatchBundle.inferPageUrlFromEndpoint(src.endpoint)
1176-
}
1177-
val latestPageUrl = entries.firstOrNull()?.version?.let { version ->
1178-
val tag = if (version.startsWith("v")) version else "v$version"
1179-
val url = repoUrl?.let { "$it/releases/tag/$tag" }
1180-
url
1181-
}
1184+
// APIPatchBundle has endpoint="api" - use SOURCE_REPO_URL directly
1185+
val repoUrl = when (src) {
1186+
is APIPatchBundle -> SOURCE_REPO_URL
1187+
else -> RemotePatchBundle.inferPageUrlFromEndpoint(src.endpoint)
1188+
}
1189+
val latestPageUrl = entries.firstOrNull()?.version?.let { version ->
1190+
repoUrl?.let { releasePageUrl(it, version) }
1191+
}
11821192

1183-
if (entries.isNotEmpty()) {
1184-
BundleChangelogState.Entries(entries, latestPageUrl = latestPageUrl)
1185-
} else {
1186-
// Fallback: CHANGELOG.md unavailable - use latest release info from API
1187-
val asset = src.fetchLatestReleaseInfo()
1188-
BundleChangelogState.Entries(
1189-
entries = listOf(
1193+
if (entries.isNotEmpty()) {
1194+
BundleChangelogState.Entries(
1195+
entries = entries,
1196+
parsedMarkdown = preParseEntries(entries),
1197+
latestPageUrl = latestPageUrl
1198+
)
1199+
} else {
1200+
// Fallback: CHANGELOG.md unavailable - use latest release info from API
1201+
val asset = src.fetchLatestReleaseInfo()
1202+
val fallbackEntries = listOf(
11901203
ChangelogEntry(
11911204
version = asset.version,
11921205
date = null,
11931206
content = asset.description.sanitizePatchChangelogMarkdown()
11941207
)
1195-
),
1196-
latestPageUrl = asset.pageUrl
1197-
)
1208+
)
1209+
BundleChangelogState.Entries(
1210+
entries = fallbackEntries,
1211+
parsedMarkdown = preParseEntries(fallbackEntries),
1212+
latestPageUrl = asset.pageUrl
1213+
)
1214+
}
1215+
} catch (t: Throwable) {
1216+
BundleChangelogState.Error(t)
11981217
}
1199-
} catch (t: Throwable) {
1200-
BundleChangelogState.Error(t)
12011218
}
12021219
}
12031220

12041221
MorpheDialog(
12051222
onDismissRequest = onDismissRequest,
1223+
// Start fetch only after the dialog enter animation completes so the shimmer
1224+
// is always visible first, even when data is cached and would resolve instantly
1225+
onEntered = { if (fetchTrigger == 0) fetchTrigger = 1 },
1226+
scrollable = false,
12061227
title = when (state) {
12071228
is BundleChangelogState.Entries -> null
12081229
is BundleChangelogState.Error -> stringResource(R.string.changelog)
@@ -1229,7 +1250,7 @@ fun BundleChangelogDialog(
12291250
MorpheDialogButtonColumn {
12301251
MorpheDialogButton(
12311252
text = stringResource(R.string.changelog_retry),
1232-
onClick = { state = BundleChangelogState.Loading },
1253+
onClick = { fetchTrigger++ },
12331254
modifier = Modifier.fillMaxWidth()
12341255
)
12351256
MorpheDialogButton(
@@ -1249,21 +1270,48 @@ fun BundleChangelogDialog(
12491270
}
12501271
}
12511272
) {
1252-
AnimatedContent(
1253-
targetState = state,
1254-
transitionSpec = MorpheAnimations.fadeCrossfade(),
1255-
contentKey = { it::class },
1256-
label = "changelog_content"
1257-
) { current ->
1258-
when (current) {
1259-
BundleChangelogState.Loading -> ChangelogSectionLoading()
1260-
is BundleChangelogState.Error -> BundleChangelogError(error = current.throwable)
1261-
is BundleChangelogState.Entries -> ChangelogEntriesList(
1262-
entries = current.entries,
1263-
headerIcon = Icons.Outlined.History,
1264-
emptyText = stringResource(R.string.changelog_empty),
1265-
textColor = LocalDialogTextColor.current
1266-
)
1273+
BundleChangelogContent(state)
1274+
}
1275+
}
1276+
1277+
@Composable
1278+
private fun BundleChangelogContent(state: BundleChangelogState) {
1279+
Crossfade(
1280+
targetState = state,
1281+
animationSpec = tween(MorpheDefaults.ANIMATION_DURATION),
1282+
modifier = Modifier.fillMaxWidth(),
1283+
label = "changelog_state"
1284+
) { current ->
1285+
when (current) {
1286+
BundleChangelogState.Loading -> ChangelogSectionLoading()
1287+
is BundleChangelogState.Error -> BundleChangelogError(error = current.throwable)
1288+
is BundleChangelogState.Entries -> {
1289+
if (current.entries.isEmpty()) {
1290+
Text(
1291+
text = stringResource(R.string.changelog_empty),
1292+
style = MaterialTheme.typography.bodyMedium,
1293+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1294+
modifier = Modifier.fillMaxWidth()
1295+
)
1296+
} else {
1297+
val textColor = LocalDialogTextColor.current
1298+
LazyColumn(modifier = Modifier.fillMaxWidth()) {
1299+
itemsIndexed(current.entries) { index, entry ->
1300+
if (index > 0) {
1301+
HorizontalDivider(
1302+
modifier = Modifier.padding(vertical = 20.dp),
1303+
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
1304+
)
1305+
}
1306+
ChangelogEntrySection(
1307+
entry = entry,
1308+
headerIcon = Icons.Outlined.History,
1309+
textColor = textColor,
1310+
precomputedMarkdown = current.parsedMarkdown.getOrNull(index)
1311+
)
1312+
}
1313+
}
1314+
}
12671315
}
12681316
}
12691317
}
@@ -1318,11 +1366,25 @@ private sealed interface BundleChangelogState {
13181366
/** [entries] are already filtered to "missed" versions, newest-first. */
13191367
data class Entries(
13201368
val entries: List<ChangelogEntry>,
1369+
val parsedMarkdown: List<MarkdownRenderState?>,
13211370
val latestPageUrl: String?
13221371
) : BundleChangelogState
13231372
data class Error(val throwable: Throwable) : BundleChangelogState
13241373
}
13251374

1375+
private suspend fun preParseEntries(entries: List<ChangelogEntry>): List<MarkdownRenderState?> =
1376+
coroutineScope {
1377+
entries.map { entry ->
1378+
async {
1379+
if (entry.content.isBlank()) null
1380+
else runCatching {
1381+
parseMarkdownFlow(entry.content.trimIndent())
1382+
.first { it !is MarkdownRenderState.Loading }
1383+
}.getOrNull()
1384+
}
1385+
}.awaitAll()
1386+
}
1387+
13261388
private val doubleBracketLinkRegex = Regex("""\[\[([^]]+)]\(([^)]+)\)]""")
13271389

13281390
private fun String.sanitizePatchChangelogMarkdown(): String =

0 commit comments

Comments
 (0)