Skip to content

Commit 33b276d

Browse files
committed
feat: Add GitLab bundle support
- JsonPatchBundle now handles gitlab.com URLs (resolveBranchUrl, extractBranch) - normalizeRemoteBundleUrl converts gitlab.com/owner/repo to raw URL - gitlabAvatarUrl via unavatar.io/gitlab/{owner} - deep link: ?gitlabs=owner/repo parameter in MainActivity - MorpheAPI.changelogUrlFromBundleEndpoint handles gitlab.com - strings.xml: mention GitLab in URL examples and error message
1 parent 49638d1 commit 33b276d

7 files changed

Lines changed: 156 additions & 38 deletions

File tree

app/src/main/java/app/morphe/manager/MainActivity.kt

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ class MainActivity : AppCompatActivity() {
119119
/**
120120
* Handles deep links for adding patch sources.
121121
* Format: https://morphe.software/add-source?github=owner/repo(&name=Display+Name)
122-
* Only GitHub URLs are accepted for safety.
122+
* https://morphe.software/add-source?gitlabs=owner/repo(&name=Display+Name)
123+
* Only GitHub and GitLab URLs are accepted for safety.
123124
*/
124125
private fun handleDeepLinkIntent(intent: Intent?, vm: MainViewModel) {
125126
val data = intent?.data ?: return
@@ -138,10 +139,21 @@ class MainActivity : AppCompatActivity() {
138139
data.path?.startsWith("/add-source") == true
139140
if (!isAddSource) return
140141

141-
val repo = data.getQueryParameter("github")?.takeIf { it.isNotBlank() } ?: return
142-
val url = "https://github.com/$repo"
143142
val name = data.getQueryParameter("name")?.takeIf { it.isNotBlank() }
144-
vm.pendingDeepLinkSource = MainViewModel.DeepLinkSource(url = url, name = name)
143+
144+
val githubRepo = data.getQueryParameter("github")?.takeIf { it.isNotBlank() }
145+
if (githubRepo != null) {
146+
val url = "https://github.com/$githubRepo"
147+
vm.pendingDeepLinkSource = MainViewModel.DeepLinkSource(url = url, name = name)
148+
return
149+
}
150+
151+
val gitlabRepo = data.getQueryParameter("gitlabs")?.takeIf { it.isNotBlank() }
152+
if (gitlabRepo != null) {
153+
val url = "https://gitlab.com/$gitlabRepo"
154+
vm.pendingDeepLinkSource = MainViewModel.DeepLinkSource(url = url, name = name)
155+
return
156+
}
145157
}
146158
}
147159

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ sealed class PatchBundleSource(
9797
}
9898

9999
/**
100-
* Get GitHub avatar URL if this bundle is from a GitHub repository
100+
* Get GitHub avatar URL if this bundle is from a GitHub repository.
101+
* Returns null for GitLab bundles (use [gitlabAvatarUrl] instead).
101102
*/
102103
val PatchBundleSource.githubAvatarUrl: String? get() {
103104
val remote = this as? RemotePatchBundle ?: return null
@@ -107,7 +108,17 @@ sealed class PatchBundleSource(
107108
}
108109

109110
/**
110-
* Extract GitHub owner/organization name from endpoint URL
111+
* Get GitLab avatar URL via unavatar.io if this bundle is from a GitLab repository.
112+
*/
113+
val PatchBundleSource.gitlabAvatarUrl: String? get() {
114+
val remote = this as? RemotePatchBundle ?: return null
115+
return extractGitLabOwner(remote.endpoint)?.let { owner ->
116+
"https://unavatar.io/gitlab/$owner"
117+
}
118+
}
119+
120+
/**
121+
* Extract GitHub owner/organization name from endpoint URL.
111122
*/
112123
private fun extractGitHubOwner(endpoint: String): String? {
113124
return try {
@@ -127,5 +138,23 @@ sealed class PatchBundleSource(
127138
null
128139
}
129140
}
141+
142+
/**
143+
* Extract GitLab owner/namespace from endpoint URL.
144+
* Supports:
145+
* - gitlab.com/owner/repo/-/raw/branch/file
146+
* - gitlab.com/owner/repo (short form)
147+
*/
148+
private fun extractGitLabOwner(endpoint: String): String? {
149+
return try {
150+
val uri = java.net.URI(endpoint)
151+
val host = uri.host?.lowercase(java.util.Locale.US) ?: return null
152+
if (host != "gitlab.com") return null
153+
val segments = uri.path?.trim('/')?.split('/')?.filter { it.isNotBlank() } ?: return null
154+
segments.firstOrNull()
155+
} catch (_: Exception) {
156+
null
157+
}
158+
}
130159
}
131160
}

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

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -191,20 +191,24 @@ sealed class RemotePatchBundle(
191191
internal val entriesCache = mutableMapOf<String, Pair<Long, List<ChangelogEntry>>>()
192192

193193
/**
194-
* Infer GitHub page URL from various endpoint formats
194+
* Infer GitHub page URL from various endpoint formats.
195195
*/
196196
fun inferPageUrlFromEndpoint(endpoint: String): String? {
197197
return try {
198198
val uri = java.net.URI(endpoint)
199199
val host = uri.host?.lowercase(java.util.Locale.US)
200+
val segments = uri.path?.trim('/')?.split('/')?.filter { it.isNotBlank() }
200201

201202
when (host) {
202203
"raw.githubusercontent.com", "github.com" -> {
203-
uri.path?.trim('/')?.split('/')
204-
?.filter { it.isNotBlank() }
205-
?.takeIf { it.size >= 2 }
204+
segments?.takeIf { it.size >= 2 }
206205
?.let { "https://github.com/${it[0]}/${it[1]}" }
207206
}
207+
"gitlab.com" -> {
208+
// gitlab.com/owner/repo/-/raw/branch/... or gitlab.com/owner/repo
209+
segments?.takeIf { it.size >= 2 }
210+
?.let { "https://gitlab.com/${it[0]}/${it[1]}" }
211+
}
208212
else -> null
209213
}
210214
} catch (_: Exception) {
@@ -245,16 +249,18 @@ class JsonPatchBundle(
245249
private val stableBranch: String get() = "main"
246250

247251
/**
248-
* Parse GitHub URL and convert to raw.githubusercontent.com format.
249-
* If [usePrerelease] is true, uses "dev" branch; otherwise uses "main".
252+
* Resolves the effective fetch URL, substituting the target branch when
253+
* [supportsPrerelease] is true.
254+
*
250255
* Only called when [supportsPrerelease] is true, i.e. the endpoint already
251256
* points to "main" or "dev" - so both branches are expected to exist.
257+
*
252258
* Supports:
253-
* - https://github.com/owner/repo/tree/branch/path/file.json
254-
* - https://github.com/owner/repo/blob/branch/path/file.json
255-
* - https://raw.githubusercontent.com/owner/repo/branch/path/file.json (passthrough)
259+
* - https://raw.githubusercontent.com/owner/repo/branch/path/file.json
260+
* - https://github.com/owner/repo/tree|blob/branch/path/file.json
261+
* - https://gitlab.com/owner/repo/-/raw/branch/path/file.json
256262
*/
257-
private fun parseGitHubUrl(url: String): String {
263+
private fun resolveBranchUrl(url: String): String {
258264
return try {
259265
val uri = java.net.URI(url)
260266
val host = uri.host?.lowercase(java.util.Locale.US)
@@ -272,19 +278,23 @@ class JsonPatchBundle(
272278
"github.com" -> {
273279
// Parse: /owner/repo/tree|blob/branch/path/to/file.json
274280
val pathParts = uri.path?.trim('/')?.split('/') ?: return url
275-
276281
if (pathParts.size < 5) return url // Need at least: owner, repo, tree/blob, branch, file
277-
278-
val owner = pathParts[0]
279-
val repo = pathParts[1]
280282
val type = pathParts[2] // "tree" or "blob"
281-
282283
if (type !in listOf("tree", "blob")) return url
283-
284+
val owner = pathParts[0]
285+
val repo = pathParts[1]
284286
val filePath = pathParts.drop(4).joinToString("/")
285-
286287
"https://raw.githubusercontent.com/$owner/$repo/$targetBranch/$filePath"
287288
}
289+
"gitlab.com" -> {
290+
// Format: owner/repo/-/raw/BRANCH/path/file.json
291+
val parts = uri.path.trim('/').split('/').toMutableList()
292+
val rawIndex = parts.indexOf("raw")
293+
if (rawIndex >= 0 && parts.getOrNull(rawIndex - 1) == "-") {
294+
parts[rawIndex + 1] = targetBranch
295+
"https://gitlab.com/${parts.joinToString("/")}"
296+
} else url
297+
}
288298
else -> url // Unknown host, return as-is
289299
}
290300
} catch (_: Exception) {
@@ -302,24 +312,24 @@ class JsonPatchBundle(
302312
}
303313

304314
override suspend fun getLatestInfo() = withContext(Dispatchers.IO) {
305-
val normalizedEndpoint = parseGitHubUrl(endpoint)
315+
val resolvedEndpoint = resolveBranchUrl(endpoint)
306316

307317
val asset = http.request<MorpheAsset> {
308-
url(normalizedEndpoint)
318+
url(resolvedEndpoint)
309319
}.getOrThrow()
310320

311321
// If pageUrl is not set, try to infer it from the endpoint and add version tag
312322
if (asset.pageUrl == null) {
313323
val repoUrl = inferPageUrlFromEndpoint(endpoint)
314324
val inferredPageUrl = if (repoUrl != null && asset.version.isNotBlank()) {
315325
// Normalize version to ensure it starts with 'v'
316-
val normalizedVersion = if (asset.version.startsWith("v")) {
317-
asset.version
318-
} else {
319-
"v${asset.version}"
320-
}
321-
// Create proper release page URL: https://github.com/owner/repo/releases/tag/v1.0.0-dev.1
322-
"$repoUrl/releases/tag/$normalizedVersion"
326+
val normalizedVersion = if (asset.version.startsWith("v")) asset.version else "v${asset.version}"
327+
// Create proper release page URL:
328+
// GitHub: https://github.com/owner/repo/releases/tag/v1.0.0-dev.1
329+
// GitLab: https://gitlab.com/owner/repo/-/releases/v1.0.0-dev.1
330+
val isGitLab = endpoint.contains("gitlab.com", ignoreCase = true)
331+
if (isGitLab) "$repoUrl/-/releases/$normalizedVersion"
332+
else "$repoUrl/releases/tag/$normalizedVersion"
323333
} else {
324334
// Fallback to repository URL if version is missing
325335
repoUrl
@@ -333,7 +343,7 @@ class JsonPatchBundle(
333343
override suspend fun fetchChangelogEntries(sinceVersion: String?): List<ChangelogEntry> {
334344
// endpoint stores the original branch - rebuild the URL for the active branch
335345
val api: MorpheAPI by inject()
336-
val activeEndpoint = parseGitHubUrl(endpoint)
346+
val activeEndpoint = resolveBranchUrl(endpoint)
337347
val changelogUrl = api.changelogUrlFromBundleEndpoint(activeEndpoint) ?: return emptyList()
338348
return fetchAndCacheEntries("$uid|$changelogUrl", sinceVersion) {
339349
api.fetchChangelogFromUrl(changelogUrl)
@@ -360,7 +370,12 @@ class JsonPatchBundle(
360370

361371
companion object {
362372
/**
363-
* Extracts the branch name from a GitHub URL.
373+
* Extracts the branch name from a GitHub or GitLab URL.
374+
*
375+
* Supported formats:
376+
* - raw.githubusercontent.com/owner/repo/BRANCH/path (returns null for refs/heads/...)
377+
* - github.com/owner/repo/tree|blob/BRANCH/path
378+
* - gitlab.com/owner/repo/-/raw/BRANCH/path
364379
*/
365380
internal fun extractBranch(url: String): String? {
366381
return try {
@@ -377,6 +392,13 @@ class JsonPatchBundle(
377392
"github.com" -> {
378393
if (parts.size >= 4 && parts[2] in listOf("tree", "blob")) parts[3] else null
379394
}
395+
"gitlab.com" -> {
396+
// Format: owner/repo/-/raw/BRANCH/path...
397+
val rawIndex = parts.indexOf("raw")
398+
if (rawIndex >= 0 && parts.getOrNull(rawIndex - 1) == "-")
399+
parts.getOrNull(rawIndex + 1)
400+
else null
401+
}
380402
else -> null
381403
}
382404
} catch (_: Exception) {

app/src/main/java/app/morphe/manager/domain/repository/PatchBundleRepository.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,44 @@ class PatchBundleRepository(
12161216
return "https://raw.githubusercontent.com$finalPath"
12171217
}
12181218

1219+
// Handle GitLab repository URLs
1220+
// Accepts short form: gitlab.com/owner/repo
1221+
// Or full raw URL: gitlab.com/owner/repo/-/raw/branch/patches-bundle.json
1222+
if (host.equals("gitlab.com", ignoreCase = true)) {
1223+
if (pathSegments.size < 2) {
1224+
throw IllegalArgumentException("Invalid GitLab repository URL")
1225+
}
1226+
1227+
val owner = pathSegments[0]
1228+
val repo = pathSegments[1]
1229+
1230+
// Check if this is already a raw URL: owner/repo/-/raw/branch/path
1231+
val rawIndex = pathSegments.indexOf("raw")
1232+
if (rawIndex >= 2 && pathSegments.getOrNull(rawIndex - 1) == "-") {
1233+
// Already a raw URL — normalize it
1234+
val normalizedPath = "/" + pathSegments.joinToString("/")
1235+
val pathNoQuery = normalizedPath.substringBefore('?').substringBefore('#')
1236+
if (!pathNoQuery.endsWith(".json", ignoreCase = true)) {
1237+
throw IllegalArgumentException("Patch bundle URL must point to a .json file.")
1238+
}
1239+
val query = parsed.encodedQuery.takeIf { it.isNotEmpty() }?.let { "?$it" }.orEmpty()
1240+
return "https://gitlab.com$normalizedPath$query"
1241+
}
1242+
1243+
// Determine branch from GitLab UI URLs (/-/tree/branch or /-/blob/branch)
1244+
val treeIndex = pathSegments.indexOf("tree")
1245+
val blobIndex = pathSegments.indexOf("blob")
1246+
val branch = when {
1247+
treeIndex >= 2 && pathSegments.getOrNull(treeIndex - 1) == "-" ->
1248+
pathSegments.getOrNull(treeIndex + 1) ?: "main"
1249+
blobIndex >= 2 && pathSegments.getOrNull(blobIndex - 1) == "-" ->
1250+
pathSegments.getOrNull(blobIndex + 1) ?: "main"
1251+
else -> "main"
1252+
}
1253+
1254+
return "https://gitlab.com/$owner/$repo/-/raw/$branch/patches-bundle.json"
1255+
}
1256+
12191257
// Handle raw.githubusercontent.com URLs (legacy support)
12201258
if (host.equals("raw.githubusercontent.com", ignoreCase = true)) {
12211259
if (pathSegments.size < 3) {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,16 @@ class MorpheAPI(
494494
"https://raw.githubusercontent.com/${parts[0]}/${parts[1]}/$branch/CHANGELOG.md"
495495
}
496496

497+
"gitlab.com" -> {
498+
// path: owner/repo/-/raw/branch/...
499+
if (parts.size < 2) return null
500+
val rawIndex = parts.indexOf("raw")
501+
val branch = if (rawIndex >= 0 && parts.getOrNull(rawIndex - 1) == "-") {
502+
parts.getOrNull(rawIndex + 1) ?: "main"
503+
} else "main"
504+
"https://gitlab.com/${parts[0]}/${parts[1]}/-/raw/$branch/CHANGELOG.md"
505+
}
506+
497507
else -> null
498508
}
499509
} catch (_: Exception) {

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import app.morphe.manager.domain.bundles.JsonPatchBundle
5555
import app.morphe.manager.domain.bundles.PatchBundleSource
5656
import app.morphe.manager.domain.bundles.PatchBundleSource.Extensions.bundleAvatarUrl
5757
import app.morphe.manager.domain.bundles.PatchBundleSource.Extensions.githubAvatarUrl
58+
import app.morphe.manager.domain.bundles.PatchBundleSource.Extensions.gitlabAvatarUrl
5859
import app.morphe.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
5960
import app.morphe.manager.domain.bundles.RemotePatchBundle
6061
import app.morphe.manager.domain.manager.PreferencesManager
@@ -927,6 +928,7 @@ fun BundleIcon(
927928
) {
928929
val bundleAvatarUrl = bundle.bundleAvatarUrl
929930
val githubAvatarUrl = bundle.githubAvatarUrl
931+
val gitlabAvatarUrl = bundle.gitlabAvatarUrl
930932
val hasMetadataError = metadataFetchError != null
931933
val hasBundleError = bundle.state is PatchBundleSource.State.Failed
932934
val isMissing = bundle.state is PatchBundleSource.State.Missing
@@ -987,10 +989,14 @@ fun BundleIcon(
987989
)
988990
}
989991

990-
bundleAvatarUrl != null || githubAvatarUrl != null -> {
992+
bundleAvatarUrl != null || githubAvatarUrl != null || gitlabAvatarUrl != null -> {
991993
RemoteAvatar(
992-
url = bundleAvatarUrl ?: githubAvatarUrl!!,
993-
fallbackUrl = if (bundleAvatarUrl != null) githubAvatarUrl else null,
994+
url = bundleAvatarUrl ?: githubAvatarUrl ?: gitlabAvatarUrl!!,
995+
fallbackUrl = when {
996+
bundleAvatarUrl != null -> githubAvatarUrl ?: gitlabAvatarUrl
997+
githubAvatarUrl != null -> gitlabAvatarUrl
998+
else -> null
999+
},
9941000
modifier = Modifier.fillMaxSize()
9951001
)
9961002
}

app/src/main/res/values/strings.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
<string name="sources_dialog_remote_url_formats_title">Examples:</string>
8787
<string name="sources_dialog_remote_url_formats_list" translatable="false">"
8888
• github.com/owner/repo
89+
• gitlab.com/owner/repo
8990
• raw.githubusercontent.com/owner/repo/patches-bundle.json
9091
• example.com/patches-bundle.json"</string>
9192
<string name="sources_dialog_local">Local</string>
@@ -94,7 +95,7 @@
9495
<string name="sources_dialog_local_file_description">Select a .mpp patch source file from storage</string>
9596
<string name="sources_dialog_local_invalid_extension">File must have a .mpp extension</string>
9697
<string name="sources_dialog_url_valid">Valid repository or JSON URL</string>
97-
<string name="sources_dialog_url_invalid">URL must point to a GitHub repo or .json file</string>
98+
<string name="sources_dialog_url_invalid">URL must point to a GitHub/GitLab repo or .json file</string>
9899
<string name="sources_dialog_local_change_file">Change file</string>
99100
<string name="sources_dialog_preinstalled">Pre-installed</string>
100101
<string name="sources_update_success">Update successful</string>

0 commit comments

Comments
 (0)