From bd5a385fb29fff5e90f4081f84ff2f22fcd52329 Mon Sep 17 00:00:00 2001 From: Mubashar Hussain Date: Tue, 20 Oct 2026 13:16:07 +0500 Subject: [PATCH 1/6] store: prioritize primary mirror for module details --- .../vector/manager/data/repository/RepoRepository.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt index b54183dcd0..75c21c9c8a 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -30,9 +30,7 @@ import org.matrix.vector.manager.logW * The Store's data: the online catalogue, and what this device already has of it. * * **The mirror list is two lists, and that is not an oversight.** The full `modules.json` is served - * by exactly one host today: `modules.lsposed.org` answers that path with a 403. Per-module - * `module/.json` *is* served by both hosts, so the public site is a real fallback there - * and only there. Merging these two lists back into one would quietly take the Store offline. + * Per-module `module/.json` is served by both hosts, with `modules.lsposed.org` providing the latest data and `backup.modules.lsposed.org` serving as fallback. * * **Caching is declared, not hoped for.** Every request states its own freshness, so the 16 MB disk * cache in `HttpClientFactory` is actually used: the catalogue revalidates against the server's own @@ -298,9 +296,9 @@ class RepoRepository( */ val LIST_MIRRORS = listOf("https://backup.modules.lsposed.org/") - /** Detail is served by both hosts, so here the public site is a genuine fallback. */ + /** Detail is served by both hosts; primary site carries the latest data while backup is fallback. */ val DETAIL_MIRRORS = - listOf("https://backup.modules.lsposed.org/", "https://modules.lsposed.org/") + listOf("https://modules.lsposed.org/", "https://backup.modules.lsposed.org/") /** Matches the server's own `cache-control: max-age=600`, so revalidation stays free. */ const val CATALOG_MAX_AGE_MINUTES = 10 From 4b7ca5f7148a512294b1841767691305c0f45130 Mon Sep 17 00:00:00 2001 From: Mubashar Hussain Date: Tue, 20 Oct 2026 13:22:55 +0500 Subject: [PATCH 2/6] store: integrate search.json for global real-time search suggestions --- .../org/matrix/vector/ui/store/RepoModels.kt | 25 +++++++ .../org/matrix/vector/ui/store/RepoScreen.kt | 74 ++++++++++++++++++- .../matrix/vector/ui/store/RepoViewModel.kt | 12 +++ .../matrix/vector/ui/store/StoreDataSource.kt | 4 + .../manager/data/repository/RepoRepository.kt | 40 ++++++++++ 5 files changed, 154 insertions(+), 1 deletion(-) diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt index 059b2e5fc4..383430e3d3 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt @@ -50,6 +50,31 @@ data class OnlineModule( get() = url ?: "https://github.com/Xposed-Modules-Repo/$name" } +/** + * An entry from `https://modules.lsposed.org/search.json`. + * + * Provides a lightweight index of all available modules for instant client-side + * global search and auto-complete. + */ +data class StoreSearchItem( + @SerializedName("name") val name: String, + @SerializedName("description") val description: String? = null, + @SerializedName("summary") val summary: String? = null, + @SerializedName("readmeExcerpt") val readmeExcerpt: String? = null, + @SerializedName("authors") val authors: String? = null, +) { + val title: String + get() = description?.takeIf { it.isNotBlank() } ?: name + + fun matches(query: String): Boolean { + if (query.isBlank()) return false + return title.contains(query, ignoreCase = true) || + name.contains(query, ignoreCase = true) || + summary?.contains(query, ignoreCase = true) == true || + authors?.contains(query, ignoreCase = true) == true + } +} + data class Collaborator( @SerializedName("login") val login: String?, @SerializedName("name") val name: String?, diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt index 431484882d..35631db462 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt @@ -104,6 +104,8 @@ fun RepoScreen( val priorities by viewModel.priorities.collectAsState() val channel by viewModel.channel.collectAsState() + val searchSuggestions by viewModel.searchSuggestions.collectAsState() + Scaffold { innerPadding -> Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { StoreHeader( @@ -115,6 +117,33 @@ fun RepoScreen( Spacer(Modifier.height(4.dp)) + // When user is searching, display real-time global search suggestions from search.json + if (query.isNotBlank()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 20.dp), + ) { + if (searchSuggestions.isEmpty() && entries.isEmpty()) { + item { + EmptyState( + modifier = Modifier.fillParentMaxSize(), + catalog = catalog, + filtered = true, + ) + } + } else { + items(searchSuggestions, key = { it.name }) { item -> + SearchSuggestionRow( + item = item, + onClick = { onModuleClick(item.name) }, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) + } + } + } + return@Column + } + // Nothing has ever loaded and a fetch is running: the one moment a spinner says // something the list could not say better itself. if (!catalog.loaded && entries.isEmpty()) { @@ -137,7 +166,7 @@ fun RepoScreen( EmptyState( modifier = Modifier.fillParentMaxSize(), catalog = catalog, - filtered = query.isNotBlank(), + filtered = false, ) } } else { @@ -427,6 +456,49 @@ private fun StoreRow(entry: StoreEntry, onClick: () -> Unit) { } } +@Composable +private fun SearchSuggestionRow(item: StoreSearchItem, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + Column( + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = item.name, + style = Mono, + color = colors.onSurfaceVariant, + ) + if (!item.summary.isNullOrBlank()) { + Spacer(Modifier.height(6.dp)) + Text( + text = item.summary, + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (!item.authors.isNullOrBlank()) { + Spacer(Modifier.height(6.dp)) + Text( + text = item.authors, + style = MaterialTheme.typography.labelSmall, + color = colors.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + @Composable private fun RowBadge(icon: ImageVector, text: String, tint: Color, modifier: Modifier = Modifier) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt index 6f8f962534..e5080dcb9c 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt @@ -170,6 +170,18 @@ class RepoViewModel( .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + /** + * Real-time search suggestions over the complete index of all ~1,180+ modules. + * When query is empty, suggestions are empty. + */ + val searchSuggestions: StateFlow> = + combine(dataSource.searchIndex, _query) { index, query -> + if (query.isBlank()) emptyList() + else index.filter { it.matches(query) }.take(20) + } + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + init { // The catalogue outlives this ViewModel — switching tabs destroys the nav entry — so a // return to the Store paints from what is already in memory and only fetches when there is diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt index ff969b535f..7e50d2cb53 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt @@ -17,6 +17,10 @@ interface StoreDataSource { /** Installed module versions on this device, keyed by package name. */ val installedVersions: StateFlow> + /** Complete search index for all modules, used for instant suggestion and search. */ + val searchIndex: StateFlow> + get() = kotlinx.coroutines.flow.MutableStateFlow(emptyList()) + /** Reload the catalogue from the network; [force] bypasses any cache. */ suspend fun refresh(force: Boolean = false) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt index 75c21c9c8a..6b2b6887e1 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -20,6 +20,7 @@ import org.matrix.vector.ui.store.StoreDataSource import org.matrix.vector.ui.store.OnlineModule import org.matrix.vector.ui.store.RepoVersion import org.matrix.vector.ui.store.StoreCatalog +import org.matrix.vector.ui.store.StoreSearchItem import org.matrix.vector.manager.data.model.versionCodeCompat import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ipc.DaemonClient @@ -57,6 +58,9 @@ class RepoRepository( private val _isRefreshing = MutableStateFlow(false) override val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + private val _searchIndex = MutableStateFlow>(emptyList()) + override val searchIndex: StateFlow> = _searchIndex.asStateFlow() + private val _installed = MutableStateFlow>(emptyMap()) /** @@ -102,6 +106,7 @@ class RepoRepository( .maxAge(CATALOG_MAX_AGE_MINUTES, TimeUnit.MINUTES) .build() + launch { fetchSearchIndex(freshness) } val fetched = LIST_MIRRORS.firstNotNullOfOrNull { fetchCatalog(it, freshness) } if (fetched != null) { _catalog.value = fetched @@ -241,6 +246,41 @@ class RepoRepository( } } + private fun fetchSearchIndex(cacheControl: CacheControl) { + val url = "https://modules.lsposed.org/search.json" + try { + client.newCall(request(url, cacheControl)).execute().use { response -> + if (response.isSuccessful) { + val parsed = parseSearchIndex(response) + if (parsed.isNotEmpty()) { + logI("store: loaded ${parsed.size} search items from $url") + _searchIndex.value = parsed + } + } + } + } catch (e: Exception) { + logW("store: search index from $url unavailable", e) + // Try cache fallback + if (cacheControl != CacheControl.FORCE_CACHE) { + fetchSearchIndex(CacheControl.FORCE_CACHE) + } + } + } + + private fun parseSearchIndex(response: Response): List { + val items = ArrayList(1200) + JsonReader(response.body.charStream()).use { reader -> + reader.beginArray() + while (reader.hasNext()) { + val element = JsonParser.parseReader(reader) + val item = runCatching { gson.fromJson(element, StoreSearchItem::class.java) } + if (item.isSuccess) item.getOrNull()?.let(items::add) + } + reader.endArray() + } + return items + } + private fun request(url: String, cacheControl: CacheControl): Request = Request.Builder().url(url).cacheControl(cacheControl).build() From 373b806c5c051c00dcd0111ff97d46dc69710ce4 Mon Sep 17 00:00:00 2001 From: Mubashar Hussain Date: Tue, 20 Oct 2026 13:27:59 +0500 Subject: [PATCH 3/6] store: paginate module catalogue using module-list endpoint --- .../org/matrix/vector/ui/store/RepoModels.kt | 7 ++ .../org/matrix/vector/ui/store/RepoScreen.kt | 32 ++++++- .../matrix/vector/ui/store/RepoViewModel.kt | 4 + .../matrix/vector/ui/store/StoreDataSource.kt | 3 + .../manager/data/repository/RepoRepository.kt | 88 +++++++++++++++++-- 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt index 383430e3d3..6a3ae7cc7d 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt @@ -293,7 +293,14 @@ data class StoreCatalog( val loaded: Boolean = false, val fromCache: Boolean = false, val loadedAtMillis: Long = 0L, + val page: Int = 1, + val pageCount: Int = 1, + val total: Int = 0, + val isLoadingMore: Boolean = false, ) { val isEmpty: Boolean get() = modules.isEmpty() + + val hasMore: Boolean + get() = page < pageCount } diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt index 35631db462..a8b87a02b3 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CloudOff @@ -46,11 +47,13 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -153,8 +156,22 @@ fun RepoScreen( return@Column } + val listState = rememberLazyListState() + LaunchedEffect(listState, catalog.hasMore, catalog.isLoadingMore) { + snapshotFlow { + val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + lastVisibleItem >= totalItems - 5 + }.collect { nearBottom -> + if (nearBottom && catalog.hasMore && !catalog.isLoadingMore) { + viewModel.loadMore() + } + } + } + PullToRefreshBox(isRefreshing = refreshing, onRefresh = viewModel::refresh) { LazyColumn( + state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 20.dp), ) { @@ -174,6 +191,16 @@ fun RepoScreen( StoreRow(entry = entry, onClick = { onModuleClick(entry.module.name) }) HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) } + if (catalog.isLoadingMore) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } + } + } } } } @@ -194,11 +221,12 @@ private fun StoreHeader( actions = actions, description = { if (catalog.modules.isNotEmpty()) { + val count = if (catalog.total > 0) catalog.total else catalog.modules.size val total = context.resources.getQuantityString( R.plurals.store_module_count, - catalog.modules.size, - catalog.modules.size, + count, + count, ) // "Up to date" is stated in words rather than as a zero, because a zero in a row // of counts reads as a failure to load rather than as good news. diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt index e5080dcb9c..b2e02e4568 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt @@ -213,6 +213,10 @@ class RepoViewModel( viewModelScope.launch { dataSource.refresh(force = true) } } + fun loadMore() { + viewModelScope.launch { dataSource.loadMore() } + } + /** The three controls that reorder the list, as one value so `combine` stays readable. */ private data class View( val sort: StoreSort, diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt index 7e50d2cb53..0d6a9f3fa0 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt @@ -24,6 +24,9 @@ interface StoreDataSource { /** Reload the catalogue from the network; [force] bypasses any cache. */ suspend fun refresh(force: Boolean = false) + /** Load the next page of modules in the catalogue. */ + suspend fun loadMore() {} + /** The full record for one module (README, all releases); null when unreachable. */ suspend fun details(packageName: String): OnlineModule? diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt index 6b2b6887e1..769f31d633 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -107,7 +107,8 @@ class RepoRepository( .build() launch { fetchSearchIndex(freshness) } - val fetched = LIST_MIRRORS.firstNotNullOfOrNull { fetchCatalog(it, freshness) } + val fetched = fetchPaginatedCatalog(1, freshness) + ?: LIST_MIRRORS.firstNotNullOfOrNull { fetchCatalog(it, freshness) } if (fetched != null) { _catalog.value = fetched return@withContext @@ -116,8 +117,8 @@ class RepoRepository( // Every mirror failed. Before reporting nothing, ask the cache — the bytes from // the last successful visit are usually still on disk, and a stale catalogue is // far more use than an empty screen. - val cached = - LIST_MIRRORS.firstNotNullOfOrNull { fetchCatalog(it, CacheControl.FORCE_CACHE) } + val cached = fetchPaginatedCatalog(1, CacheControl.FORCE_CACHE) + ?: LIST_MIRRORS.firstNotNullOfOrNull { fetchCatalog(it, CacheControl.FORCE_CACHE) } when { cached != null -> _catalog.value = cached.copy(fromCache = true) // Nothing on the network and nothing on disk. `loaded` still flips, so the @@ -135,6 +136,29 @@ class RepoRepository( } } + override suspend fun loadMore() { + val current = _catalog.value + if (!current.hasMore || current.isLoadingMore) return + _catalog.value = current.copy(isLoadingMore = true) + withContext(Dispatchers.IO) { + val nextPage = current.page + 1 + val freshness = CacheControl.Builder().maxAge(CATALOG_MAX_AGE_MINUTES, TimeUnit.MINUTES).build() + val result = fetchPaginatedCatalog(nextPage, freshness) + if (result != null) { + val combined = (current.modules + result.modules).distinctBy { it.name } + _catalog.value = current.copy( + modules = combined, + page = result.page, + pageCount = result.pageCount, + total = result.total, + isLoadingMore = false, + ) + } else { + _catalog.value = current.copy(isLoadingMore = false) + } + } + } + /** * The full record for one module: its README, and every release rather than only the newest. * @@ -281,6 +305,59 @@ class RepoRepository( return items } + private fun fetchPaginatedCatalog(page: Int, cacheControl: CacheControl): StoreCatalog? { + val url = "https://modules.lsposed.org/module-list/$page.json" + return try { + client.newCall(request(url, cacheControl)).execute().use { response -> + if (!response.isSuccessful) { + if (response.networkResponse != null) { + logW("store: $url returned HTTP ${response.code}") + } + return null + } + val parsed = parsePaginatedCatalog(response) ?: return null + logI("store: page ${parsed.page}/${parsed.pageCount} (${parsed.modules.size} modules) from $url") + StoreCatalog( + modules = usable(parsed.modules), + loaded = true, + loadedAtMillis = response.receivedResponseAtMillis, + page = parsed.page, + pageCount = parsed.pageCount, + total = parsed.total, + ) + } + } catch (e: Exception) { + logW("store: $url unavailable", e) + null + } + } + + private data class PaginatedResponse( + val modules: List = emptyList(), + val page: Int = 1, + val pageCount: Int = 1, + val total: Int = 0, + ) + + private fun parsePaginatedCatalog(response: Response): PaginatedResponse? { + return try { + val json = JsonParser.parseReader(response.body.charStream()).asJsonObject + val page = json.get("page")?.asInt ?: 1 + val pageCount = json.get("pageCount")?.asInt ?: 1 + val total = json.get("total")?.asInt ?: 0 + val modulesArray = json.getAsJsonArray("modules") ?: return null + val modules = ArrayList(modulesArray.size()) + for (element in modulesArray) { + val module = runCatching { gson.fromJson(element, OnlineModule::class.java) } + if (module.isSuccess) module.getOrNull()?.let(modules::add) + } + PaginatedResponse(modules = modules, page = page, pageCount = pageCount, total = total) + } catch (e: Exception) { + logW("store: failed to parse paginated catalog", e) + null + } + } + private fun request(url: String, cacheControl: CacheControl): Request = Request.Builder().url(url).cacheControl(cacheControl).build() @@ -318,14 +395,13 @@ class RepoRepository( * * `distinctBy` is not superstition about today's data — it is what stops a mirror serving the * same package twice from crashing the Store's `LazyColumn`, which is keyed by package name. - * Entries with no release at all are dropped because there is nothing to install and nothing to - * say about them. + * Entries with no release or latestRelease are dropped. */ private fun usable(parsed: List): List = parsed .asSequence() .filter { it.hide != true } - .filter { !it.releases.isNullOrEmpty() } + .filter { !it.releases.isNullOrEmpty() || !it.latestRelease.isNullOrBlank() } .distinctBy { it.name } .toList() From ae9a57aad102600c416877acc85d26836bbd8981 Mon Sep 17 00:00:00 2001 From: Mubashar Hussain Date: Tue, 20 Oct 2026 14:06:58 +0500 Subject: [PATCH 4/6] store: implement sequential background sync with progress tracking and 1-hour cache --- .../org/matrix/vector/ui/store/RepoModels.kt | 7 +- .../org/matrix/vector/ui/store/RepoScreen.kt | 106 ++++++++++-------- .../matrix/vector/ui/store/RepoViewModel.kt | 4 - .../matrix/vector/ui/store/StoreDataSource.kt | 3 - .../manager/data/repository/RepoRepository.kt | 78 +++++++------ 5 files changed, 109 insertions(+), 89 deletions(-) diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt index 6a3ae7cc7d..99792865cf 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt @@ -296,11 +296,12 @@ data class StoreCatalog( val page: Int = 1, val pageCount: Int = 1, val total: Int = 0, - val isLoadingMore: Boolean = false, + val isSyncing: Boolean = false, + val syncProgress: Float = 0f, ) { val isEmpty: Boolean get() = modules.isEmpty() - val hasMore: Boolean - get() = page < pageCount + val isFullySynced: Boolean + get() = page >= pageCount && pageCount > 0 } diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt index a8b87a02b3..64356b8773 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -114,7 +115,7 @@ fun RepoScreen( StoreHeader( catalog = catalog, updates = updates, - search = { StoreSearch(query, viewModel, sort, priorities, channel) }, + search = { StoreSearch(query, viewModel, sort, priorities, channel, isSyncing = catalog.isSyncing) }, actions = actions, ) @@ -156,52 +157,63 @@ fun RepoScreen( return@Column } - val listState = rememberLazyListState() - LaunchedEffect(listState, catalog.hasMore, catalog.isLoadingMore) { - snapshotFlow { - val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - val totalItems = listState.layoutInfo.totalItemsCount - lastVisibleItem >= totalItems - 5 - }.collect { nearBottom -> - if (nearBottom && catalog.hasMore && !catalog.isLoadingMore) { - viewModel.loadMore() + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + PullToRefreshBox(isRefreshing = refreshing, onRefresh = viewModel::refresh) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 20.dp), + ) { + if (entries.isEmpty()) { + // Inside the list rather than beside it: an empty Box has nothing to + // scroll, and pull-to-refresh is exactly what the reader wants when the + // reason the list is empty is that the network was down. + item { + EmptyState( + modifier = Modifier.fillParentMaxSize(), + catalog = catalog, + filtered = false, + ) + } + } else { + items(entries, key = { it.module.name }) { entry -> + StoreRow(entry = entry, onClick = { onModuleClick(entry.module.name) }) + HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) + } + } } } } - PullToRefreshBox(isRefreshing = refreshing, onRefresh = viewModel::refresh) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 20.dp), + // Sync progress bar positioned above the bottom bar + if (catalog.isSyncing) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 6.dp) ) { - if (entries.isEmpty()) { - // Inside the list rather than beside it: an empty Box has nothing to - // scroll, and pull-to-refresh is exactly what the reader wants when the - // reason the list is empty is that the network was down. - item { - EmptyState( - modifier = Modifier.fillParentMaxSize(), - catalog = catalog, - filtered = false, - ) - } - } else { - items(entries, key = { it.module.name }) { entry -> - StoreRow(entry = entry, onClick = { onModuleClick(entry.module.name) }) - HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) - } - if (catalog.isLoadingMore) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) - } - } - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Syncing repository (${catalog.page}/${catalog.pageCount})...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${(catalog.syncProgress * 100).toInt()}%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) } + Spacer(Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { catalog.syncProgress }, + modifier = Modifier.fillMaxWidth().height(3.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) } } } @@ -257,6 +269,7 @@ private fun StoreSearch( sort: StoreSort, priorities: List, channel: StoreChannel, + isSyncing: Boolean, ) { SearchField( query = query, @@ -270,6 +283,7 @@ private fun StoreSearch( onTogglePriority = viewModel::togglePriority, channel = channel, onChannelChange = viewModel::setChannel, + isSyncing = isSyncing, ) } } @@ -292,6 +306,7 @@ private fun StoreFilterButton( onTogglePriority: (StorePriority) -> Unit, channel: StoreChannel, onChannelChange: (StoreChannel) -> Unit, + isSyncing: Boolean = false, ) { var sheetOpen by remember { mutableStateOf(false) } val narrowed = @@ -299,14 +314,17 @@ private fun StoreFilterButton( channel != StoreChannel.Stable || priorities != listOf(StorePriority.Updates) - IconButton(onClick = { sheetOpen = true }) { + IconButton(onClick = { if (!isSyncing) sheetOpen = true }, enabled = !isSyncing) { BadgedBox(badge = { if (narrowed) Badge(modifier = Modifier.size(6.dp)) }) { Icon( Icons.Rounded.FilterList, contentDescription = stringResource(R.string.store_filter), tint = - if (narrowed) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, + when { + isSyncing -> MaterialTheme.colorScheme.outlineVariant + narrowed -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, ) } } diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt index b2e02e4568..e5080dcb9c 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt @@ -213,10 +213,6 @@ class RepoViewModel( viewModelScope.launch { dataSource.refresh(force = true) } } - fun loadMore() { - viewModelScope.launch { dataSource.loadMore() } - } - /** The three controls that reorder the list, as one value so `combine` stays readable. */ private data class View( val sort: StoreSort, diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt index 0d6a9f3fa0..7e50d2cb53 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt @@ -24,9 +24,6 @@ interface StoreDataSource { /** Reload the catalogue from the network; [force] bypasses any cache. */ suspend fun refresh(force: Boolean = false) - /** Load the next page of modules in the catalogue. */ - suspend fun loadMore() {} - /** The full record for one module (README, all releases); null when unreachable. */ suspend fun details(packageName: String): OnlineModule? diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt index 769f31d633..7897f843d2 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -93,8 +93,7 @@ class RepoRepository( * because a user who pulls is telling us they think what they are looking at is stale. */ override suspend fun refresh(force: Boolean) { - // A second caller during a refresh is a no-op rather than a queued duplicate of a 1.2 MB - // download. + // A second caller during a refresh is a no-op rather than a queued duplicate if (!refreshing.tryLock()) return try { _isRefreshing.value = true @@ -107,56 +106,65 @@ class RepoRepository( .build() launch { fetchSearchIndex(freshness) } - val fetched = fetchPaginatedCatalog(1, freshness) + + // Fetch page 1 immediately to show the initial modules instantly + val firstPage = fetchPaginatedCatalog(1, freshness) ?: LIST_MIRRORS.firstNotNullOfOrNull { fetchCatalog(it, freshness) } - if (fetched != null) { - _catalog.value = fetched + + if (firstPage != null) { + val initialCatalog = firstPage.copy( + isSyncing = firstPage.page < firstPage.pageCount, + syncProgress = if (firstPage.pageCount > 0) 1f / firstPage.pageCount else 1f, + ) + _catalog.value = initialCatalog + + // If more pages exist, sync them sequentially in the background + if (firstPage.page < firstPage.pageCount) { + syncRemainingPages(firstPage, freshness) + } return@withContext } - // Every mirror failed. Before reporting nothing, ask the cache — the bytes from - // the last successful visit are usually still on disk, and a stale catalogue is - // far more use than an empty screen. + // If network failed, check cache for page 1 val cached = fetchPaginatedCatalog(1, CacheControl.FORCE_CACHE) ?: LIST_MIRRORS.firstNotNullOfOrNull { fetchCatalog(it, CacheControl.FORCE_CACHE) } + when { - cached != null -> _catalog.value = cached.copy(fromCache = true) - // Nothing on the network and nothing on disk. `loaded` still flips, so the - // screen can say the repository is unreachable instead of sitting forever on - // a spinner that means nothing. - else -> _catalog.value = _catalog.value.copy(loaded = true) + cached != null -> _catalog.value = cached.copy(fromCache = true, isSyncing = false) + else -> _catalog.value = _catalog.value.copy(loaded = true, isSyncing = false) } } } finally { - // In a `finally` rather than after the block: a cancelled `viewModelScope` — a - // rotation mid-refresh — would otherwise strand the flag at true, and with - // pull-to-refresh reading it that is a spinner that never stops. _isRefreshing.value = false refreshing.unlock() } } - override suspend fun loadMore() { - val current = _catalog.value - if (!current.hasMore || current.isLoadingMore) return - _catalog.value = current.copy(isLoadingMore = true) - withContext(Dispatchers.IO) { - val nextPage = current.page + 1 - val freshness = CacheControl.Builder().maxAge(CATALOG_MAX_AGE_MINUTES, TimeUnit.MINUTES).build() - val result = fetchPaginatedCatalog(nextPage, freshness) - if (result != null) { - val combined = (current.modules + result.modules).distinctBy { it.name } - _catalog.value = current.copy( - modules = combined, - page = result.page, - pageCount = result.pageCount, - total = result.total, - isLoadingMore = false, + private suspend fun syncRemainingPages(initial: StoreCatalog, cacheControl: CacheControl) { + var accumulated = initial.modules.toMutableList() + val totalPages = initial.pageCount + for (p in (initial.page + 1)..totalPages) { + val pageResult = fetchPaginatedCatalog(p, cacheControl) + if (pageResult != null) { + accumulated.addAll(pageResult.modules) + val distinct = usable(accumulated) + accumulated = distinct.toMutableList() + val progress = p.toFloat() / totalPages + _catalog.value = _catalog.value.copy( + modules = distinct, + page = p, + pageCount = totalPages, + total = pageResult.total, + isSyncing = p < totalPages, + syncProgress = progress, ) } else { - _catalog.value = current.copy(isLoadingMore = false) + // If a page fails, stop syncing and keep current state + _catalog.value = _catalog.value.copy(isSyncing = false) + break } } + _catalog.value = _catalog.value.copy(isSyncing = false, syncProgress = 1f) } /** @@ -416,8 +424,8 @@ class RepoRepository( val DETAIL_MIRRORS = listOf("https://modules.lsposed.org/", "https://backup.modules.lsposed.org/") - /** Matches the server's own `cache-control: max-age=600`, so revalidation stays free. */ - const val CATALOG_MAX_AGE_MINUTES = 10 + /** Matches 1 hour cache duration so full catalogue sync only happens once an hour or on pull-to-refresh. */ + const val CATALOG_MAX_AGE_MINUTES = 60 /** Longer: a module's release history changes far less often than the index does. */ const val DETAIL_MAX_AGE_MINUTES = 60 From 4e00420ff4d4c6d42cc8ba3d7d2f5de0c4c3fb6e Mon Sep 17 00:00:00 2001 From: Mubashar Hussain Date: Tue, 20 Oct 2026 14:14:57 +0500 Subject: [PATCH 5/6] refactor: remove search index and suggestions feature from store repository and UI --- .../org/matrix/vector/ui/store/RepoModels.kt | 24 ------ .../org/matrix/vector/ui/store/RepoScreen.kt | 75 +------------------ .../matrix/vector/ui/store/RepoViewModel.kt | 12 --- .../matrix/vector/ui/store/StoreDataSource.kt | 4 - .../manager/data/repository/RepoRepository.kt | 40 ---------- 5 files changed, 2 insertions(+), 153 deletions(-) diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt index 99792865cf..c1ed022075 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoModels.kt @@ -50,30 +50,6 @@ data class OnlineModule( get() = url ?: "https://github.com/Xposed-Modules-Repo/$name" } -/** - * An entry from `https://modules.lsposed.org/search.json`. - * - * Provides a lightweight index of all available modules for instant client-side - * global search and auto-complete. - */ -data class StoreSearchItem( - @SerializedName("name") val name: String, - @SerializedName("description") val description: String? = null, - @SerializedName("summary") val summary: String? = null, - @SerializedName("readmeExcerpt") val readmeExcerpt: String? = null, - @SerializedName("authors") val authors: String? = null, -) { - val title: String - get() = description?.takeIf { it.isNotBlank() } ?: name - - fun matches(query: String): Boolean { - if (query.isBlank()) return false - return title.contains(query, ignoreCase = true) || - name.contains(query, ignoreCase = true) || - summary?.contains(query, ignoreCase = true) == true || - authors?.contains(query, ignoreCase = true) == true - } -} data class Collaborator( @SerializedName("login") val login: String?, diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt index 64356b8773..4e540aa5d0 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoScreen.kt @@ -108,8 +108,6 @@ fun RepoScreen( val priorities by viewModel.priorities.collectAsState() val channel by viewModel.channel.collectAsState() - val searchSuggestions by viewModel.searchSuggestions.collectAsState() - Scaffold { innerPadding -> Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) { StoreHeader( @@ -121,33 +119,6 @@ fun RepoScreen( Spacer(Modifier.height(4.dp)) - // When user is searching, display real-time global search suggestions from search.json - if (query.isNotBlank()) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 20.dp), - ) { - if (searchSuggestions.isEmpty() && entries.isEmpty()) { - item { - EmptyState( - modifier = Modifier.fillParentMaxSize(), - catalog = catalog, - filtered = true, - ) - } - } else { - items(searchSuggestions, key = { it.name }) { item -> - SearchSuggestionRow( - item = item, - onClick = { onModuleClick(item.name) }, - ) - HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant) - } - } - } - return@Column - } - // Nothing has ever loaded and a fetch is running: the one moment a spinner says // something the list could not say better itself. if (!catalog.loaded && entries.isEmpty()) { @@ -171,7 +142,7 @@ fun RepoScreen( EmptyState( modifier = Modifier.fillParentMaxSize(), catalog = catalog, - filtered = false, + filtered = query.isNotBlank(), ) } } else { @@ -197,7 +168,7 @@ fun RepoScreen( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Syncing repository (${catalog.page}/${catalog.pageCount})...", + text = "Syncing modules (${catalog.page}/${catalog.pageCount})...", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -502,48 +473,6 @@ private fun StoreRow(entry: StoreEntry, onClick: () -> Unit) { } } -@Composable -private fun SearchSuggestionRow(item: StoreSearchItem, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - Column( - modifier = - Modifier.fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 12.dp) - ) { - Text( - text = item.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = item.name, - style = Mono, - color = colors.onSurfaceVariant, - ) - if (!item.summary.isNullOrBlank()) { - Spacer(Modifier.height(6.dp)) - Text( - text = item.summary, - style = MaterialTheme.typography.bodyMedium, - color = colors.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - if (!item.authors.isNullOrBlank()) { - Spacer(Modifier.height(6.dp)) - Text( - text = item.authors, - style = MaterialTheme.typography.labelSmall, - color = colors.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} @Composable private fun RowBadge(icon: ImageVector, text: String, tint: Color, modifier: Modifier = Modifier) { diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt index e5080dcb9c..6f8f962534 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/RepoViewModel.kt @@ -170,18 +170,6 @@ class RepoViewModel( .flowOn(Dispatchers.Default) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - /** - * Real-time search suggestions over the complete index of all ~1,180+ modules. - * When query is empty, suggestions are empty. - */ - val searchSuggestions: StateFlow> = - combine(dataSource.searchIndex, _query) { index, query -> - if (query.isBlank()) emptyList() - else index.filter { it.matches(query) }.take(20) - } - .flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - init { // The catalogue outlives this ViewModel — switching tabs destroys the nav entry — so a // return to the Store paints from what is already in memory and only fetches when there is diff --git a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt index 7e50d2cb53..ff969b535f 100644 --- a/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt +++ b/manager-ui/src/main/kotlin/org/matrix/vector/ui/store/StoreDataSource.kt @@ -17,10 +17,6 @@ interface StoreDataSource { /** Installed module versions on this device, keyed by package name. */ val installedVersions: StateFlow> - /** Complete search index for all modules, used for instant suggestion and search. */ - val searchIndex: StateFlow> - get() = kotlinx.coroutines.flow.MutableStateFlow(emptyList()) - /** Reload the catalogue from the network; [force] bypasses any cache. */ suspend fun refresh(force: Boolean = false) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt index 7897f843d2..c40eb61463 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -20,7 +20,6 @@ import org.matrix.vector.ui.store.StoreDataSource import org.matrix.vector.ui.store.OnlineModule import org.matrix.vector.ui.store.RepoVersion import org.matrix.vector.ui.store.StoreCatalog -import org.matrix.vector.ui.store.StoreSearchItem import org.matrix.vector.manager.data.model.versionCodeCompat import org.matrix.vector.manager.di.ServiceLocator import org.matrix.vector.manager.ipc.DaemonClient @@ -58,9 +57,6 @@ class RepoRepository( private val _isRefreshing = MutableStateFlow(false) override val isRefreshing: StateFlow = _isRefreshing.asStateFlow() - private val _searchIndex = MutableStateFlow>(emptyList()) - override val searchIndex: StateFlow> = _searchIndex.asStateFlow() - private val _installed = MutableStateFlow>(emptyMap()) /** @@ -105,8 +101,6 @@ class RepoRepository( .maxAge(CATALOG_MAX_AGE_MINUTES, TimeUnit.MINUTES) .build() - launch { fetchSearchIndex(freshness) } - // Fetch page 1 immediately to show the initial modules instantly val firstPage = fetchPaginatedCatalog(1, freshness) ?: LIST_MIRRORS.firstNotNullOfOrNull { fetchCatalog(it, freshness) } @@ -278,40 +272,6 @@ class RepoRepository( } } - private fun fetchSearchIndex(cacheControl: CacheControl) { - val url = "https://modules.lsposed.org/search.json" - try { - client.newCall(request(url, cacheControl)).execute().use { response -> - if (response.isSuccessful) { - val parsed = parseSearchIndex(response) - if (parsed.isNotEmpty()) { - logI("store: loaded ${parsed.size} search items from $url") - _searchIndex.value = parsed - } - } - } - } catch (e: Exception) { - logW("store: search index from $url unavailable", e) - // Try cache fallback - if (cacheControl != CacheControl.FORCE_CACHE) { - fetchSearchIndex(CacheControl.FORCE_CACHE) - } - } - } - - private fun parseSearchIndex(response: Response): List { - val items = ArrayList(1200) - JsonReader(response.body.charStream()).use { reader -> - reader.beginArray() - while (reader.hasNext()) { - val element = JsonParser.parseReader(reader) - val item = runCatching { gson.fromJson(element, StoreSearchItem::class.java) } - if (item.isSuccess) item.getOrNull()?.let(items::add) - } - reader.endArray() - } - return items - } private fun fetchPaginatedCatalog(page: Int, cacheControl: CacheControl): StoreCatalog? { val url = "https://modules.lsposed.org/module-list/$page.json" From 647a8f3a1a758cd29b3b3c907e6bd5a83b87da27 Mon Sep 17 00:00:00 2001 From: Mubashar Hussain Date: Tue, 20 Oct 2026 14:23:38 +0500 Subject: [PATCH 6/6] store: enforce 1-hour cache freshness and prevent automatic resync on tab switch --- .../manager/data/repository/RepoRepository.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt index c40eb61463..e977faaec4 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/RepoRepository.kt @@ -58,6 +58,7 @@ class RepoRepository( override val isRefreshing: StateFlow = _isRefreshing.asStateFlow() private val _installed = MutableStateFlow>(emptyMap()) + private var lastFullSyncTimeMillis: Long = 0L /** * What each package on this device is at, keyed by package name. @@ -94,6 +95,17 @@ class RepoRepository( try { _isRefreshing.value = true withContext(Dispatchers.IO) { + // If not forcing a refresh, check if we already have a fully synced catalog within 1 hour + if (!force) { + val current = _catalog.value + val ageMillis = System.currentTimeMillis() - lastFullSyncTimeMillis + val isFresh = lastFullSyncTimeMillis > 0L && ageMillis < TimeUnit.MINUTES.toMillis(CATALOG_MAX_AGE_MINUTES.toLong()) + if (current.isFullySynced && isFresh) { + logI("store: catalog is fresh (age: ${TimeUnit.MILLISECONDS.toMinutes(ageMillis)}m), skipping sync") + return@withContext + } + } + val freshness = if (force) CacheControl.FORCE_NETWORK else @@ -115,6 +127,8 @@ class RepoRepository( // If more pages exist, sync them sequentially in the background if (firstPage.page < firstPage.pageCount) { syncRemainingPages(firstPage, freshness) + } else { + lastFullSyncTimeMillis = System.currentTimeMillis() } return@withContext } @@ -159,6 +173,9 @@ class RepoRepository( } } _catalog.value = _catalog.value.copy(isSyncing = false, syncProgress = 1f) + if (_catalog.value.isFullySynced) { + lastFullSyncTimeMillis = System.currentTimeMillis() + } } /**