refactor: standardize URL handling with canonicalization for manifest paths

This commit is contained in:
tapframe 2026-02-17 14:35:13 +05:30
parent daa72ab245
commit 3a472435fe
3 changed files with 58 additions and 22 deletions

View file

@ -35,6 +35,7 @@ private const val TAG = "PluginManager"
private const val MAX_CONCURRENT_SCRAPERS = 5
private const val MAX_RESULT_ITEMS = 150
private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024L
private const val MANIFEST_SUFFIX = "/manifest.json"
@Singleton
class PluginManager @Inject constructor(
@ -64,7 +65,16 @@ class PluginManager @Inject constructor(
return sb.toString()
}
private fun normalizeUrl(url: String): String = url.trimEnd('/').lowercase()
private fun canonicalizeManifestUrl(url: String): String {
val trimmed = url.trim().trimEnd('/')
return if (trimmed.endsWith(MANIFEST_SUFFIX, ignoreCase = true)) {
trimmed
} else {
"$trimmed$MANIFEST_SUFFIX"
}
}
private fun normalizeUrl(url: String): String = canonicalizeManifestUrl(url).lowercase()
// Single-flight map to prevent duplicate scraper executions
private val inFlightScrapers = ConcurrentHashMap<String, kotlinx.coroutines.Deferred<List<LocalScraperResult>>>()
@ -112,17 +122,18 @@ class PluginManager @Inject constructor(
*/
suspend fun addRepository(manifestUrl: String): Result<PluginRepository> = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Adding repository from: $manifestUrl")
val canonicalManifestUrl = canonicalizeManifestUrl(manifestUrl)
Log.d(TAG, "Adding repository from: $canonicalManifestUrl")
// Fetch manifest
val manifest = fetchManifest(manifestUrl)
val manifest = fetchManifest(canonicalManifestUrl)
?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
// Create repository
val repo = PluginRepository(
id = UUID.randomUUID().toString(),
name = manifest.name,
url = manifestUrl,
url = canonicalManifestUrl,
enabled = true,
lastUpdated = System.currentTimeMillis(),
scraperCount = manifest.scrapers.size
@ -132,7 +143,7 @@ class PluginManager @Inject constructor(
dataStore.addRepository(repo)
// Download and save scrapers
downloadScrapers(repo.id, manifestUrl, manifest.scrapers)
downloadScrapers(repo.id, canonicalManifestUrl, manifest.scrapers)
Log.d(TAG, "Repository added: ${repo.name} with ${manifest.scrapers.size} scrapers")
triggerRemoteSync()
@ -170,7 +181,7 @@ class PluginManager @Inject constructor(
removeMissingLocal: Boolean = true
) {
val normalizedRemote = remoteUrls
.map { it.trim() }
.map { canonicalizeManifestUrl(it) }
.filter { it.isNotEmpty() }
.distinctBy { normalizeUrl(it) }
val remoteUrlSet = normalizedRemote.map { normalizeUrl(it) }.toSet()

View file

@ -25,6 +25,16 @@ class AddonPreferences @Inject constructor(
private val gson = Gson()
private val orderedUrlsKey = stringPreferencesKey("installed_addon_urls_ordered")
private val legacyUrlsKey = stringSetPreferencesKey("installed_addon_urls")
private val manifestSuffix = "/manifest.json"
private fun canonicalizeUrl(url: String): String {
val trimmed = url.trim().trimEnd('/')
return if (trimmed.endsWith(manifestSuffix, ignoreCase = true)) {
trimmed.dropLast(manifestSuffix.length).trimEnd('/')
} else {
trimmed
}
}
val installedAddonUrls: Flow<List<String>> = context.dataStore.data
.map { preferences ->
@ -51,8 +61,8 @@ class AddonPreferences @Inject constructor(
suspend fun addAddon(url: String) {
context.dataStore.edit { preferences ->
val current = getCurrentList(preferences)
val normalizedUrl = url.trimEnd('/')
if (current.any { it.trimEnd('/').equals(normalizedUrl, ignoreCase = true) }) return@edit
val normalizedUrl = canonicalizeUrl(url)
if (current.any { canonicalizeUrl(it).equals(normalizedUrl, ignoreCase = true) }) return@edit
preferences[orderedUrlsKey] = gson.toJson(current + normalizedUrl)
}
}
@ -60,9 +70,11 @@ class AddonPreferences @Inject constructor(
suspend fun removeAddon(url: String) {
context.dataStore.edit { preferences ->
val current = getCurrentList(preferences).toMutableList()
val normalizedUrl = url.trimEnd('/')
val normalizedUrl = canonicalizeUrl(url)
val indexToRemove = current.indexOfFirst { it.trimEnd('/') == normalizedUrl }
val indexToRemove = current.indexOfFirst {
canonicalizeUrl(it).equals(normalizedUrl, ignoreCase = true)
}
if (indexToRemove != -1) {
current.removeAt(indexToRemove)
}
@ -72,7 +84,7 @@ class AddonPreferences @Inject constructor(
suspend fun setAddonOrder(urls: List<String>) {
context.dataStore.edit { preferences ->
preferences[orderedUrlsKey] = gson.toJson(urls)
preferences[orderedUrlsKey] = gson.toJson(urls.map(::canonicalizeUrl))
}
}

View file

@ -42,13 +42,23 @@ class AddonRepositoryImpl @Inject constructor(
private const val TAG = "AddonRepository"
private const val MANIFEST_CACHE_PREFS = "addon_manifest_cache"
private const val MANIFEST_CACHE_KEY = "manifests"
private const val MANIFEST_SUFFIX = "/manifest.json"
}
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var syncJob: Job? = null
var isSyncingFromRemote = false
private fun normalizeUrl(url: String): String = url.trimEnd('/').lowercase()
private fun canonicalizeUrl(url: String): String {
val trimmed = url.trim().trimEnd('/')
return if (trimmed.endsWith(MANIFEST_SUFFIX, ignoreCase = true)) {
trimmed.dropLast(MANIFEST_SUFFIX.length).trimEnd('/')
} else {
trimmed
}
}
private fun normalizeUrl(url: String): String = canonicalizeUrl(url).lowercase()
private fun triggerRemoteSync() {
if (isSyncingFromRemote) return
@ -93,7 +103,7 @@ class AddonRepositoryImpl @Inject constructor(
preferences.installedAddonUrls.flatMapLatest { urls ->
flow {
// Emit cached addons immediately (now includes disk-persisted cache)
val cached = urls.mapNotNull { manifestCache[it.trimEnd('/')] }
val cached = urls.mapNotNull { manifestCache[canonicalizeUrl(it)] }
if (cached.isNotEmpty()) {
emit(applyDisplayNames(cached))
}
@ -103,7 +113,7 @@ class AddonRepositoryImpl @Inject constructor(
async {
when (val result = fetchAddon(url)) {
is NetworkResult.Success -> result.data
else -> manifestCache[url.trimEnd('/')]
else -> manifestCache[canonicalizeUrl(url)]
}
}
}.awaitAll().filterNotNull()
@ -116,7 +126,7 @@ class AddonRepositoryImpl @Inject constructor(
}
override suspend fun fetchAddon(baseUrl: String): NetworkResult<Addon> {
val cleanBaseUrl = baseUrl.trimEnd('/')
val cleanBaseUrl = canonicalizeUrl(baseUrl)
val manifestUrl = "$cleanBaseUrl/manifest.json"
return when (val result = safeApiCall { api.getManifest(manifestUrl) }) {
@ -126,19 +136,22 @@ class AddonRepositoryImpl @Inject constructor(
persistManifestCacheToDisk()
NetworkResult.Success(addon)
}
is NetworkResult.Error -> result
is NetworkResult.Error -> {
Log.w(TAG, "Failed to fetch addon manifest for url=$cleanBaseUrl code=${result.code} message=${result.message}")
result
}
NetworkResult.Loading -> NetworkResult.Loading
}
}
override suspend fun addAddon(url: String) {
val cleanUrl = url.trimEnd('/')
val cleanUrl = canonicalizeUrl(url)
preferences.addAddon(cleanUrl)
triggerRemoteSync()
}
override suspend fun removeAddon(url: String) {
val cleanUrl = url.trimEnd('/')
val cleanUrl = canonicalizeUrl(url)
manifestCache.remove(cleanUrl)
preferences.removeAddon(cleanUrl)
triggerRemoteSync()
@ -154,7 +167,7 @@ class AddonRepositoryImpl @Inject constructor(
removeMissingLocal: Boolean = true
) {
val normalizedRemote = remoteUrls
.map { it.trimEnd('/') }
.map { canonicalizeUrl(it) }
.filter { it.isNotBlank() }
.distinctBy { normalizeUrl(it) }
val remoteSet = normalizedRemote.map { normalizeUrl(it) }.toSet()
@ -175,16 +188,16 @@ class AddonRepositoryImpl @Inject constructor(
val currentUrls = preferences.installedAddonUrls.first()
val currentByNormalizedUrl = linkedMapOf<String, String>()
currentUrls.forEach { url ->
currentByNormalizedUrl.putIfAbsent(normalizeUrl(url), url.trimEnd('/'))
currentByNormalizedUrl.putIfAbsent(normalizeUrl(url), canonicalizeUrl(url))
}
val remoteOrdered = normalizedRemote
.mapNotNull { currentByNormalizedUrl[normalizeUrl(it)] }
val extras = currentUrls
.map { it.trimEnd('/') }
.map { canonicalizeUrl(it) }
.filter { normalizeUrl(it) !in remoteSet }
val reordered = if (removeMissingLocal) remoteOrdered else remoteOrdered + extras
if (reordered != currentUrls.map { it.trimEnd('/') }) {
if (reordered != currentUrls.map { canonicalizeUrl(it) }) {
preferences.setAddonOrder(reordered)
}
}