From c24add1d469a2e1e1e64ece07ed76d9964ed450b Mon Sep 17 00:00:00 2001 From: meowarex Date: Thu, 20 Aug 2026 07:14:19 +0000 Subject: [PATCH] Fix Refresh Button --- .version | 2 +- Manager/app/build.gradle.kts | 4 +- .../network/services/RLMobileGithubService.kt | 20 ++++- .../network/utils/CommitsPagingSource.kt | 5 +- .../ui/legacy/screens/home/HomeScreen.kt | 14 +++- .../rlmobile/ui/screens/home/HomeModel.kt | 75 ++++++++++++------- .../rlmobile/ui/screens/home/HomeScreen.kt | 32 +++++++- .../ui/widgets/updater/UpdaterViewModel.kt | 38 +++++++--- 8 files changed, 143 insertions(+), 47 deletions(-) diff --git a/.version b/.version index e25d8d9..0664a8f 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -1.1.5 +1.1.6 diff --git a/Manager/app/build.gradle.kts b/Manager/app/build.gradle.kts index a52ade9..ad19044 100644 --- a/Manager/app/build.gradle.kts +++ b/Manager/app/build.gradle.kts @@ -31,8 +31,8 @@ android { defaultConfig { minSdk = 24 targetSdk = 36 - versionCode = 67 - versionName = "1.1.5" + versionCode = 68 + versionName = "1.1.6" vectorDrawables { useSupportLibrary = true diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt index e8f8998..cd6ad33 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/services/RLMobileGithubService.kt @@ -39,10 +39,14 @@ class RadiantLyricsGithubService( /** * Fetches manager self-update releases. */ - suspend fun getManagerReleases(): ApiResponse> = + suspend fun getManagerReleases(force: Boolean = false): ApiResponse> = http.request { url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/releases") - header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60") + if (force) { + header(HttpHeaders.CacheControl, "no-cache") + } else { + header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60") + } } /** @@ -66,10 +70,18 @@ class RadiantLyricsGithubService( /** * Fetches a page of commits (paginated). Used by the Home screen's commit list. */ - suspend fun getCommits(page: Int, perPage: Int = 30): ApiResponse> = + suspend fun getCommits( + page: Int, + perPage: Int = 30, + force: Boolean = false, + ): ApiResponse> = http.request { url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/commits?per_page=$perPage&page=${page + 1}") - header(HttpHeaders.CacheControl, "public, max-age=120, s-maxage=120") + if (force) { + header(HttpHeaders.CacheControl, "no-cache") + } else { + header(HttpHeaders.CacheControl, "public, max-age=120, s-maxage=120") + } } companion object { diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt index c6f2589..a66bd4a 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/network/utils/CommitsPagingSource.kt @@ -7,6 +7,8 @@ import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService class CommitsPagingSource( private val github: RadiantLyricsGithubService, + /** Bypasses the http response cache */ + private val force: Boolean = false, ) : PagingSource() { private val seenShas = mutableSetOf() private val seenTitles = mutableSetOf() @@ -19,7 +21,8 @@ class CommitsPagingSource( override suspend fun load(params: LoadParams): LoadResult { val page = params.key ?: 0 - return when (val r = github.getCommits(page)) { + // Only the first page skips the cache + return when (val r = github.getCommits(page, force = force && page == 0)) { is ApiResponse.Success -> LoadResult.Page( data = r.data.filter { commit -> val title = commit.commit.message.lineSequence().first().trim() diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/legacy/screens/home/HomeScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/legacy/screens/home/HomeScreen.kt index 7790b58..71a020c 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/legacy/screens/home/HomeScreen.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/legacy/screens/home/HomeScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.activity.ComponentActivity import androidx.compose.ui.platform.LocalContext @@ -57,6 +58,8 @@ class HomeScreen : Screen, Parcelable { val activity = LocalContext.current as ComponentActivity val updater = koinViewModel(viewModelStoreOwner = activity) val managerUpdateAvailable = updater.targetVersion != null + val refreshing = model.refreshing + val refreshAngle = rememberRefreshAngle() LifecycleResumeEffect(Unit) { model.refresh(delay = true) @@ -68,10 +71,19 @@ class HomeScreen : Screen, Parcelable { TopAppBar( title = { Text(stringResource(R.string.navigation_home)) }, actions = { - IconButton(onClick = { model.refresh() }) { + IconButton( + onClick = { + model.refresh(force = true) + updater.checkForUpdates(force = true) + }, + enabled = !refreshing, + ) { Icon( painterResource(R.drawable.ic_refresh), contentDescription = stringResource(R.string.navigation_refresh), + modifier = Modifier.graphicsLayer { + rotationZ = if (refreshing) refreshAngle() else 0f + }, ) } if (managerUpdateAvailable) { diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt index 8e07c9c..44fa773 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeModel.kt @@ -34,6 +34,9 @@ import com.meowarex.rlmobile.ui.util.TidalVersion import com.meowarex.rlmobile.ui.widgets.managerupdate.VersionDelta import com.meowarex.rlmobile.util.* import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json @@ -53,9 +56,22 @@ class HomeModel( var managerUpdateDeltas by mutableStateOf?>(null) private set - val commits = Pager(PagingConfig(pageSize = 30)) { - CommitsPagingSource(github) - }.flow.cachedIn(screenModelScope) + /** Whether a user-initiated refresh is currently in flight */ + var refreshing by mutableStateOf(false) + private set + + // Bumped by every forced refresh, which restarts the pager with a fresh (cache-skipping) source. + // Without this the commit list would stay pinned to whatever it loaded on process start. + private val commitsGeneration = MutableStateFlow(0) + + @OptIn(ExperimentalCoroutinesApi::class) + val commits = commitsGeneration + .flatMapLatest { generation -> + Pager(PagingConfig(pageSize = 30)) { + CommitsPagingSource(github, force = generation > 0) + }.flow + } + .cachedIn(screenModelScope) private val refreshingLock = Mutex() private var remoteDataJson: RLBuildInfo? = null @@ -164,7 +180,10 @@ class HomeModel( ) } - fun refresh(delay: Boolean = false) = screenModelScope.launchIO { + /** + * @param delay Waits a moment before starting + */ + fun refresh(delay: Boolean = false, force: Boolean = false) = screenModelScope.launchIO { if (refreshingLock.isLocked) return@launchIO if (delay) { delay(250) @@ -172,26 +191,32 @@ class HomeModel( } refreshingLock.withLock { - val pkg = fetchInstalled() - // Skip the remote fetch entirely when offline - val online = application.isOnline() - if (online) { - val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() } - remote.await() - } + if (force) mainThread { refreshing = true } + try { + val pkg = fetchInstalled() + // Skip the remote fetch entirely when offline + val online = application.isOnline() + if (online && (force || remoteDataJson == null)) { + fetchRemoteData(force) + } + // Offline the reload would only replace the visible list with a load error + if (force && online) commitsGeneration.update { it + 1 } - val install = pkg?.toInstallData() - val latest = remoteDataJson?.tidalVersionCode - val offlineRepatchReady = hasOfflineRepatchAssets() + val install = pkg?.toInstallData() + val latest = remoteDataJson?.tidalVersionCode + val offlineRepatchReady = hasOfflineRepatchAssets() - mainThread { - state = HomeState.Loaded( - install = install, - latestTidalVersionCode = latest, - offline = !online, - offlineRepatchReady = offlineRepatchReady, - ) - maybeCheckManagerUpdate(pkg) + mainThread { + state = HomeState.Loaded( + install = install, + latestTidalVersionCode = latest, + offline = !online, + offlineRepatchReady = offlineRepatchReady, + ) + maybeCheckManagerUpdate(pkg) + } + } finally { + if (force) mainThread { refreshing = false } } } } @@ -283,9 +308,9 @@ class HomeModel( paths.hasCachedSmaliPatches(info.data.patchesVersion) } - private suspend fun fetchRemoteData() { + private suspend fun fetchRemoteData(force: Boolean = false) { val release = try { - github.getLatestRelease().fold( + github.getLatestRelease(force).fold( success = { it }, fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest release", it); return }, ) @@ -299,7 +324,7 @@ class HomeModel( ?.browserDownloadUrl ?: return - github.getBuildInfo(dataJsonUrl).fold( + github.getBuildInfo(dataJsonUrl, force).fold( success = { remoteDataJson = it }, fail = { Log.w(BuildConfig.TAG, "Failed to fetch build info", it) }, ) diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt index 43d14e5..c7532d1 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/home/HomeScreen.kt @@ -3,6 +3,11 @@ package com.meowarex.rlmobile.ui.screens.home import android.os.Parcelable import androidx.activity.ComponentActivity import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.interaction.MutableInteractionSource @@ -14,6 +19,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -70,6 +76,8 @@ class HomeScreen : Screen, Parcelable { val activity = LocalContext.current as ComponentActivity val updater = koinViewModel(viewModelStoreOwner = activity) val managerUpdateAvailable = updater.targetVersion != null + val refreshing = model.refreshing + val refreshAngle = rememberRefreshAngle() LifecycleResumeEffect(Unit) { model.refresh(delay = true) @@ -86,7 +94,14 @@ class HomeScreen : Screen, Parcelable { icon = painterResource(R.drawable.ic_refresh), contentDescription = stringResource(R.string.navigation_refresh), subtle = true, - onClick = { model.refresh() }, + enabled = !refreshing, + onClick = { + model.refresh(force = true) + updater.checkForUpdates(force = true) + }, + modifier = Modifier.graphicsLayer { + rotationZ = if (refreshing) refreshAngle() else 0f + }, ) if (managerUpdateAvailable) { RadiantIconButton( @@ -364,3 +379,18 @@ private fun HeroContainer( Column(modifier = Modifier.fillMaxWidth(), content = content) } } + +/** + * Refresh Spinner thing + */ +@Composable +internal fun rememberRefreshAngle(): () -> Float { + val transition = rememberInfiniteTransition(label = "RefreshSpin") + val angle by transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing)), + label = "RefreshSpinAngle", + ) + return { angle } +} diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/widgets/updater/UpdaterViewModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/widgets/updater/UpdaterViewModel.kt index 7368982..e70c57d 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/widgets/updater/UpdaterViewModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/widgets/updater/UpdaterViewModel.kt @@ -37,17 +37,29 @@ class UpdaterViewModel( field = MutableStateFlow(null) val isWorking: StateFlow field = MutableStateFlow(false) + val isChecking: StateFlow + field = MutableStateFlow(false) private var targetApkUrl: String? = null init { - viewModelScope.launchIO { - try { - fetchInfo() - } catch (t: Throwable) { - Log.e(BuildConfig.TAG, "Failed to check for updates!", t) - mainThread { application.showToast(R.string.updater_check_fail) } - } + checkForUpdates() + } + + /** + * Re-runs the update check + */ + fun checkForUpdates(force: Boolean = false) = viewModelScope.launchIO { + if (!isChecking.compareAndSet(expect = false, update = true)) + return@launchIO + + try { + fetchInfo(force) + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to check for updates!", t) + mainThread { application.showToast(R.string.updater_check_fail) } + } finally { + isChecking.value = false } } @@ -142,14 +154,14 @@ class UpdaterViewModel( * then finds the latest release based on the largest semantic version extracted from the tag name (`v1.0.0`), * and populates the state to show to the user. */ - private suspend fun fetchInfo() { + private suspend fun fetchInfo(force: Boolean = false) { Log.d(BuildConfig.TAG, "Checking for updates...") val currentVersion = SemVer.parseOrNull(BuildConfig.VERSION_NAME) ?: throw Error("Failed to parse current app version") // Fetch releases from GitHub (60s local cache) - val releases = github.getManagerReleases().getOrThrow() + val releases = github.getManagerReleases(force).getOrThrow() // Find the latest release by parsed version val (version, release, apkUrl) = releases @@ -171,12 +183,14 @@ class UpdaterViewModel( return } - Log.d(BuildConfig.TAG, "Found an update! $targetVersion $targetApkUrl") + Log.d(BuildConfig.TAG, "Found an update! $version $apkUrl") + val newVersion = version.toString() mainThread { + val alreadyKnown = targetVersion == newVersion targetReleaseUrl = release.htmlUrl - targetVersion = version.toString() + targetVersion = newVersion targetApkUrl = apkUrl - showDialog = true + if (!alreadyKnown) showDialog = true } }