From 324a6eb6c8e24b56c9c74fee408441bcf5625b0b Mon Sep 17 00:00:00 2001 From: meowarex Date: Sun, 24 May 2026 21:49:25 +1000 Subject: [PATCH] Overhaul Developer Options <3 --- .github/workflows/release.yml | 10 +- .../screens/ComponentOptionsScreenPreview.kt | 6 + .../componentopts/ComponentOptionsModel.kt | 168 +++++++++++++ .../componentopts/ComponentOptionsScreen.kt | 232 ++++++++++++++++++ .../rlmobile/ui/screens/home/HomeScreen.kt | 20 +- .../res/drawable/ic_launcher_foreground.xml | 2 +- Manager/app/src/main/res/values/strings.xml | 7 +- 7 files changed, 422 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8ad9a2..1ca9d7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,8 @@ jobs: run: | mv ./dist/app-release.apk ./dist/rl-manager.apk cp patches/data.json ./dist/data.json + # Point each release's data.json at its own assets so historical releases stay self-contained. + sed -i "s|releases/download/latest/|releases/download/v${{ needs.Version.outputs.version }}/|g" ./dist/data.json cd patches && zip -r ../dist/patches.zip . -x "data.json" && cd .. tidal_src=$(find tidal-apk -maxdepth 1 \( -name "*.apk" -o -name "*.apkm" \) | head -1) @@ -67,10 +69,10 @@ jobs: fi - name: Publish release - uses: marvinpinto/action-automatic-releases@latest + uses: softprops/action-gh-release@v2 with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - automatic_release_tag: latest + tag_name: v${{ needs.Version.outputs.version }} + name: v${{ needs.Version.outputs.version }} prerelease: false - title: v${{ needs.Version.outputs.version }} + make_latest: "true" files: ./dist/** diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/ComponentOptionsScreenPreview.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/ComponentOptionsScreenPreview.kt index 4aec8d9..f93ef9e 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/ComponentOptionsScreenPreview.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/previews/screens/ComponentOptionsScreenPreview.kt @@ -30,6 +30,12 @@ private fun ComponentOptionsScreenPreview( selected = parameters.selected, onSelectComponent = {}, onDeleteComponent = {}, + onImportFromUri = {}, + releasesExpanded = false, + releasesState = com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsModel.ReleasesState.Idle, + onToggleReleases = {}, + onImportRelease = {}, + importingReleaseTag = null, onBackPressed = {}, ) } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsModel.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsModel.kt index 616cafe..c76e664 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsModel.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsModel.kt @@ -1,26 +1,44 @@ package com.meowarex.rlmobile.ui.screens.componentopts import android.app.Application +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log import androidx.compose.runtime.* import cafe.adriel.voyager.core.model.screenModelScope +import com.meowarex.rlmobile.BuildConfig import com.meowarex.rlmobile.R import com.meowarex.rlmobile.manager.PathManager +import com.meowarex.rlmobile.manager.download.KtorDownloadManager +import com.meowarex.rlmobile.network.models.GithubRelease +import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService import com.meowarex.rlmobile.network.utils.SemVer +import com.meowarex.rlmobile.network.utils.fold import com.meowarex.rlmobile.ui.util.ScreenModelWithResult import com.meowarex.rlmobile.ui.util.ScreenResultKey import com.meowarex.rlmobile.util.* import kotlinx.coroutines.launch +import java.io.File import kotlin.time.Instant class ComponentOptionsModel( screenResultKey: ScreenResultKey, private val paths: PathManager, private val context: Application, + private val github: RadiantLyricsGithubService, + private val downloader: KtorDownloadManager, ) : ScreenModelWithResult(screenResultKey) { val components = mutableStateListOf() var selected by mutableStateOf(null) private set + var releasesExpanded by mutableStateOf(false) + private set + var releasesState by mutableStateOf(ReleasesState.Idle) + private set + var importingReleaseTag by mutableStateOf(null) + private set + fun selectComponent(component: PatchComponent?) { selected = component } @@ -64,7 +82,157 @@ class ComponentOptionsModel( } } + fun importFromUri(uri: Uri, type: PatchComponent.Type) = screenModelScope.launchIO { + try { + val targetDir = when (type) { + PatchComponent.Type.TidalApk -> paths.customTidalApksDir + PatchComponent.Type.Patches -> paths.customPatchesDir + } + val ext = when (type) { + PatchComponent.Type.TidalApk -> "apk" + PatchComponent.Type.Patches -> "zip" + } + targetDir.mkdirs() + + val tempFile = targetDir.resolve("import-${System.currentTimeMillis()}.tmp") + context.contentResolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> input.copyTo(output) } + } ?: throw IllegalStateException("Could not open input stream for $uri") + + val sourceDisplayName = queryDisplayName(uri) + val version = when (type) { + PatchComponent.Type.TidalApk -> readApkVersion(tempFile) + ?: extractVersionFromName(sourceDisplayName) + ?: FALLBACK_VERSION + PatchComponent.Type.Patches -> extractVersionFromName(sourceDisplayName) + ?: FALLBACK_VERSION + } + + val finalName = "${System.currentTimeMillis()}_$version.$ext" + val finalFile = targetDir.resolve(finalName) + if (!tempFile.renameTo(finalFile)) { + tempFile.copyTo(finalFile, overwrite = true) + tempFile.delete() + } + + refreshComponents(type) + mainThread { context.showToast(R.string.intent_import_component_success, finalName) } + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to import custom component from $uri", t) + mainThread { context.showToast(R.string.intent_import_component_failure) } + } + } + + private fun queryDisplayName(uri: Uri): String? = try { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + } catch (_: Throwable) { + null + } + + private fun readApkVersion(apkFile: File): String? = try { + @Suppress("DEPRECATION") + context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + ?.versionName + ?.let { SemVer.parseOrNull(it) } + ?.toString() + } catch (_: Throwable) { + null + } + + private fun extractVersionFromName(name: String?): String? { + if (name == null) return null + return """(\d+\.\d+\.\d+)""".toRegex().find(name)?.value + } + + fun toggleReleasesExpanded(type: PatchComponent.Type) { + releasesExpanded = !releasesExpanded + if (releasesExpanded && releasesState is ReleasesState.Idle) { + loadReleases(type) + } + } + + private fun loadReleases(type: PatchComponent.Type) = screenModelScope.launchIO { + releasesState = ReleasesState.Loading + github.getManagerReleases().fold( + success = { all -> + val assetName = assetNameFor(type) + val filtered = all.filter { release -> + release.assets.any { it.name == assetName } + } + releasesState = ReleasesState.Loaded(filtered) + }, + fail = { + Log.w(BuildConfig.TAG, "Failed to load GitHub releases", it) + releasesState = ReleasesState.Failed + }, + ) + } + + fun importFromRelease(release: GithubRelease, type: PatchComponent.Type) = screenModelScope.launchIO { + val assetName = assetNameFor(type) + val asset = release.assets.find { it.name == assetName } ?: run { + mainThread { context.showToast(R.string.intent_import_component_failure) } + return@launchIO + } + + val targetDir = when (type) { + PatchComponent.Type.TidalApk -> paths.customTidalApksDir + PatchComponent.Type.Patches -> paths.customPatchesDir + } + targetDir.mkdirs() + + importingReleaseTag = release.tagName + try { + val tempFile = targetDir.resolve("release-${System.currentTimeMillis()}.tmp") + val result = downloader.download(asset.browserDownloadUrl, tempFile) + if (result !is com.meowarex.rlmobile.manager.download.IDownloadManager.Result.Success) { + tempFile.delete() + mainThread { context.showToast(R.string.intent_import_component_failure) } + return@launchIO + } + + val ext = when (type) { + PatchComponent.Type.TidalApk -> "apk" + PatchComponent.Type.Patches -> "zip" + } + val version = SemVer.parseOrNull(release.tagName.removePrefix("v"))?.toString() + ?: FALLBACK_VERSION + val finalName = "${System.currentTimeMillis()}_$version.$ext" + val finalFile = targetDir.resolve(finalName) + if (!tempFile.renameTo(finalFile)) { + tempFile.copyTo(finalFile, overwrite = true) + tempFile.delete() + } + refreshComponents(type) + mainThread { context.showToast(R.string.intent_import_component_success, finalName) } + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to import release ${release.tagName}", t) + mainThread { context.showToast(R.string.intent_import_component_failure) } + } finally { + importingReleaseTag = null + } + } + + private fun assetNameFor(type: PatchComponent.Type) = when (type) { + PatchComponent.Type.TidalApk -> "tidal-stock.apk" + PatchComponent.Type.Patches -> "patches.zip" + } + override fun onDispose() { screenModelScope.launch { setResult(selected) } } + + sealed interface ReleasesState { + data object Idle : ReleasesState + data object Loading : ReleasesState + data class Loaded(val releases: List) : ReleasesState + data object Failed : ReleasesState + } + + companion object { + private const val FALLBACK_VERSION = "0.0.0" + } } diff --git a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsScreen.kt b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsScreen.kt index f67d0a3..e7714ea 100644 --- a/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsScreen.kt +++ b/Manager/app/src/main/kotlin/com/meowarex/rlmobile/ui/screens/componentopts/ComponentOptionsScreen.kt @@ -1,13 +1,20 @@ package com.meowarex.rlmobile.ui.screens.componentopts +import android.net.Uri import android.os.Parcelable +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import cafe.adriel.voyager.koin.koinScreenModel @@ -61,6 +68,12 @@ class ComponentOptionsScreen( selected = model.selected, onSelectComponent = model::selectComponent, onDeleteComponent = model::deleteComponent, + onImportFromUri = { uri -> model.importFromUri(uri, componentType) }, + releasesExpanded = model.releasesExpanded, + releasesState = model.releasesState, + onToggleReleases = { model.toggleReleasesExpanded(componentType) }, + onImportRelease = { release -> model.importFromRelease(release, componentType) }, + importingReleaseTag = model.importingReleaseTag, onBackPressed = { navigator.back(null) }, ) } @@ -73,8 +86,22 @@ fun ComponentOptionsScreenContent( selected: PatchComponent?, onSelectComponent: (PatchComponent?) -> Unit, onDeleteComponent: (PatchComponent) -> Unit, + onImportFromUri: (Uri) -> Unit, + releasesExpanded: Boolean, + releasesState: ComponentOptionsModel.ReleasesState, + onToggleReleases: () -> Unit, + onImportRelease: (com.meowarex.rlmobile.network.models.GithubRelease) -> Unit, + importingReleaseTag: String?, onBackPressed: () -> Unit, ) { + val mime = when (componentType) { + PatchComponent.Type.TidalApk -> "application/vnd.android.package-archive" + PatchComponent.Type.Patches -> "application/zip" + } + val filePicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> uri?.let(onImportFromUri) } + Scaffold( topBar = { ComponentOptionsAppBar(componentType = componentType) }, ) { paddingValues -> @@ -98,6 +125,16 @@ fun ComponentOptionsScreenContent( } } + item(key = "RELEASES_ACCORDION") { + ReleasesAccordion( + expanded = releasesExpanded, + state = releasesState, + importingTag = importingReleaseTag, + onToggle = onToggleReleases, + onImport = onImportRelease, + ) + } + items( items = components, contentType = { "COMPONENT" }, @@ -112,6 +149,10 @@ fun ComponentOptionsScreenContent( ) } + item(key = "BROWSE") { + BrowseImportCard(onClick = { filePicker.launch(arrayOf(mime)) }) + } + item("EXIT_BTN") { Row( horizontalArrangement = Arrangement.End, @@ -129,3 +170,194 @@ fun ComponentOptionsScreenContent( } } } + +@Composable +private fun ReleasesAccordion( + expanded: Boolean, + state: ComponentOptionsModel.ReleasesState, + importingTag: String?, + onToggle: () -> Unit, + onImport: (com.meowarex.rlmobile.network.models.GithubRelease) -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_account_github_white_24dp), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.componentopts_releases_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.componentopts_releases_desc), + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current.copy(alpha = 0.65f), + ) + } + Icon( + painter = painterResource( + if (expanded) R.drawable.ic_arrow_up_small else R.drawable.ic_arrow_down_small + ), + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.7f), + ) + } + + if (expanded) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + ) + ReleasesContent( + state = state, + importingTag = importingTag, + onImport = onImport, + ) + } + } + } +} + +@Composable +private fun ReleasesContent( + state: ComponentOptionsModel.ReleasesState, + importingTag: String?, + onImport: (com.meowarex.rlmobile.network.models.GithubRelease) -> Unit, +) { + when (state) { + is ComponentOptionsModel.ReleasesState.Idle, + ComponentOptionsModel.ReleasesState.Loading -> Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + ) { CircularProgressIndicator(modifier = Modifier.size(24.dp)) } + + ComponentOptionsModel.ReleasesState.Failed -> Text( + text = stringResource(R.string.network_load_fail), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + ) + + is ComponentOptionsModel.ReleasesState.Loaded -> { + if (state.releases.isEmpty()) { + Text( + text = stringResource(R.string.componentopts_releases_empty), + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current.copy(alpha = 0.65f), + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + ) + } else { + Column { + state.releases.forEachIndexed { index, release -> + ReleaseRow( + release = release, + importing = importingTag == release.tagName, + anyImporting = importingTag != null, + onImport = { onImport(release) }, + ) + if (index != state.releases.lastIndex) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), + ) + } + } + } + } + } + } +} + +@Composable +private fun ReleaseRow( + release: com.meowarex.rlmobile.network.models.GithubRelease, + importing: Boolean, + anyImporting: Boolean, + onImport: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = release.tagName, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = release.name ?: release.tagName, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.55f), + ) + } + FilledTonalButton( + onClick = onImport, + enabled = !anyImporting, + ) { + if (importing) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp), + ) + } else { + Text(stringResource(R.string.action_install)) + } + } + } +} + +@Composable +private fun BrowseImportCard(onClick: () -> Unit) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.componentopts_browse_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.componentopts_browse_desc), + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current.copy(alpha = 0.65f), + ) + } + } + } +} 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 74448ed..20192e0 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 @@ -29,6 +29,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import com.meowarex.rlmobile.R import com.meowarex.rlmobile.ui.components.SegmentedButton +import com.meowarex.rlmobile.ui.components.Tag import com.meowarex.rlmobile.ui.screens.about.AboutScreen import com.meowarex.rlmobile.ui.screens.home.components.CommitList import com.meowarex.rlmobile.ui.screens.logs.LogsListScreen @@ -194,8 +195,8 @@ private fun ColumnScope.HomeContent( val tidalBehind = install != null && install.tidalUpToDate == false AnimatedVisibility(visible = patchesBehind || tidalBehind) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (patchesBehind) UpdateTag(text = "New Patches!") - if (tidalBehind) UpdateTag(text = "TIDAL Update!") + if (patchesBehind) Tag(text = "New Patches!") + if (tidalBehind) Tag(text = "TIDAL Update!") } } @@ -240,20 +241,5 @@ private fun ColumnScope.HomeContent( ElevatedCard(modifier = Modifier.fillMaxSize()) { CommitList(commits = commits.collectAsLazyPagingItems()) - '} -} - -@Composable -private fun UpdateTag(text: String) { - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - shape = RoundedCornerShape(6.dp), - ) { - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - ) } } diff --git a/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml b/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml index 20aadb8..cea9941 100644 --- a/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/Manager/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -21,4 +21,4 @@ android:pathData="M5.209,1.737C5.313,1.473 5.687,1.473 5.791,1.737L6.157,2.667C6.189,2.747 6.253,2.811 6.333,2.843L7.263,3.209C7.527,3.313 7.527,3.687 7.263,3.791L6.333,4.157C6.253,4.189 6.189,4.253 6.157,4.333L5.791,5.263C5.687,5.527 5.313,5.527 5.209,5.263L4.843,4.333C4.811,4.253 4.747,4.189 4.667,4.157L3.737,3.791C3.473,3.687 3.473,3.313 3.737,3.209L4.667,2.843C4.747,2.811 4.811,2.747 4.843,2.667L5.209,1.737Z" /> -aight + diff --git a/Manager/app/src/main/res/values/strings.xml b/Manager/app/src/main/res/values/strings.xml index b0b6d19..e8e0d5b 100644 --- a/Manager/app/src/main/res/values/strings.xml +++ b/Manager/app/src/main/res/values/strings.xml @@ -71,7 +71,7 @@ Notifications Background Battery Ensures the installation process does not get automatically cancelled if the app is minimized. - Used only to show the download progress if Radiant Lyrics Manager is minimized during installation. + Used to notify you when updates are available! Failed to obtain root permissions Failed to obtain Shizuku permissions @@ -278,6 +278,11 @@ Select a custom build that was imported by Manager. Latest Successfully deleted component! + Browse files… + Import a file from your device + GitHub Releases + Pick a historical release from the repo + No releases with this component found Log Installation Info