mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-08-27 06:27:46 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ede6ded9f | ||
|
|
c24add1d46 |
@@ -31,8 +31,8 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 67
|
versionCode = 68
|
||||||
versionName = "1.1.5"
|
versionName = "1.1.6"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
+16
-4
@@ -39,10 +39,14 @@ class RadiantLyricsGithubService(
|
|||||||
/**
|
/**
|
||||||
* Fetches manager self-update releases.
|
* Fetches manager self-update releases.
|
||||||
*/
|
*/
|
||||||
suspend fun getManagerReleases(): ApiResponse<List<GithubRelease>> =
|
suspend fun getManagerReleases(force: Boolean = false): ApiResponse<List<GithubRelease>> =
|
||||||
http.request {
|
http.request {
|
||||||
url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/releases")
|
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.
|
* Fetches a page of commits (paginated). Used by the Home screen's commit list.
|
||||||
*/
|
*/
|
||||||
suspend fun getCommits(page: Int, perPage: Int = 30): ApiResponse<List<com.meowarex.rlmobile.network.models.GithubCommit>> =
|
suspend fun getCommits(
|
||||||
|
page: Int,
|
||||||
|
perPage: Int = 30,
|
||||||
|
force: Boolean = false,
|
||||||
|
): ApiResponse<List<com.meowarex.rlmobile.network.models.GithubCommit>> =
|
||||||
http.request {
|
http.request {
|
||||||
url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/commits?per_page=$perPage&page=${page + 1}")
|
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 {
|
companion object {
|
||||||
|
|||||||
+4
-1
@@ -7,6 +7,8 @@ import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
|
|||||||
|
|
||||||
class CommitsPagingSource(
|
class CommitsPagingSource(
|
||||||
private val github: RadiantLyricsGithubService,
|
private val github: RadiantLyricsGithubService,
|
||||||
|
/** Bypasses the http response cache */
|
||||||
|
private val force: Boolean = false,
|
||||||
) : PagingSource<Int, GithubCommit>() {
|
) : PagingSource<Int, GithubCommit>() {
|
||||||
private val seenShas = mutableSetOf<String>()
|
private val seenShas = mutableSetOf<String>()
|
||||||
private val seenTitles = mutableSetOf<String>()
|
private val seenTitles = mutableSetOf<String>()
|
||||||
@@ -19,7 +21,8 @@ class CommitsPagingSource(
|
|||||||
|
|
||||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GithubCommit> {
|
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GithubCommit> {
|
||||||
val page = params.key ?: 0
|
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(
|
is ApiResponse.Success -> LoadResult.Page(
|
||||||
data = r.data.filter { commit ->
|
data = r.data.filter { commit ->
|
||||||
val title = commit.commit.message.lineSequence().first().trim()
|
val title = commit.commit.message.lineSequence().first().trim()
|
||||||
|
|||||||
+13
-1
@@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -57,6 +58,8 @@ class HomeScreen : Screen, Parcelable {
|
|||||||
val activity = LocalContext.current as ComponentActivity
|
val activity = LocalContext.current as ComponentActivity
|
||||||
val updater = koinViewModel<UpdaterViewModel>(viewModelStoreOwner = activity)
|
val updater = koinViewModel<UpdaterViewModel>(viewModelStoreOwner = activity)
|
||||||
val managerUpdateAvailable = updater.targetVersion != null
|
val managerUpdateAvailable = updater.targetVersion != null
|
||||||
|
val refreshing = model.refreshing
|
||||||
|
val refreshAngle = rememberRefreshAngle()
|
||||||
|
|
||||||
LifecycleResumeEffect(Unit) {
|
LifecycleResumeEffect(Unit) {
|
||||||
model.refresh(delay = true)
|
model.refresh(delay = true)
|
||||||
@@ -68,10 +71,19 @@ class HomeScreen : Screen, Parcelable {
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.navigation_home)) },
|
title = { Text(stringResource(R.string.navigation_home)) },
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { model.refresh() }) {
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
model.refresh(force = true)
|
||||||
|
updater.checkForUpdates(force = true)
|
||||||
|
},
|
||||||
|
enabled = !refreshing,
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(R.drawable.ic_refresh),
|
painterResource(R.drawable.ic_refresh),
|
||||||
contentDescription = stringResource(R.string.navigation_refresh),
|
contentDescription = stringResource(R.string.navigation_refresh),
|
||||||
|
modifier = Modifier.graphicsLayer {
|
||||||
|
rotationZ = if (refreshing) refreshAngle() else 0f
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (managerUpdateAvailable) {
|
if (managerUpdateAvailable) {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ import com.meowarex.rlmobile.ui.util.TidalVersion
|
|||||||
import com.meowarex.rlmobile.ui.widgets.managerupdate.VersionDelta
|
import com.meowarex.rlmobile.ui.widgets.managerupdate.VersionDelta
|
||||||
import com.meowarex.rlmobile.util.*
|
import com.meowarex.rlmobile.util.*
|
||||||
import kotlinx.coroutines.*
|
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.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -53,9 +56,22 @@ class HomeModel(
|
|||||||
var managerUpdateDeltas by mutableStateOf<List<VersionDelta>?>(null)
|
var managerUpdateDeltas by mutableStateOf<List<VersionDelta>?>(null)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val commits = Pager(PagingConfig(pageSize = 30)) {
|
/** Whether a user-initiated refresh is currently in flight */
|
||||||
CommitsPagingSource(github)
|
var refreshing by mutableStateOf(false)
|
||||||
}.flow.cachedIn(screenModelScope)
|
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 val refreshingLock = Mutex()
|
||||||
private var remoteDataJson: RLBuildInfo? = null
|
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 (refreshingLock.isLocked) return@launchIO
|
||||||
if (delay) {
|
if (delay) {
|
||||||
delay(250)
|
delay(250)
|
||||||
@@ -172,26 +191,32 @@ class HomeModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshingLock.withLock {
|
refreshingLock.withLock {
|
||||||
val pkg = fetchInstalled()
|
if (force) mainThread { refreshing = true }
|
||||||
// Skip the remote fetch entirely when offline
|
try {
|
||||||
val online = application.isOnline()
|
val pkg = fetchInstalled()
|
||||||
if (online) {
|
// Skip the remote fetch entirely when offline
|
||||||
val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() }
|
val online = application.isOnline()
|
||||||
remote.await()
|
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 install = pkg?.toInstallData()
|
||||||
val latest = remoteDataJson?.tidalVersionCode
|
val latest = remoteDataJson?.tidalVersionCode
|
||||||
val offlineRepatchReady = hasOfflineRepatchAssets()
|
val offlineRepatchReady = hasOfflineRepatchAssets()
|
||||||
|
|
||||||
mainThread {
|
mainThread {
|
||||||
state = HomeState.Loaded(
|
state = HomeState.Loaded(
|
||||||
install = install,
|
install = install,
|
||||||
latestTidalVersionCode = latest,
|
latestTidalVersionCode = latest,
|
||||||
offline = !online,
|
offline = !online,
|
||||||
offlineRepatchReady = offlineRepatchReady,
|
offlineRepatchReady = offlineRepatchReady,
|
||||||
)
|
)
|
||||||
maybeCheckManagerUpdate(pkg)
|
maybeCheckManagerUpdate(pkg)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (force) mainThread { refreshing = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,9 +308,9 @@ class HomeModel(
|
|||||||
paths.hasCachedSmaliPatches(info.data.patchesVersion)
|
paths.hasCachedSmaliPatches(info.data.patchesVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchRemoteData() {
|
private suspend fun fetchRemoteData(force: Boolean = false) {
|
||||||
val release = try {
|
val release = try {
|
||||||
github.getLatestRelease().fold(
|
github.getLatestRelease(force).fold(
|
||||||
success = { it },
|
success = { it },
|
||||||
fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest release", it); return },
|
fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest release", it); return },
|
||||||
)
|
)
|
||||||
@@ -299,7 +324,7 @@ class HomeModel(
|
|||||||
?.browserDownloadUrl
|
?.browserDownloadUrl
|
||||||
?: return
|
?: return
|
||||||
|
|
||||||
github.getBuildInfo(dataJsonUrl).fold(
|
github.getBuildInfo(dataJsonUrl, force).fold(
|
||||||
success = { remoteDataJson = it },
|
success = { remoteDataJson = it },
|
||||||
fail = { Log.w(BuildConfig.TAG, "Failed to fetch build info", it) },
|
fail = { Log.w(BuildConfig.TAG, "Failed to fetch build info", it) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ package com.meowarex.rlmobile.ui.screens.home
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.Image
|
||||||
import androidx.compose.foundation.basicMarquee
|
import androidx.compose.foundation.basicMarquee
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -14,6 +19,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -70,6 +76,8 @@ class HomeScreen : Screen, Parcelable {
|
|||||||
val activity = LocalContext.current as ComponentActivity
|
val activity = LocalContext.current as ComponentActivity
|
||||||
val updater = koinViewModel<UpdaterViewModel>(viewModelStoreOwner = activity)
|
val updater = koinViewModel<UpdaterViewModel>(viewModelStoreOwner = activity)
|
||||||
val managerUpdateAvailable = updater.targetVersion != null
|
val managerUpdateAvailable = updater.targetVersion != null
|
||||||
|
val refreshing = model.refreshing
|
||||||
|
val refreshAngle = rememberRefreshAngle()
|
||||||
|
|
||||||
LifecycleResumeEffect(Unit) {
|
LifecycleResumeEffect(Unit) {
|
||||||
model.refresh(delay = true)
|
model.refresh(delay = true)
|
||||||
@@ -86,7 +94,14 @@ class HomeScreen : Screen, Parcelable {
|
|||||||
icon = painterResource(R.drawable.ic_refresh),
|
icon = painterResource(R.drawable.ic_refresh),
|
||||||
contentDescription = stringResource(R.string.navigation_refresh),
|
contentDescription = stringResource(R.string.navigation_refresh),
|
||||||
subtle = true,
|
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) {
|
if (managerUpdateAvailable) {
|
||||||
RadiantIconButton(
|
RadiantIconButton(
|
||||||
@@ -364,3 +379,18 @@ private fun HeroContainer(
|
|||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
+26
-12
@@ -37,17 +37,29 @@ class UpdaterViewModel(
|
|||||||
field = MutableStateFlow(null)
|
field = MutableStateFlow(null)
|
||||||
val isWorking: StateFlow<Boolean>
|
val isWorking: StateFlow<Boolean>
|
||||||
field = MutableStateFlow(false)
|
field = MutableStateFlow(false)
|
||||||
|
val isChecking: StateFlow<Boolean>
|
||||||
|
field = MutableStateFlow(false)
|
||||||
|
|
||||||
private var targetApkUrl: String? = null
|
private var targetApkUrl: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
checkForUpdates()
|
||||||
try {
|
}
|
||||||
fetchInfo()
|
|
||||||
} catch (t: Throwable) {
|
/**
|
||||||
Log.e(BuildConfig.TAG, "Failed to check for updates!", t)
|
* Re-runs the update check
|
||||||
mainThread { application.showToast(R.string.updater_check_fail) }
|
*/
|
||||||
}
|
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`),
|
* 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.
|
* 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...")
|
Log.d(BuildConfig.TAG, "Checking for updates...")
|
||||||
|
|
||||||
val currentVersion = SemVer.parseOrNull(BuildConfig.VERSION_NAME)
|
val currentVersion = SemVer.parseOrNull(BuildConfig.VERSION_NAME)
|
||||||
?: throw Error("Failed to parse current app version")
|
?: throw Error("Failed to parse current app version")
|
||||||
|
|
||||||
// Fetch releases from GitHub (60s local cache)
|
// 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
|
// Find the latest release by parsed version
|
||||||
val (version, release, apkUrl) = releases
|
val (version, release, apkUrl) = releases
|
||||||
@@ -171,12 +183,14 @@ class UpdaterViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(BuildConfig.TAG, "Found an update! $targetVersion $targetApkUrl")
|
Log.d(BuildConfig.TAG, "Found an update! $version $apkUrl")
|
||||||
|
val newVersion = version.toString()
|
||||||
mainThread {
|
mainThread {
|
||||||
|
val alreadyKnown = targetVersion == newVersion
|
||||||
targetReleaseUrl = release.htmlUrl
|
targetReleaseUrl = release.htmlUrl
|
||||||
targetVersion = version.toString()
|
targetVersion = newVersion
|
||||||
targetApkUrl = apkUrl
|
targetApkUrl = apkUrl
|
||||||
showDialog = true
|
if (!alreadyKnown) showDialog = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user