Merge pull request #11 from meowarex/dev

Rewrite Manager UI <3
This commit is contained in:
2026-05-21 00:42:40 +10:00
committed by GitHub
47 changed files with 687 additions and 1859 deletions
+2 -1
View File
@@ -57,7 +57,8 @@ jobs:
fi fi
if [[ "$tidal_src" == *.apkm ]]; then if [[ "$tidal_src" == *.apkm ]]; then
echo "Merging splits from $tidal_src via APKEditor" echo "Merging splits from $tidal_src via APKEditor"
curl -sLo /tmp/APKEditor.jar https://github.com/REAndroid/APKEditor/releases/download/V1.4.3/APKEditor-1.4.3.jar curl --fail --location --retry 3 --retry-delay 2 -sSo /tmp/APKEditor.jar \
https://github.com/REAndroid/APKEditor/releases/download/V1.4.3/APKEditor-1.4.3.jar
java -jar /tmp/APKEditor.jar m -i "$tidal_src" -o ./dist/tidal-stock.apk java -jar /tmp/APKEditor.jar m -i "$tidal_src" -o ./dist/tidal-stock.apk
echo "Merged tidal-stock.apk:" echo "Merged tidal-stock.apk:"
ls -la ./dist/tidal-stock.apk ls -la ./dist/tidal-stock.apk
+3
View File
@@ -191,6 +191,9 @@ dependencies {
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.runtime.tracing) debugImplementation(libs.compose.runtime.tracing)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.work.runtime)
implementation(libs.kotlinx.immutable) implementation(libs.kotlinx.immutable)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
@@ -26,7 +26,6 @@ import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreen import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreen
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen
import com.meowarex.rlmobile.ui.theme.ManagerTheme import com.meowarex.rlmobile.ui.theme.ManagerTheme
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterDialog import com.meowarex.rlmobile.ui.widgets.updater.UpdaterDialog
import com.meowarex.rlmobile.util.* import com.meowarex.rlmobile.util.*
@@ -123,16 +122,6 @@ class MainActivity : ComponentActivity() {
navigator.push(handleReinstall(packageName)) navigator.push(handleReinstall(packageName))
} }
INTENT_OPEN_PLUGINS -> {
// TODO: per-install plugins screen
// val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: run {
// Log.w(BuildConfig.TAG, "Missing $EXTRA_PACKAGE_NAME extra for intent $INTENT_REINSTALL")
// return@launchBlock
// }
navigator.push(PluginsScreen())
}
INTENT_IMPORT_COMPONENT -> { INTENT_IMPORT_COMPONENT -> {
val path = intent.getStringExtra(EXTRA_FILE_PATH) ?: run { val path = intent.getStringExtra(EXTRA_FILE_PATH) ?: run {
Log.w(BuildConfig.TAG, "Missing $EXTRA_FILE_PATH extra for intent $INTENT_IMPORT_COMPONENT") Log.w(BuildConfig.TAG, "Missing $EXTRA_FILE_PATH extra for intent $INTENT_IMPORT_COMPONENT")
@@ -200,7 +189,6 @@ class MainActivity : ComponentActivity() {
companion object { companion object {
const val INTENT_REINSTALL = "com.meowarex.rlmobile.REINSTALL" const val INTENT_REINSTALL = "com.meowarex.rlmobile.REINSTALL"
const val INTENT_OPEN_PLUGINS = "com.meowarex.rlmobile.OPEN_PLUGINS"
const val INTENT_IMPORT_COMPONENT = "com.meowarex.rlmobile.IMPORT_COMPONENT" const val INTENT_IMPORT_COMPONENT = "com.meowarex.rlmobile.IMPORT_COMPONENT"
const val EXTRA_PACKAGE_NAME = "rlmobile.packageName" const val EXTRA_PACKAGE_NAME = "rlmobile.packageName"
@@ -22,10 +22,11 @@ import com.meowarex.rlmobile.ui.screens.logs.LogsListScreenModel
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenModel import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenModel
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsModel import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsModel
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel
import com.meowarex.rlmobile.ui.screens.plugins.PluginsModel
import com.meowarex.rlmobile.ui.screens.settings.SettingsModel import com.meowarex.rlmobile.ui.screens.settings.SettingsModel
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterViewModel import com.meowarex.rlmobile.ui.widgets.updater.UpdaterViewModel
import com.meowarex.rlmobile.updatechecker.UpdateCheckWorker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.module.dsl.* import org.koin.core.module.dsl.*
@@ -57,7 +58,6 @@ class ManagerApplication : Application() {
// UI Models // UI Models
modules(module { modules(module {
factoryOf(::HomeModel) factoryOf(::HomeModel)
factoryOf(::PluginsModel)
factoryOf(::AboutModel) factoryOf(::AboutModel)
factoryOf(::PatchingScreenModel) factoryOf(::PatchingScreenModel)
factoryOf(::SettingsModel) factoryOf(::SettingsModel)
@@ -101,5 +101,13 @@ class ManagerApplication : Application() {
.fetcherCoroutineContext(Dispatchers.IO.limitedParallelism(5)) .fetcherCoroutineContext(Dispatchers.IO.limitedParallelism(5))
.build() .build()
} }
// Schedule periodic update check only when the user has opted in,
// so the disabled state survives app restarts instead of being re-enqueued.
if (get<PreferencesManager>().autoUpdateCheck) {
UpdateCheckWorker.schedule(this)
} else {
UpdateCheckWorker.cancel(this)
}
} }
} }
@@ -14,4 +14,5 @@ class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager
var keepPatchedApks by booleanPreference("keep_patched_apks", false) var keepPatchedApks by booleanPreference("keep_patched_apks", false)
var showNetworkWarning by booleanPreference("show_network_warning", true) var showNetworkWarning by booleanPreference("show_network_warning", true)
var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true) var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true)
var autoUpdateCheck by booleanPreference("auto_update_check", true)
} }
@@ -0,0 +1,35 @@
package com.meowarex.rlmobile.network.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GithubCommit(
val sha: String,
@SerialName("html_url")
val htmlUrl: String,
val commit: CommitDetails,
val author: Author? = null,
) {
@Serializable
data class CommitDetails(
val message: String,
val author: AuthorMeta,
)
@Serializable
data class AuthorMeta(
val name: String,
val email: String,
val date: String,
)
@Serializable
data class Author(
val login: String,
@SerialName("avatar_url")
val avatarUrl: String,
@SerialName("html_url")
val htmlUrl: String,
)
}
@@ -0,0 +1,13 @@
package com.meowarex.rlmobile.network.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GithubContributor(
val login: String,
@SerialName("avatar_url")
val avatarUrl: String,
val contributions: Int,
val type: String? = null,
)
@@ -45,6 +45,33 @@ class RadiantLyricsGithubService(
header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60") header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60")
} }
/**
* Fetches the contributors list from GitHub for the repo.
*/
suspend fun getContributors(): ApiResponse<List<com.meowarex.rlmobile.network.models.GithubContributor>> =
http.request {
url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/contributors?per_page=100")
header(HttpHeaders.CacheControl, "public, max-age=600, s-maxage=600")
}
/**
* Fetches the latest commit on the default branch.
*/
suspend fun getLatestCommit(): ApiResponse<com.meowarex.rlmobile.network.models.GithubCommit> =
http.request {
url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/commits/HEAD")
header(HttpHeaders.CacheControl, "public, max-age=120, s-maxage=120")
}
/**
* 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>> =
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")
}
companion object { companion object {
const val REPO_OWNER = BuildConfig.PATCHES_REPO_OWNER const val REPO_OWNER = BuildConfig.PATCHES_REPO_OWNER
const val REPO_NAME = BuildConfig.PATCHES_REPO_NAME const val REPO_NAME = BuildConfig.PATCHES_REPO_NAME
@@ -0,0 +1,30 @@
package com.meowarex.rlmobile.network.utils
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.meowarex.rlmobile.network.models.GithubCommit
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
class CommitsPagingSource(
private val github: RadiantLyricsGithubService,
) : PagingSource<Int, GithubCommit>() {
override fun getRefreshKey(state: PagingState<Int, GithubCommit>): Int? =
state.anchorPosition?.let {
val page = state.closestPageToPosition(it) ?: return null
page.prevKey?.plus(1) ?: page.nextKey?.minus(1)
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GithubCommit> {
val page = params.key ?: 0
return when (val r = github.getCommits(page)) {
is ApiResponse.Success -> LoadResult.Page(
data = r.data,
prevKey = if (page > 0) page - 1 else null,
nextKey = if (r.data.isNotEmpty()) page + 1 else null,
)
is ApiResponse.Failure -> LoadResult.Error(r.error)
is ApiResponse.Error -> LoadResult.Error(r.error)
}
}
}
@@ -51,8 +51,16 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent {
if (patchFile.endsWith(".smali") && patchFile.startsWith("extension/")) { if (patchFile.endsWith(".smali") && patchFile.startsWith("extension/")) {
val relative = patchFile.removePrefix("extension/") val relative = patchFile.removePrefix("extension/")
val out = smaliDir.resolve(relative) val out = smaliDir.resolve(relative)
out.parentFile?.mkdirs() // Guard against zip-slip: a crafted entry could otherwise escape smaliDir.
out.writeBytes(zip.openEntry(patchFile)!!.read()) val baseCanonical = smaliDir.canonicalPath + File.separator
val outCanonical = out.canonicalPath
if (!outCanonical.startsWith(baseCanonical)) {
throw SecurityException("Zip entry escapes target directory: $patchFile")
}
val entry = zip.openEntry(patchFile)
?: throw FileNotFoundException("Missing zip entry: $patchFile")
out.canonicalFile.parentFile?.mkdirs()
out.writeBytes(entry.read())
container.log("Extracted extension smali: $relative") container.log("Extracted extension smali: $relative")
continue continue
} }
@@ -35,7 +35,8 @@ object ManifestPatcher {
} }
} }
}) })
val origPkg = originalPackage ?: "com.aspiro.tidal" val origPkg = originalPackage
?: throw IllegalStateException("Manifest has no package attribute; refusing to rewrite authorities/permissions without a known originalPackage")
val reader = AxmlReader(manifestBytes) val reader = AxmlReader(manifestBytes)
val writer = AxmlWriter() val writer = AxmlWriter()
@@ -104,7 +105,7 @@ object ManifestPatcher {
super.attr( super.attr(
ns, name, resourceId, type, ns, name, resourceId, type,
when (name) { when (name) {
"name" -> (value as String).replace(origPkg, packageName) "name" -> (value as? String)?.replace(origPkg, packageName) ?: value
else -> value else -> value
} }
) )
@@ -155,7 +156,7 @@ object ManifestPatcher {
super.attr( super.attr(
ns, name, resourceId, type, ns, name, resourceId, type,
if (name == "authorities") { if (name == "authorities") {
(value as String).replace(origPkg, packageName) (value as? String)?.replace(origPkg, packageName) ?: value
} else { } else {
value value
} }
@@ -1,20 +0,0 @@
package com.meowarex.rlmobile.ui.previews.dialogs
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.plugins.components.dialogs.UninstallPluginDialog
import com.meowarex.rlmobile.ui.theme.ManagerTheme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun UninstallPluginDialogPreview() {
ManagerTheme {
UninstallPluginDialog(
pluginName = "FakeNitro",
onConfirm = {},
onDismiss = {},
)
}
}
@@ -1,22 +0,0 @@
package com.meowarex.rlmobile.ui.previews.screens.home
import android.content.res.Configuration
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.home.HomeScreenFailureContent
import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar
import com.meowarex.rlmobile.ui.theme.ManagerTheme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun HomeScreenFailedPreview() {
ManagerTheme {
Scaffold(
topBar = { HomeAppBar() },
) { padding ->
HomeScreenFailureContent(padding = padding)
}
}
}
@@ -1,77 +0,0 @@
package com.meowarex.rlmobile.ui.previews.screens.home
import android.content.res.Configuration
import android.graphics.BitmapFactory
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.tooling.preview.*
import com.meowarex.rlmobile.ui.screens.home.*
import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import com.meowarex.rlmobile.ui.util.TidalVersion
import kotlinx.collections.immutable.persistentListOf
import kotlin.io.encoding.Base64
// This preview has animations that cannot be properly viewed from an IDE preview
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun HomeScreenLoadedPreview(
@PreviewParameter(HomeScreenParametersProvider::class)
state: InstallsState.Fetched,
) {
ManagerTheme {
Scaffold(
topBar = { HomeAppBar() },
) { padding ->
HomeScreenLoadedContent(
state = state,
padding = padding,
onClickInstall = {},
onUpdate = {},
onOpenApp = {},
onOpenAppInfo = {},
onOpenPlugins = {},
)
}
}
}
private class HomeScreenParametersProvider : PreviewParameterProvider<InstallsState.Fetched> {
private val stableVersion = TidalVersion.Existing(TidalVersion.Type.STABLE, "126.21", 126021)
private val radiantIconBytes = Base64.decode(
"/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAKBueIx4ZKCMgoy0qqC+8P//8Nzc8P//////////////////////////////////////////////////////////2wBDAaq0tPDS8P//////////////////////////////////////////////////////////////////////////////wAARCAC9AL0DASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAIDAf/EACIQAQEAAgEFAAIDAAAAAAAAAAABAhEhAxIxQVETMiJhcf/EABcBAQEBAQAAAAAAAAAAAAAAAAABAgP/xAAZEQEBAQEBAQAAAAAAAAAAAAAAARECEjH/2gAMAwEAAhEDEQA/AMsce7/Gkkngk1NOoxboAIAAAAAAAAAAAAAAAAAAm4ys7NXVbJyx2LKoAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZLfRcbJug4AABJugDtxs9OAAAAAAAAAAAAAA7jN0HccN81cxk9OityBZuaAVP459OyKBMifxwxw1dqAyBrYCpuEvhFmq1TnNwZsZgIyAAAAAAAANcJqM8ZutRrkAVoAAAAAAAAABnnNVLTObjNGKACAAAAAAL6c9rcxmsY6rcALdTYrlyk8kzlZeRGPTYThdzSlbC2TyMsruiW4vvimK+nedIkqwFaGN4rZnnP5DPSQEZAAAAAJ5BtPACug5n+tdAYi8sL6cmFqOeO9P2sk1NCtwYtk5Yb5gljNWH7Odt+NMce2IkjoCtiOp6WjqeIJfiAEYAAAACeYANgllnAroA5lbJxAdEzOe+FblDQAAAAcuUntyZW3iCaoAUT1PEUjOy8CX4gBGAAAAAACWzw0mf1mBLjYZS2eFTqfVa9O9TWv7Zu27u3EZtd7r9O/L64Brvdfrm79AHZ55asVY56mhZWjlykRc7Ui3pWWdqQGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHMbuOsZbLw0xy2LYoAQAAAAAAAAAAAAAAAAAARnlzqGWdnEQNSP/2Q=="
)
private val radiantIcon = BitmapFactory
.decodeByteArray(radiantIconBytes, 0, radiantIconBytes.size)
.asImageBitmap()
.let(::BitmapPainter)
override val values = sequenceOf(
InstallsState.Fetched(
persistentListOf(
InstallData(
name = "Radiant Lyrics",
packageName = "com.radiantLyrics",
version = stableVersion,
icon = radiantIcon,
isUpToDate = true,
)
)
),
InstallsState.Fetched(
persistentListOf(
InstallData(
name = "Tidal",
packageName = "com.tidal",
version = stableVersion,
icon = radiantIcon,
isUpToDate = false,
)
)
),
)
}
@@ -1,24 +0,0 @@
package com.meowarex.rlmobile.ui.previews.screens.home
import android.content.res.Configuration
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.home.HomeScreenLoadingContent
import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar
import com.meowarex.rlmobile.ui.theme.ManagerTheme
// This preview cannot be properly viewed from an IDE preview
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun HomeScreenLoadingPreview() {
ManagerTheme {
Scaffold(
topBar = { HomeAppBar() },
) { padding ->
HomeScreenLoadingContent(padding = padding)
}
}
}
@@ -1,25 +0,0 @@
package com.meowarex.rlmobile.ui.previews.screens.home
import android.content.res.Configuration
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.home.HomeScreenNoneContent
import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar
import com.meowarex.rlmobile.ui.theme.ManagerTheme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun HomeScreenNonePreview() {
ManagerTheme {
Scaffold(
topBar = { HomeAppBar() },
) { padding ->
HomeScreenNoneContent(
padding = padding,
onClickInstall = {},
)
}
}
}
@@ -1,31 +0,0 @@
package com.meowarex.rlmobile.ui.previews.screens.plugins
import android.content.res.Configuration
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import com.meowarex.rlmobile.ui.util.emptyImmutableList
// This preview has interactable content that cannot be tested from an IDE preview
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun PluginsScreenFailedPreview() {
val filterState = remember { mutableStateOf("") }
ManagerTheme {
PluginsScreenContent(
searchText = filterState,
setSearchText = filterState::value::set,
isError = true,
plugins = emptyImmutableList(),
onPluginUninstall = {},
onPluginChangelog = {},
onPluginToggle = { name, enabled -> },
safeMode = false,
setSafeMode = {}
)
}
}
@@ -1,94 +0,0 @@
package com.meowarex.rlmobile.ui.previews.screens.plugins
import android.content.res.Configuration
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginManifest
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
// This preview has scrollable/interactable content that cannot be tested from an IDE preview
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun PluginsScreenLoadedPreview() {
val filterState = remember { mutableStateOf("test") }
ManagerTheme {
PluginsScreenContent(
searchText = filterState,
setSearchText = filterState::value::set,
isError = false,
plugins = plugins,
onPluginUninstall = {},
onPluginChangelog = {},
onPluginToggle = { name, enabled -> },
safeMode = false,
setSafeMode = {}
)
}
}
private val plugins: ImmutableList<PluginItem> = persistentListOf(
PluginItem(
path = UUID.randomUUID().toString(),
manifest = PluginManifest(
name = "CloseDMs",
authors = persistentListOf(
PluginManifest.Author(name = "Diamond", id = 0L),
),
description = "Shortcut to close DMs in the DM context menu.",
version = "1.0.0",
updateUrl = "",
changelog = "",
changelogMedia = null,
)
),
PluginItem(
path = UUID.randomUUID().toString(),
manifest = PluginManifest(
name = "ConfigurableStickerSizes",
authors = persistentListOf(
PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false),
),
description = "Makes sticker sizes configurable.",
version = "1.1.5",
updateUrl = "",
changelog = "",
changelogMedia = null,
)
),
PluginItem(
path = UUID.randomUUID().toString(),
manifest = PluginManifest(
name = "AudioPlayer",
authors = persistentListOf(
PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false),
),
description = "Makes audio files playable.",
version = "0.0.1",
updateUrl = "",
changelog = "",
changelogMedia = null,
)
),
PluginItem(
path = UUID.randomUUID().toString(),
manifest = PluginManifest(
name = "TypingIndicators",
authors = persistentListOf(
PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false),
),
description = "Adds typing indicators to channels that people are currently typing in.",
version = "1.1.0",
updateUrl = "",
changelog = "",
changelogMedia = null,
)
),
)
@@ -1,31 +0,0 @@
package com.meowarex.rlmobile.ui.previews.screens.plugins
import android.content.res.Configuration
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import com.meowarex.rlmobile.ui.util.emptyImmutableList
// This preview has interactable content that cannot be tested from an IDE preview
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun PluginsScreenNonePreview() {
val filterState = remember { mutableStateOf("") }
ManagerTheme {
PluginsScreenContent(
searchText = filterState,
setSearchText = filterState::value::set,
isError = false,
plugins = emptyImmutableList(),
onPluginUninstall = {},
onPluginChangelog = {},
onPluginToggle = { name, enabled -> },
safeMode = false,
setSafeMode = {}
)
}
}
@@ -1,14 +1,47 @@
package com.meowarex.rlmobile.ui.screens.about package com.meowarex.rlmobile.ui.screens.about
import android.util.Log
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.network.models.Contributor import com.meowarex.rlmobile.network.models.Contributor
import com.meowarex.rlmobile.network.services.HttpService import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
import com.meowarex.rlmobile.network.utils.ApiResponse
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
import com.meowarex.rlmobile.util.launchIO
import kotlinx.collections.immutable.persistentListOf
class AboutModel( class AboutModel(
@Suppress("unused") private val http: HttpService, private val github: RadiantLyricsGithubService,
) : StateScreenModel<AboutScreenState>( ) : StateScreenModel<AboutScreenState>(AboutScreenState.Loading) {
AboutScreenState.Loaded(emptyList<Contributor>().toUnsafeImmutable())
) { init {
fun fetchContributors() = Unit fetchContributors()
}
fun fetchContributors() = screenModelScope.launchIO {
mutableState.value = AboutScreenState.Loading
when (val result = github.getContributors()) {
is ApiResponse.Success -> {
val list = result.data
.filter { it.type == null || it.type == "User" }
.map { c ->
Contributor(
username = c.login,
avatarUrl = c.avatarUrl,
commits = c.contributions,
repositories = persistentListOf(),
)
}
.toUnsafeImmutable()
mutableState.value = AboutScreenState.Loaded(list)
}
is ApiResponse.Error,
is ApiResponse.Failure -> {
Log.w(BuildConfig.TAG, "Failed to fetch contributors: $result")
mutableState.value = AboutScreenState.Failure
}
}
}
} }
@@ -12,6 +12,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.koin.koinScreenModel
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.* import com.meowarex.rlmobile.ui.components.*
import com.meowarex.rlmobile.ui.screens.about.components.LeadContributor import com.meowarex.rlmobile.ui.screens.about.components.LeadContributor
@@ -69,7 +70,7 @@ fun AboutScreenContent(state: State<AboutScreenState>) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
LeadContributor("meowarex", "Radiant Lyrics") LeadContributor(BuildConfig.PATCHES_REPO_OWNER, "Radiant Lyrics")
} }
} }
@@ -100,7 +101,8 @@ fun AboutScreenContent(state: State<AboutScreenState>) {
} }
is AboutScreenState.Loaded -> { is AboutScreenState.Loaded -> {
items(state.contributors, key = { it.username }) { user -> val others = state.contributors.filter { it.username != BuildConfig.PATCHES_REPO_OWNER }
items(others, key = { it.username }) { user ->
ContributorCommitsItem(user) ContributorCommitsItem(user)
} }
} }
@@ -11,19 +11,23 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import com.github.diamondminer88.zip.ZipReader import com.github.diamondminer88.zip.ZipReader
import com.meowarex.rlmobile.BuildConfig import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.network.models.GithubCommit
import com.meowarex.rlmobile.network.models.RLBuildInfo import com.meowarex.rlmobile.network.models.RLBuildInfo
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
import com.meowarex.rlmobile.network.utils.CommitsPagingSource
import com.meowarex.rlmobile.network.utils.fold import com.meowarex.rlmobile.network.utils.fold
import com.meowarex.rlmobile.patcher.InstallMetadata import com.meowarex.rlmobile.patcher.InstallMetadata
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
import com.meowarex.rlmobile.ui.util.TidalVersion import com.meowarex.rlmobile.ui.util.TidalVersion
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
import com.meowarex.rlmobile.util.* import com.meowarex.rlmobile.util.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -36,9 +40,14 @@ class HomeModel(
private val github: RadiantLyricsGithubService, private val github: RadiantLyricsGithubService,
private val json: Json, private val json: Json,
) : ScreenModel { ) : ScreenModel {
var installsState by mutableStateOf<InstallsState>(InstallsState.Fetching)
var state by mutableStateOf<HomeState>(HomeState.Loading)
private set private set
val commits = Pager(PagingConfig(pageSize = 30)) {
CommitsPagingSource(github)
}.flow.cachedIn(screenModelScope)
private val refreshingLock = Mutex() private val refreshingLock = Mutex()
private var remoteDataJson: RLBuildInfo? = null private var remoteDataJson: RLBuildInfo? = null
@@ -48,37 +57,47 @@ class HomeModel(
fun refresh(delay: Boolean = false) = screenModelScope.launchIO { fun refresh(delay: Boolean = false) = screenModelScope.launchIO {
if (refreshingLock.isLocked) return@launchIO if (refreshingLock.isLocked) return@launchIO
if (delay) { if (delay) {
delay(250) delay(250)
if (refreshingLock.isLocked) return@launchIO
if (refreshingLock.isLocked)
return@launchIO
} }
refreshingLock.withLock { refreshingLock.withLock {
val packages = fetchRadiantLyricsPackages() val pkg = fetchInstalled()
val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() }
remote.await()
val jobs = listOf( val install = pkg?.toInstallData()
screenModelScope.launch(Dispatchers.IO) { val latest = remoteDataJson?.tidalVersionCode
fetchInstallations(packages)
}, mainThread {
screenModelScope.launch(Dispatchers.IO) { state = HomeState.Loaded(
if (remoteDataJson == null) install = install,
fetchRemoteData() latestTidalVersionCode = latest,
}
) )
jobs.joinAll()
mainThread { refreshInstallationsUpToDate(packages) }
} }
} }
}
fun launchInstall() {
val current = (state as? HomeState.Loaded)?.install ?: return
openApp(current.packageName)
}
fun openCurrentAppInfo() {
val current = (state as? HomeState.Loaded)?.install ?: return
openAppInfo(current.packageName)
}
fun createReinstallScreen(): PatchOptionsScreen? {
val current = (state as? HomeState.Loaded)?.install ?: return null
return createPrefilledPatchOptsScreen(current.packageName)
}
fun openApp(packageName: String) { fun openApp(packageName: String) {
val launchIntent = application.packageManager val launchIntent = application.packageManager.getLaunchIntentForPackage(packageName)
.getLaunchIntentForPackage(packageName)
if (launchIntent != null) { if (launchIntent != null) {
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
application.startActivity(launchIntent) application.startActivity(launchIntent)
} else { } else {
application.showToast(R.string.launch_app_fail) application.showToast(R.string.launch_app_fail)
@@ -89,7 +108,6 @@ class HomeModel(
val launchIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) val launchIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData("package:$packageName".toUri()) .setData("package:$packageName".toUri())
application.startActivity(launchIntent) application.startActivity(launchIntent)
} }
@@ -98,42 +116,30 @@ class HomeModel(
val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0) val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0)
val metadataFile = ZipReader(applicationInfo.publicSourceDir) val metadataFile = ZipReader(applicationInfo.publicSourceDir)
.use { it.openEntry("rlmobile.json")?.read() } .use { it.openEntry("rlmobile.json")?.read() }
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) } metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to parse Radiant Lyrics install metadata from package $packageName", t) Log.w(BuildConfig.TAG, "Failed to parse install metadata for $packageName", t)
null null
} }
val patchOptions = metadata?.options val patchOptions = metadata?.options ?: PatchOptions.Default.copy(packageName = packageName)
?: PatchOptions.Default.copy(packageName = packageName)
return PatchOptionsScreen(prefilledOptions = patchOptions) return PatchOptionsScreen(prefilledOptions = patchOptions)
} }
private suspend fun fetchInstallations(packages: List<PackageInfo>) { private fun fetchInstalled(): PackageInfo? = application.packageManager
mainThread { .getInstalledPackages(PackageManager.GET_META_DATA)
if (installsState !is InstallsState.Fetched) .firstOrNull { it.applicationInfo?.metaData?.containsKey("isRadiantLyrics") == true }
installsState = InstallsState.Fetching
}
try { private fun PackageInfo.toInstallData(): InstallData {
val packageManager = application.packageManager val pm = application.packageManager
val rlMobileInstallations = packages.mapNotNull { pkg -> @Suppress("DEPRECATION") val versionCode = versionCode
@Suppress("DEPRECATION") val versionName = versionName ?: ""
val versionCode = pkg.versionCode val info = applicationInfo!!
val versionName = pkg.versionName ?: return@mapNotNull null return InstallData(
val applicationInfo = pkg.applicationInfo ?: return@mapNotNull null name = pm.getApplicationLabel(info).toString(),
packageName = packageName,
InstallData( isUpToDate = isInstallationUpToDate(this),
name = packageManager.getApplicationLabel(applicationInfo).toString(), icon = pm.getApplicationIcon(info).toBitmap().asImageBitmap().let(::BitmapPainter),
packageName = pkg.packageName,
isUpToDate = isInstallationUpToDate(pkg),
icon = packageManager
.getApplicationIcon(applicationInfo)
.toBitmap()
.asImageBitmap()
.let(::BitmapPainter),
version = TidalVersion.Existing( version = TidalVersion.Existing(
type = TidalVersion.parseVersionType(versionCode), type = TidalVersion.parseVersionType(versionCode),
name = versionName.split("-")[0].trim(), name = versionName.split("-")[0].trim(),
@@ -142,102 +148,42 @@ class HomeModel(
) )
} }
mainThread {
installsState = if (rlMobileInstallations.isNotEmpty()) {
InstallsState.Fetched(data = rlMobileInstallations.toUnsafeImmutable())
} else {
InstallsState.None
}
}
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to query Radiant Lyrics installations", t)
mainThread { installsState = InstallsState.Error }
}
}
private suspend fun refreshInstallationsUpToDate(packages: List<PackageInfo>) {
val installations = mainThread { (installsState as? InstallsState.Fetched)?.data }
?: return
try {
val newInstallations = installations.map { data ->
val packageInfo = packages.find { it.packageName == data.packageName }
?: throw IllegalStateException("Checking up-to-date status for package that has not been fetched")
data.copy(isUpToDate = isInstallationUpToDate(packageInfo))
}
mainThread { installsState = InstallsState.Fetched(data = newInstallations.toUnsafeImmutable()) }
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to check installations up-to-date", t)
mainThread { installsState = InstallsState.Error }
}
}
private suspend fun fetchRemoteData() { private suspend fun fetchRemoteData() {
val release = try { val release = try {
github.getLatestRelease().let { response -> github.getLatestRelease().fold(
response.fold(
success = { it }, success = { it },
fail = { fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest release", it); return },
Log.w(BuildConfig.TAG, "Failed to fetch latest release", it)
return
},
) )
}
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to fetch remote data", t) Log.w(BuildConfig.TAG, "Failed to fetch remote data", t)
mainThread { application.showToast(R.string.home_network_fail) }
return return
} }
val dataJsonUrl = release.assets val dataJsonUrl = release.assets
.find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME } .find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME }
?.browserDownloadUrl ?.browserDownloadUrl
?: run { ?: return
Log.w(BuildConfig.TAG, "No data.json asset in latest release")
return
}
github.getBuildInfo(dataJsonUrl).fold( github.getBuildInfo(dataJsonUrl).fold(
success = { remoteDataJson = it }, success = { remoteDataJson = it },
fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build info", it) }, fail = { Log.w(BuildConfig.TAG, "Failed to fetch build info", it) },
) )
if (remoteDataJson == null) {
mainThread { application.showToast(R.string.home_network_fail) }
}
}
private fun fetchRadiantLyricsPackages(): List<PackageInfo> {
return application.packageManager
.getInstalledPackages(PackageManager.GET_META_DATA)
.filter {
it.applicationInfo?.metaData?.containsKey("isRadiantLyrics") == true
}
} }
private fun isInstallationUpToDate(pkg: PackageInfo): Boolean? { private fun isInstallationUpToDate(pkg: PackageInfo): Boolean? {
val remoteBuildData = remoteDataJson ?: return null val remote = remoteDataJson ?: return null
@Suppress("DEPRECATION") val versionCode = pkg.versionCode
@Suppress("DEPRECATION") if (remote.tidalVersionCode != versionCode) return false
val versionCode = pkg.versionCode
if (remoteBuildData.tidalVersionCode != versionCode) return false
val apkPath = pkg.applicationInfo?.publicSourceDir ?: return false val apkPath = pkg.applicationInfo?.publicSourceDir ?: return false
val installMetadata = try { val installMetadata = try {
val metadataFile = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() } val mf = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() } ?: return false
?: return false json.decodeFromStream<InstallMetadata>(mf.inputStream())
json.decodeFromStream<InstallMetadata>(metadataFile.inputStream())
} catch (t: Throwable) { } catch (t: Throwable) {
Log.d(BuildConfig.TAG, "Failed to parse Radiant Lyrics InstallMetadata from package ${pkg.packageName}", t)
return false return false
} }
if (installMetadata.options.customPatches != null) return true if (installMetadata.options.customPatches != null) return true
return remote.patchesVersion == installMetadata.patchesVersion
return remoteBuildData.patchesVersion == installMetadata.patchesVersion
} }
} }
@@ -1,34 +1,39 @@
package com.meowarex.rlmobile.ui.screens.home package com.meowarex.rlmobile.ui.screens.home
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.animation.* import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState import androidx.compose.foundation.Image
import androidx.compose.animation.core.tween import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.paging.compose.collectAsLazyPagingItems
import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.LoadFailure import com.meowarex.rlmobile.ui.components.SegmentedButton
import com.meowarex.rlmobile.ui.components.ProjectHeader import com.meowarex.rlmobile.ui.screens.about.AboutScreen
import com.meowarex.rlmobile.ui.screens.home.components.* import com.meowarex.rlmobile.ui.screens.home.components.CommitList
import com.meowarex.rlmobile.ui.screens.logs.LogsListScreen
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides
import com.meowarex.rlmobile.ui.util.paddings.exclude
import com.meowarex.rlmobile.util.* import com.meowarex.rlmobile.util.*
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -44,180 +49,178 @@ class HomeScreen : Screen, Parcelable {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val model = koinScreenModel<HomeModel>() val model = koinScreenModel<HomeModel>()
// Refresh installations list when the screen changes or activity resumes
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
model.refresh(delay = true) model.refresh(delay = true)
onPauseOrDispose {} onPauseOrDispose {}
} }
Scaffold( Scaffold(
topBar = { HomeAppBar() }, topBar = {
) { padding -> TopAppBar(
when (val state = model.installsState) { title = { Text(stringResource(R.string.navigation_home)) },
is InstallsState.Fetched -> HomeScreenLoadedContent( actions = {
IconButton(onClick = { model.refresh() }) {
Icon(
painterResource(R.drawable.ic_refresh),
contentDescription = stringResource(R.string.navigation_refresh),
)
}
IconButton(onClick = { navigator.push(AboutScreen()) }) {
Icon(
painterResource(R.drawable.ic_info),
contentDescription = stringResource(R.string.navigation_about),
)
}
IconButton(onClick = { navigator.push(LogsListScreen()) }) {
Icon(
painterResource(R.drawable.ic_receipt),
contentDescription = stringResource(R.string.navigation_logs),
)
}
IconButton(onClick = { navigator.push(SettingsScreen()) }) {
Icon(
painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings),
)
}
},
)
},
) { pv ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.padding(pv)
.padding(16.dp)
.fillMaxSize(),
) {
val state = model.state
when (state) {
HomeState.Loading -> Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) { CircularProgressIndicator() }
is HomeState.Loaded -> HomeContent(
state = state, state = state,
padding = padding, commits = model.commits,
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) }, onInstall = { navigator.pushOnce(PatchOptionsScreen()) },
onUpdate = { onReinstall = {
scope.launchIO { scope.launchIO {
val screen = model.createPrefilledPatchOptsScreen(it) val screen = model.createReinstallScreen() ?: return@launchIO
mainThread { navigator.push(screen) } mainThread { navigator.push(screen) }
} }
}, },
onOpenApp = model::openApp, onLaunch = model::launchInstall,
onOpenAppInfo = model::openAppInfo, onInfo = model::openCurrentAppInfo,
onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins
) )
}
InstallsState.Fetching -> HomeScreenLoadingContent(padding = padding)
InstallsState.None -> HomeScreenNoneContent(
padding = padding,
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) },
)
InstallsState.Error -> HomeScreenFailureContent(padding = padding)
} }
} }
} }
} }
@Composable @Composable
fun HomeScreenLoadingContent(padding: PaddingValues) { private fun ColumnScope.HomeContent(
Column( state: HomeState.Loaded,
horizontalAlignment = Alignment.CenterHorizontally, commits: kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<com.meowarex.rlmobile.network.models.GithubCommit>>,
modifier = Modifier onInstall: () -> Unit,
.fillMaxSize() onReinstall: () -> Unit,
.padding(padding) onLaunch: () -> Unit,
.padding(top = 16.dp, start = 16.dp, end = 16.dp) onInfo: () -> Unit,
) {
ProjectHeader()
AnimatedVisibility(
visibleState = remember { MutableTransitionState(false) }.apply { targetState = true },
enter = fadeIn(animationSpec = tween(durationMillis = 800)),
exit = ExitTransition.None,
) {
Box(
contentAlignment = Alignment.Center,
content = { CircularProgressIndicator() },
modifier = Modifier
.fillMaxSize(),
)
}
}
}
@Composable
fun HomeScreenLoadedContent(
state: InstallsState.Fetched,
padding: PaddingValues,
onClickInstall: () -> Unit,
onUpdate: (packageName: String) -> Unit,
onOpenApp: (packageName: String) -> Unit,
onOpenAppInfo: (packageName: String) -> Unit,
onOpenPlugins: (packageName: String) -> Unit,
) { ) {
LazyColumn( val install = state.install
verticalArrangement = Arrangement.spacedBy(10.dp), val currentVersionName = install?.version?.let { "v${it.toString()}" }
horizontalAlignment = Alignment.CenterHorizontally, val latestVersionName = state.latestTidalVersionCode?.let { "build $it" }
contentPadding = padding
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top),
modifier = Modifier
.fillMaxSize()
.padding(padding.exclude(PaddingValuesSides.Bottom))
.padding(top = 16.dp, start = 16.dp, end = 16.dp),
) {
item(key = "PROJECT_HEADER") {
ProjectHeader()
}
item(key = "ADD_INSTALL_BUTTON") { val fallbackPainter = if (install?.icon == null) {
InstallButton( // R.mipmap.ic_launcher is an adaptive-icon XML on API 26+, which painterResource cannot decode.
secondaryInstall = true, val context = LocalContext.current
onClick = onClickInstall, remember {
val drawable = ContextCompat.getDrawable(context, R.mipmap.ic_launcher)
drawable?.toBitmap()?.asImageBitmap()?.let(::BitmapPainter)
}
} else null
val iconPainter = install?.icon ?: fallbackPainter
if (iconPainter != null) {
Image(
painter = iconPainter,
contentDescription = null,
modifier = Modifier modifier = Modifier
.padding(vertical = 4.dp) .size(60.dp)
.height(50.dp) .clip(CircleShape),
.fillMaxWidth()
) )
} }
items(state.data, key = { it.packageName }) { item ->
InstalledItemCard(
data = item,
onUpdate = { onUpdate(item.packageName) },
onOpenApp = { onOpenApp(item.packageName) },
onOpenInfo = { onOpenAppInfo(item.packageName) },
onOpenPlugins = { onOpenPlugins(item.packageName) },
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
fun HomeScreenNoneContent(
padding: PaddingValues,
onClickInstall: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize(),
) {
ProjectHeader()
InstallButton(
secondaryInstall = false,
onClick = onClickInstall,
modifier = Modifier
.padding(12.dp)
.height(height = 50.dp)
.fillMaxWidth()
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.alpha(.7f)
.fillMaxSize()
.padding(bottom = 80.dp)
) {
Text(
text = """ /ᐠﹷ ‸ ﹷ ᐟ\ノ""",
style = MaterialTheme.typography.labelLarge
.copy(fontSize = 38.sp),
)
Text( Text(
text = stringResource(R.string.installs_no_installs), text = install?.name ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth(),
) {
AnimatedVisibility(visible = currentVersionName != null) {
Text(
text = "Current: ${currentVersionName ?: "-"}",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(start = 10.dp), color = LocalContentColor.current.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
)
}
AnimatedVisibility(visible = latestVersionName != null) {
Text(
text = "Latest: ${latestVersionName ?: "-"}",
style = MaterialTheme.typography.labelLarge,
color = LocalContentColor.current.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
) )
} }
} }
}
@Composable Button(
fun HomeScreenFailureContent( onClick = if (install == null) onInstall else onReinstall,
padding: PaddingValues, enabled = state.latestTidalVersionCode != null,
) { modifier = Modifier.fillMaxWidth(),
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize(),
) { ) {
ProjectHeader() val label = when {
LoadFailure(modifier = Modifier.fillMaxSize()) state.latestTidalVersionCode == null -> "Loading…"
install == null -> "Install"
install.isUpToDate == false -> "Update"
else -> "Reinstall"
}
Text(
text = label,
textAlign = TextAlign.Center,
maxLines = 1,
modifier = Modifier
.basicMarquee()
.fillMaxWidth(),
)
}
AnimatedVisibility(visible = install != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.clip(RoundedCornerShape(16.dp)),
) {
SegmentedButton(
icon = painterResource(R.drawable.ic_launch),
text = stringResource(R.string.action_launch),
onClick = onLaunch,
)
SegmentedButton(
icon = painterResource(R.drawable.ic_info),
text = stringResource(R.string.action_open_info),
onClick = onInfo,
)
}
}
ElevatedCard(modifier = Modifier.fillMaxSize()) {
CommitList(commits = commits.collectAsLazyPagingItems())
} }
} }
@@ -0,0 +1,12 @@
package com.meowarex.rlmobile.ui.screens.home
import androidx.compose.runtime.Immutable
@Immutable
sealed interface HomeState {
data object Loading : HomeState
data class Loaded(
val install: InstallData?,
val latestTidalVersionCode: Int?,
) : HomeState
}
@@ -1,12 +0,0 @@
package com.meowarex.rlmobile.ui.screens.home
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
sealed interface InstallsState {
data object None : InstallsState
data object Error : InstallsState
data object Fetching : InstallsState
data class Fetched(val data: ImmutableList<InstallData>) : InstallsState
}
@@ -0,0 +1,115 @@
package com.meowarex.rlmobile.ui.screens.home.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import coil3.compose.AsyncImage
import com.meowarex.rlmobile.network.models.GithubCommit
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Date
@Composable
fun CommitList(commits: LazyPagingItems<GithubCommit>) {
val loading = commits.loadState.refresh is LoadState.Loading || commits.loadState.append is LoadState.Loading
val failed = commits.loadState.refresh is LoadState.Error || commits.loadState.append is LoadState.Error
LazyColumn {
items(
count = commits.itemCount,
key = commits.itemKey { it.sha },
) { index ->
val commit = commits[index] ?: return@items
CommitRow(commit)
if (index < commits.itemCount - 1) {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
if (loading) item {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
CircularProgressIndicator(strokeWidth = 3.dp, modifier = Modifier.size(30.dp))
}
}
if (failed) item {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
Text("Failed to load commits", style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Center)
Button(onClick = { commits.retry() }) { Text("Retry") }
}
}
}
}
@Composable
private fun CommitRow(commit: GithubCommit) {
val uriHandler = LocalUriHandler.current
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.clickable { uriHandler.openUri(commit.htmlUrl) }
.padding(16.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = commit.author?.avatarUrl ?: "https://github.com/ghost.png",
contentDescription = commit.author?.login ?: "ghost",
modifier = Modifier.size(20.dp).clip(CircleShape),
)
Text(
text = commit.author?.login ?: commit.commit.author.name,
style = MaterialTheme.typography.labelMedium,
)
Text("", style = MaterialTheme.typography.labelLarge)
Text(
text = commit.sha.take(7),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontFamily = FontFamily.Monospace,
)
Text(
text = runCatching {
SimpleDateFormat.getDateInstance(SimpleDateFormat.SHORT)
.format(Date.from(Instant.parse(commit.commit.author.date)))
}.getOrDefault(""),
style = MaterialTheme.typography.labelMedium,
color = LocalContentColor.current.copy(alpha = 0.5f),
textAlign = TextAlign.End,
modifier = Modifier.weight(1f),
)
}
Text(
text = commit.commit.message.lineSequence().first(),
style = MaterialTheme.typography.labelLarge,
)
}
}
@@ -1,42 +0,0 @@
package com.meowarex.rlmobile.ui.screens.home.components
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.navigator.LocalNavigator
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.about.AboutScreen
import com.meowarex.rlmobile.ui.screens.logs.LogsListScreen
import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
@Composable
fun HomeAppBar() {
TopAppBar(
title = {},
actions = {
val navigator = LocalNavigator.current
IconButton(onClick = { navigator?.push(AboutScreen()) }) {
Icon(
painter = painterResource(R.drawable.ic_info),
contentDescription = stringResource(R.string.navigation_about),
)
}
IconButton(onClick = { navigator?.push(LogsListScreen()) }) {
Icon(
painter = painterResource(R.drawable.ic_receipt),
contentDescription = stringResource(R.string.navigation_logs),
)
}
IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
Icon(
painter = painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings),
)
}
}
)
}
@@ -1,88 +0,0 @@
package com.meowarex.rlmobile.ui.screens.home.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.util.thenIf
import com.valentinilk.shimmer.*
private val shimmerTheme = defaultShimmerTheme.copy(
shimmerWidth = 150.dp,
animationSpec = infiniteRepeatable(
animation = shimmerSpec(
durationMillis = 2000,
easing = LinearEasing,
delayMillis = 3500,
),
repeatMode = RepeatMode.Restart,
initialStartOffset = StartOffset(1000),
),
blendMode = BlendMode.Lighten,
shaderColors = listOf(
Color.White.copy(alpha = 0.00f),
Color.White.copy(alpha = 0.50f),
Color.White.copy(alpha = 1.00f),
Color.White.copy(alpha = 0.50f),
Color.White.copy(alpha = 0.00f),
),
shaderColorStops = listOf(
0.0f,
0.25f,
0.5f,
0.75f,
1.0f,
),
)
@Composable
fun InstallButton(
enabled: Boolean = true,
secondaryInstall: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(
LocalShimmerTheme provides shimmerTheme
) {
FilledTonalIconButton(
shape = RectangleShape,
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = if (secondaryInstall) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.primary
},
),
enabled = enabled,
onClick = onClick,
modifier = modifier
.clip(MaterialTheme.shapes.medium)
.thenIf(!secondaryInstall) { shimmer() }
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 10.dp),
) {
Icon(
painter = painterResource(R.drawable.ic_add),
contentDescription = null,
)
Text(
text = stringResource(R.string.action_add_install),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
@@ -1,123 +0,0 @@
package com.meowarex.rlmobile.ui.screens.home.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.SegmentedButton
import com.meowarex.rlmobile.ui.components.VersionDisplay
import com.meowarex.rlmobile.ui.screens.home.InstallData
@Composable
fun InstalledItemCard(
data: InstallData,
onUpdate: () -> Unit,
onOpenApp: () -> Unit,
onOpenInfo: () -> Unit,
onOpenPlugins: () -> Unit,
modifier: Modifier = Modifier,
) {
ElevatedCard(
shape = MaterialTheme.shapes.medium,
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = 3.dp,
),
modifier = modifier
.width(IntrinsicSize.Max),
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(20.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = data.icon,
contentDescription = null,
modifier = Modifier
.size(34.dp)
.clip(CircleShape),
)
Column {
Text(
text = data.name,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = .94f),
)
Text(
text = data.packageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(start = 1.dp)
.offset(y = (-2).dp)
.alpha(.7f)
.basicMarquee(),
)
}
Spacer(Modifier.weight(1f, fill = true))
VersionDisplay(
version = data.version,
prefix = { append("v") },
modifier = Modifier
.alpha(.6f)
.padding(end = 4.dp),
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.clip(MaterialTheme.shapes.large),
) {
SegmentedButton(
icon = painterResource(R.drawable.ic_extension),
text = stringResource(R.string.plugins_title),
onClick = onOpenPlugins,
)
SegmentedButton(
icon = painterResource(R.drawable.ic_info),
text = stringResource(R.string.action_open_info),
onClick = onOpenInfo,
)
// If the up-to-date status cannot be determined, assume it is up-to-date
if (data.isUpToDate ?: true) {
SegmentedButton(
icon = painterResource(R.drawable.ic_launch),
text = stringResource(R.string.action_launch),
onClick = onOpenApp,
)
} else {
val warningColor = Color(0xFFFFBB33)
SegmentedButton(
icon = painterResource(R.drawable.ic_update),
text = stringResource(R.string.action_update),
iconColor = warningColor,
textColor = warningColor,
onClick = onUpdate,
)
}
}
}
}
}
@@ -38,7 +38,7 @@ data class PatchOptions(
companion object { companion object {
val Default = PatchOptions( val Default = PatchOptions(
appName = "TIDAL", appName = "TIDAL",
packageName = "com.tidal.music", packageName = "com.aspiro.tidal",
debuggable = false, debuggable = false,
customInjector = null, customInjector = null,
customPatches = null, customPatches = null,
@@ -1,279 +0,0 @@
package com.meowarex.rlmobile.ui.screens.plugins
import android.app.Application
import android.util.Log
import androidx.compose.runtime.*
import cafe.adriel.voyager.core.model.ScreenModel
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.ui.screens.plugins.model.PluginItem
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginManifest
import com.meowarex.rlmobile.ui.util.emptyImmutableList
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
import com.meowarex.rlmobile.util.*
import com.github.diamondminer88.zip.ZipReader
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import java.io.File
import kotlin.time.Duration
class PluginsModel(
private val context: Application,
private val paths: PathManager,
private val json: Json,
) : ScreenModel {
private val plugins = MutableStateFlow<ImmutableList<PluginItem>>(emptyImmutableList())
var error by mutableStateOf(false)
private set
var showChangelogDialog by mutableStateOf<PluginItem?>(null)
private set
var showUninstallDialog by mutableStateOf<PluginItem?>(null)
private set
val searchText: StateFlow<String>
field = MutableStateFlow("")
var pluginsSafeMode = MutableStateFlow(false)
private set
val filteredPlugins: StateFlow<ImmutableList<PluginItem>> = searchText
.combine(plugins) { searchText, plugins ->
if (searchText.isBlank()) {
plugins
} else {
plugins.filter { plugin ->
plugin.manifest.name.contains(searchText, ignoreCase = true)
|| plugin.manifest.description.contains(searchText, ignoreCase = true)
|| plugin.manifest.authors.any { (name) -> name.contains(searchText, ignoreCase = true) }
}.toUnsafeImmutable()
}
}.stateIn(
scope = screenModelScope + Dispatchers.Default,
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO),
initialValue = plugins.value,
)
// ---- State setters ---- //
fun setSearchText(search: String) {
searchText.value = search
}
fun showChangelogDialog(plugin: PluginItem) {
showChangelogDialog = plugin
}
fun hideChangelogDialog() {
showChangelogDialog = null
}
fun showUninstallDialog(plugin: PluginItem) {
showUninstallDialog = plugin
}
fun hideUninstallDialog() {
showUninstallDialog = null
}
// ---- IO state setters ---- //
fun uninstallPlugin(plugin: PluginItem) = screenModelScope.launchIO {
if (!plugins.value.any { it.path == plugin.path }) {
mainThread { hideUninstallDialog() }
return@launchIO
}
val deleteSuccess = try {
File(plugin.path).delete()
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to delete plugin", t)
false
}
if (!deleteSuccess) {
mainThread {
hideUninstallDialog()
context.showToast(R.string.plugins_error)
}
return@launchIO
}
plugins.update { (it - plugin).toUnsafeImmutable() }
mainThread { hideUninstallDialog() }
}
fun setPluginEnabled(pluginName: String, enabled: Boolean) = screenModelScope.launchIO {
try {
editTidalSettings {
put(JsonPrimitive("AC_PM_$pluginName"), JsonPrimitive(enabled))
}
mainThread {
plugins.value.forEach {
if (it.manifest.name == pluginName)
it.enabled = enabled
}
}
} catch (e: Exception) {
Log.e(BuildConfig.TAG, "Failed to toggle plugin", e)
mainThread { context.showToast(R.string.status_failed) }
}
}
fun setSafeMode(safeMode: Boolean) = screenModelScope.launchIO {
try {
editTidalSettings {
put(JsonPrimitive("RL_safe_mode_enabled"), JsonPrimitive(safeMode))
}
pluginsSafeMode.value = safeMode
} catch (e: Exception) {
Log.e(BuildConfig.TAG, "Failed to toggle plugin", e)
mainThread { context.showToast(R.string.status_failed) }
}
}
// ---- State loading ---- //
// Called by screen to load initial data
fun refreshData() = screenModelScope.launchIO {
try {
loadSafeMode()
loadPlugins()
loadPluginsEnabled()
} catch (e: Exception) {
Log.e(BuildConfig.TAG, "Failed to load plugins state", e)
mainThread {
context.showToast(R.string.plugins_error)
error = true
}
}
}
private fun loadSafeMode() = screenModelScope.launchIO {
@Serializable
data class SafeModeSettings(
@SerialName("RL_safe_mode_enabled")
val safeMode: Boolean = false,
)
pluginsSafeMode.value = readTidalSettings<SafeModeSettings>()?.safeMode ?: false
}
private suspend fun loadPluginsEnabled() {
val pluginToggles = readTidalSettings<Map<JsonPrimitive, JsonElement>>()
?.filterKeys { it.isString && it.content.startsWith("AC_PM_") }
?.filterValues { (it as? JsonPrimitive)?.booleanOrNull == true }
?.mapKeys { (key, _) -> key.content.substring("AC_PM_".length) }
?.mapValues { (_, value) -> value.jsonPrimitive.boolean }
if (pluginToggles != null) mainThread {
plugins.value.forEach {
if (!pluginToggles.getOrDefault(it.manifest.name, true))
it.enabled = false
}
}
}
private fun loadPlugins() {
if (!paths.pluginsDir.exists() && !paths.pluginsDir.mkdirs())
throw IllegalStateException("Failed to create plugins directory")
val pluginFiles = paths.pluginsDir.listFiles { file -> file.extension == "zip" }
?: throw IllegalStateException("Failed to read plugins directory")
val pluginItems = pluginFiles
.mapNotNull {
try {
PluginItem(
manifest = loadPluginManifest(it),
path = it.absolutePath,
)
} catch (e: Exception) {
Log.e(BuildConfig.TAG, "Failed to load plugin at ${it.absolutePath}", e)
null
}
}
.sortedBy { it.manifest.name }
plugins.value = pluginItems.toUnsafeImmutable()
}
private fun loadPluginManifest(pluginFile: File): PluginManifest {
return ZipReader(pluginFile).use {
val manifest = it.openEntry("manifest.json")
?: throw Exception("Plugin ${pluginFile.name} has no manifest")
try {
json.decodeFromStream(manifest.read().inputStream())
} catch (t: Throwable) {
throw Exception("Failed to parse plugin manifest for ${pluginFile.name}", t)
}
}
}
// ---- Radiant Lyrics settings ---- //
/**
* Reads Radiant Lyrics core's settings, applies [block] to it, and writes it back.
*/
private suspend fun editTidalSettings(block: (MutableMap<JsonPrimitive, JsonElement>).() -> Unit) {
SETTINGS_MUTEX.withLock {
val settings = try {
if (paths.coreSettingsFile.exists()) {
json.decodeFromStream<MutableMap<JsonPrimitive, JsonElement>>(paths.coreSettingsFile.inputStream())
} else {
mutableMapOf()
}
} catch (e: Exception) {
Log.e(BuildConfig.TAG, "Radiant Lyrics settings are corrupted!", e)
mutableMapOf()
}
// Apply modifier block
block(settings)
paths.coreSettingsFile.parentFile!!.mkdirs()
paths.coreSettingsFile.outputStream()
.use { out -> json.encodeToStream(settings, out) }
}
}
/**
* Reads Radiant Lyrics core's settings and parses it into a specific model.
* This should not be used for future writes.
*
* @return The parsed settings model, or null if settings are missing or corrupt.
*/
private suspend inline fun <reified T> readTidalSettings(): T? {
return SETTINGS_MUTEX.withLock {
try {
if (paths.coreSettingsFile.exists()) {
json.decodeFromStream<T>(paths.coreSettingsFile.inputStream())
} else {
null
}
} catch (e: Exception) {
Log.e(BuildConfig.TAG, "Radiant Lyrics settings are corrupted!", e)
null
}
}
}
private companion object {
/**
* Global lock on the main Radiant Lyrics settings.
*/
private val SETTINGS_MUTEX = Mutex()
}
}
@@ -1,152 +0,0 @@
package com.meowarex.rlmobile.ui.screens.plugins
import android.os.Parcelable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.BackButton
import com.meowarex.rlmobile.ui.components.settings.SettingsSwitch
import com.meowarex.rlmobile.ui.screens.plugins.components.*
import com.meowarex.rlmobile.ui.screens.plugins.components.dialogs.UninstallPluginDialog
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem
import com.meowarex.rlmobile.ui.util.paddings.*
import kotlinx.collections.immutable.ImmutableList
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
class PluginsScreen : Screen, Parcelable {
@IgnoredOnParcel
override val key = "Plugins"
@Composable
override fun Content() {
val model = koinScreenModel<PluginsModel>()
// Refresh plugins list on activity resume or when this initially opens
LifecycleResumeEffect(Unit) {
model.refreshData()
onPauseOrDispose {}
}
model.showUninstallDialog?.let { plugin ->
UninstallPluginDialog(
pluginName = plugin.manifest.name,
onConfirm = { model.uninstallPlugin(plugin) },
onDismiss = model::hideUninstallDialog
)
}
model.showChangelogDialog?.let { plugin ->
Changelog(
plugin = plugin,
onDismiss = model::hideChangelogDialog
)
}
PluginsScreenContent(
searchText = model.searchText.collectAsState(),
setSearchText = model::setSearchText,
isError = model.error,
plugins = model.filteredPlugins.collectAsState().value,
onPluginUninstall = model::showUninstallDialog,
onPluginChangelog = model::showChangelogDialog,
onPluginToggle = model::setPluginEnabled,
safeMode = model.pluginsSafeMode.collectAsState().value,
setSafeMode = model::setSafeMode,
)
}
}
@Composable
fun PluginsScreenContent(
searchText: State<String>,
setSearchText: (String) -> Unit,
isError: Boolean,
plugins: ImmutableList<PluginItem>,
onPluginUninstall: (PluginItem) -> Unit,
onPluginChangelog: (PluginItem) -> Unit,
onPluginToggle: (name: String, enabled: Boolean) -> Unit,
safeMode: Boolean,
setSafeMode: (Boolean) -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.plugins_title)) },
navigationIcon = { BackButton() },
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues.exclude(PaddingValuesSides.Bottom)),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
SettingsSwitch(
label = stringResource(R.string.plugins_safe_mode_title),
secondaryLabel = stringResource(R.string.plugins_safe_mode_desc),
icon = { Icon(painterResource(R.drawable.ic_security), null) },
pref = safeMode,
onPrefChange = setSafeMode
)
Column(
modifier = Modifier.padding(horizontal = 20.dp)
) {
PluginSearch(
currentFilter = searchText,
onFilterChange = setSearchText,
modifier = Modifier
.fillMaxWidth()
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = paddingValues
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
.add(PaddingValues(vertical = 12.dp)),
modifier = Modifier.fillMaxSize(),
) {
when {
isError -> item(key = "ERROR") {
PluginsError(modifier = Modifier.fillParentMaxSize())
}
plugins.isNotEmpty() -> {
items(
items = plugins,
contentType = { "PLUGIN" },
key = { it.path },
) { plugin ->
PluginCard(
plugin = plugin,
onClickDelete = { onPluginUninstall(plugin) },
onClickShowChangelog = { onPluginChangelog(plugin) },
onSetEnabled = { onPluginToggle(plugin.manifest.name, it) },
)
}
}
else -> item("PLUGINS_NONE") {
PluginsNone(Modifier.fillParentMaxSize())
}
}
}
}
}
}
}
@@ -1,154 +0,0 @@
package com.meowarex.rlmobile.ui.screens.plugins.components
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.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem
private val hyperLinkPattern = Regex("\\[(.+?)]\\((.+?\\))")
@Suppress("RegExpRedundantEscape") // It is very much not redundant and causes a crash lol
private val headerStylePattern = Regex("\\{(improved|added|fixed)( marginTop)?\\}")
@Composable
private fun AnnotatedString.Builder.MarkdownHyperlink(content: String) {
var idx = 0
with(hyperLinkPattern.toPattern().matcher(content)) {
while (find()) {
val start = start()
val end = end()
val title = group(1)!!
val url = group(2)!!
append(content.substring(idx, start))
// @formatter:off
pushLink(LinkAnnotation.Url(
url,
TextLinkStyles(SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
))
))
append(title)
pop()
// @formatter:on
idx = end
}
}
if (idx < content.length) append(content.substring(idx))
}
@Composable
fun Changelog(
plugin: PluginItem,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
painter = painterResource(R.drawable.ic_history),
contentDescription = stringResource(R.string.plugins_view_changelog, plugin.manifest.name)
)
},
title = { Text(plugin.manifest.name) },
text = {
Column {
plugin.manifest.changelogMedia?.let { mediaUrl ->
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 90.dp)
.clip(RoundedCornerShape(14.dp)),
model = mediaUrl,
contentDescription = stringResource(R.string.plugins_changelog_media)
)
}
LazyColumn {
items(plugin.manifest.changelog!!.lines()) {
var line = it.trim()
if (line.isNotEmpty()) {
when (line[0]) {
'#' -> {
do {
line = line.substring(1)
} while (line.startsWith("#"))
Text(
text = line.trimStart(),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp)
)
}
'*' -> {
Text(
modifier = Modifier.padding(bottom = 2.dp),
text = buildAnnotatedString {
withStyle(
SpanStyle(
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
) {
append("")
}
MarkdownHyperlink(line.substring(1))
}
)
}
else -> {
when {
line.endsWith("marginTop}") -> {
val color = MaterialTheme.colorScheme.onSurface
Text(
text = line,
fontWeight = FontWeight.Bold,
color = color,
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp)
)
}
line.all { c -> c == '=' } -> {} // Tidal ignores =======
else -> {
Text(buildAnnotatedString {
MarkdownHyperlink(line)
})
}
}
}
}
}
}
}
}
},
confirmButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.action_close))
}
}
)
}
@@ -1,150 +0,0 @@
package com.meowarex.rlmobile.ui.screens.plugins.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem
@Composable
fun PluginCard(
plugin: PluginItem,
onClickDelete: () -> Unit,
onClickShowChangelog: () -> Unit,
onSetEnabled: (Boolean) -> Unit,
) {
val uriHandler = LocalUriHandler.current
ElevatedCard {
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable { onSetEnabled(!plugin.enabled) }
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 14.dp),
) {
Column {
// Name
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(plugin.manifest.name)
}
append(" v")
append(plugin.manifest.version)
}
)
// Authors
val authors = buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
for ((idx, author) in plugin.manifest.authors.withIndex()) {
if (idx > 0) append(", ")
if (author.hyperlink) pushLink(
LinkAnnotation.Url(
url = author.socialUrl,
styles = TextLinkStyles(
SpanStyle(
textDecoration = TextDecoration.Underline,
)
),
)
)
append(author.name)
if (author.hyperlink) pop()
}
}
}
Text(
text = authors,
style = MaterialTheme.typography.labelLarge,
)
}
Spacer(Modifier.weight(1f, true))
// Toggle Switch
Switch(
checked = plugin.enabled,
onCheckedChange = { onSetEnabled(!plugin.enabled) }
)
}
HorizontalDivider(
modifier = Modifier
.alpha(0.3f)
.padding(horizontal = 16.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Description
Text(
text = plugin.manifest.description,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.heightIn(max = 150.dp, min = 40.dp)
.padding(bottom = 20.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
plugin.manifest.repositoryUrl?.let { repositoryUrl ->
IconButton(
onClick = { uriHandler.openUri(repositoryUrl) },
modifier = Modifier.size(25.dp),
) {
Icon(
modifier = Modifier.fillMaxSize(),
painter = painterResource(R.drawable.ic_account_github_white_24dp),
contentDescription = stringResource(R.string.github)
)
}
}
if (plugin.manifest.changelog != null) {
IconButton(
onClick = onClickShowChangelog,
modifier = Modifier.size(25.dp),
) {
Icon(
painter = painterResource(R.drawable.ic_history),
contentDescription = stringResource(R.string.plugins_view_changelog),
modifier = Modifier.fillMaxSize(),
)
}
}
Spacer(Modifier.weight(1f, true))
IconButton(
onClick = onClickDelete,
modifier = Modifier.size(25.dp),
) {
Icon(
modifier = Modifier.fillMaxSize(),
painter = painterResource(R.drawable.ic_delete_forever),
contentDescription = stringResource(R.string.action_uninstall),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
@@ -1,46 +0,0 @@
package com.meowarex.rlmobile.ui.screens.plugins.components
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.ResetToDefaultButton
@Composable
fun PluginSearch(
currentFilter: State<String>,
onFilterChange: (String) -> Unit,
modifier: Modifier = Modifier.Companion,
) {
val focusManager = LocalFocusManager.current
OutlinedTextField(
value = currentFilter.value,
onValueChange = onFilterChange,
singleLine = true,
shape = MaterialTheme.shapes.medium,
label = { Text(stringResource(R.string.action_search)) },
trailingIcon = {
val isFilterBlank by remember { derivedStateOf { currentFilter.value.isEmpty() } }
ResetToDefaultButton(
enabled = !isFilterBlank,
onClick = { onFilterChange("") },
modifier = Modifier.padding(end = 4.dp),
)
},
keyboardOptions = KeyboardOptions(
autoCorrectEnabled = false,
imeAction = ImeAction.Companion.Search
),
keyboardActions = KeyboardActions { focusManager.clearFocus() },
modifier = modifier,
)
}
@@ -1,35 +0,0 @@
package com.meowarex.rlmobile.ui.screens.plugins.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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 com.meowarex.rlmobile.R
@Composable
fun PluginsError(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier,
) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(R.drawable.ic_warning),
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Text(
text = stringResource(R.string.plugins_error),
color = MaterialTheme.colorScheme.error,
)
}
}
}
@@ -1,31 +0,0 @@
package com.meowarex.rlmobile.ui.screens.plugins.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.meowarex.rlmobile.R
@Composable
fun PluginsNone(modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier,
) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(R.drawable.ic_extension_off),
contentDescription = null
)
Text(stringResource(R.string.plugins_none_installed))
}
}
}
@@ -1,56 +0,0 @@
package com.meowarex.rlmobile.ui.screens.plugins.components.dialogs
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
@Composable
fun UninstallPluginDialog(
pluginName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
painter = painterResource(R.drawable.ic_delete_forever),
contentDescription = null,
modifier = Modifier.size(32.dp),
)
},
title = {
Text(stringResource(R.string.plugins_delete_plugin, pluginName))
},
text = {
Text(
text = stringResource(R.string.plugins_delete_plugin_body, pluginName),
textAlign = TextAlign.Center,
)
},
confirmButton = {
Button(
onClick = onConfirm,
) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
@@ -1,12 +0,0 @@
package com.meowarex.rlmobile.ui.screens.plugins.model
import androidx.compose.runtime.*
@Stable
data class PluginItem(
val manifest: PluginManifest,
val path: String,
) {
// Plugins are enabled by default unless disabled in Radiant Lyrics settings
var enabled by mutableStateOf(true)
}
@@ -1,36 +0,0 @@
package com.meowarex.rlmobile.ui.screens.plugins.model
import androidx.compose.runtime.Immutable
import com.meowarex.rlmobile.util.serialization.ImmutableListSerializer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.serialization.Serializable
@Immutable
@Serializable
data class PluginManifest(
val name: String,
@Serializable(with = ImmutableListSerializer::class)
val authors: ImmutableList<Author>,
val description: String,
val version: String,
val updateUrl: String?,
val changelog: String?,
val changelogMedia: String?,
) {
val repositoryUrl: String?
get() = updateUrl?.replaceFirst(
"https://(raw\\.githubusercontent\\.com|cdn\\.jsdelivr\\.net/gh)/([^/]+)/([^/@]+).*".toRegex(),
"https://github.com/$2/$3"
)
@Immutable
@Serializable
data class Author(
val name: String,
val id: Long,
val hyperlink: Boolean = true,
) {
val socialUrl: String
get() = "https://tidal.com/users/$id"
}
}
@@ -13,6 +13,7 @@ import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.di.ActivityProvider import com.meowarex.rlmobile.di.ActivityProvider
import com.meowarex.rlmobile.manager.* import com.meowarex.rlmobile.manager.*
import com.meowarex.rlmobile.ui.theme.Theme import com.meowarex.rlmobile.ui.theme.Theme
import com.meowarex.rlmobile.updatechecker.UpdateCheckWorker
import com.meowarex.rlmobile.util.* import com.meowarex.rlmobile.util.*
class SettingsModel( class SettingsModel(
@@ -58,6 +59,11 @@ class SettingsModel(
preferences.keepPatchedApks = value preferences.keepPatchedApks = value
} }
fun setAutoUpdateCheck(value: Boolean) {
preferences.autoUpdateCheck = value
if (value) UpdateCheckWorker.schedule(application) else UpdateCheckWorker.cancel(application)
}
fun clearCache() = screenModelScope.launchIO { fun clearCache() = screenModelScope.launchIO {
paths.clearCache() paths.clearCache()
@@ -99,6 +99,16 @@ class SettingsScreen : Screen, Parcelable {
} }
} }
item(key = "SETTING_AUTO_UPDATE_CHECK", contentType = "SETTING_SWITCH") {
SettingsSwitch(
label = stringResource(R.string.setting_auto_update_check),
secondaryLabel = stringResource(R.string.setting_auto_update_check_desc),
pref = preferences.autoUpdateCheck,
icon = { Icon(painterResource(R.drawable.ic_update), null) },
onPrefChange = { model.setAutoUpdateCheck(it) },
)
}
item(key = "HEADER_INSTALL", contentType = "DIVIDER") { item(key = "HEADER_INSTALL", contentType = "DIVIDER") {
SettingsHeader(stringResource(R.string.settings_header_installation)) SettingsHeader(stringResource(R.string.settings_header_installation))
} }
@@ -0,0 +1,113 @@
package com.meowarex.rlmobile.updatechecker
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import androidx.work.*
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.PreferencesManager
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
import com.meowarex.rlmobile.network.utils.SemVer
import com.meowarex.rlmobile.network.utils.fold
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.util.concurrent.TimeUnit
private const val CHANNEL_ID = "rl_updates"
private const val NOTIFICATION_ID = 4242
private const val WORK_NAME = "rl_update_check"
class UpdateCheckWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params), KoinComponent {
private val github: RadiantLyricsGithubService by inject()
private val prefs: PreferencesManager by inject()
override suspend fun doWork(): Result {
if (!prefs.autoUpdateCheck) return Result.success()
val current = SemVer.parseOrNull(BuildConfig.VERSION_NAME) ?: return Result.success()
val releases = github.getManagerReleases().fold(
success = { it },
fail = { return Result.retry() },
)
val latestVersion = releases
.mapNotNull { r -> SemVer.parseOrNull((r.name ?: "").removePrefix("v"))?.let { v -> v to r } }
.maxByOrNull { it.first }
?: return Result.success()
val (version, release) = latestVersion
if (current >= version) return Result.success()
Log.i(BuildConfig.TAG, "Update available: $version (installed $current)")
postUpdateNotification(version.toString(), release.htmlUrl)
return Result.success()
}
private fun postUpdateNotification(version: String, releaseUrl: String) {
val nm = applicationContext.getSystemService<NotificationManager>() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
applicationContext.getString(R.string.notif_channel_updates),
NotificationManager.IMPORTANCE_DEFAULT,
)
nm.createNotificationChannel(channel)
}
if (Build.VERSION.SDK_INT >= 33 &&
ActivityCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
Log.w(BuildConfig.TAG, "Notification permission not granted; skipping update notification")
return
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,
0,
Intent(Intent.ACTION_VIEW, Uri.parse(releaseUrl)).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) },
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notif = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(applicationContext.getString(R.string.notif_update_title))
.setContentText(applicationContext.getString(R.string.notif_update_text, version))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
nm.notify(NOTIFICATION_ID, notif)
}
companion object {
fun schedule(context: Context) {
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(6, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request,
)
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
}
@@ -9,14 +9,14 @@
android:translateX="16" android:translateX="16"
android:translateY="18"> android:translateY="18">
<path <path
android:fillColor="#00C2D7" android:fillColor="#FFE0EC"
android:pathData="M13.837,2.948C14.253,1.893 15.747,1.893 16.163,2.948L17.629,6.667C17.756,6.989 18.011,7.244 18.333,7.371L22.052,8.837C23.107,9.253 23.107,10.747 22.052,11.163L18.333,12.629C18.011,12.756 17.756,13.011 17.629,13.333L16.163,17.052C15.747,18.107 14.253,18.107 13.837,17.052L12.371,13.333C12.244,13.011 11.989,12.756 11.667,12.629L7.948,11.163C6.893,10.747 6.893,9.253 7.948,8.837L11.667,7.371C11.989,7.244 12.244,6.989 12.371,6.667L13.837,2.948Z" /> android:pathData="M13.837,2.948C14.253,1.893 15.747,1.893 16.163,2.948L17.629,6.667C17.756,6.989 18.011,7.244 18.333,7.371L22.052,8.837C23.107,9.253 23.107,10.747 22.052,11.163L18.333,12.629C18.011,12.756 17.756,13.011 17.629,13.333L16.163,17.052C15.747,18.107 14.253,18.107 13.837,17.052L12.371,13.333C12.244,13.011 11.989,12.756 11.667,12.629L7.948,11.163C6.893,10.747 6.893,9.253 7.948,8.837L11.667,7.371C11.989,7.244 12.244,6.989 12.371,6.667L13.837,2.948Z" />
<path <path
android:fillColor="#00C2D7" android:fillColor="#FFE0EC"
android:fillAlpha="0.6" android:fillAlpha="0.6"
android:pathData="M5.322,13.72C5.564,13.104 6.436,13.104 6.678,13.72L7.581,16.008C7.655,16.196 7.804,16.345 7.992,16.419L10.28,17.322C10.896,17.564 10.896,18.436 10.28,18.678L7.992,19.581C7.804,19.655 7.655,19.804 7.581,19.992L6.678,22.28C6.436,22.896 5.564,22.896 5.322,22.28L4.419,19.992C4.345,19.804 4.196,19.655 4.008,19.581L1.72,18.678C1.104,18.436 1.104,17.564 1.72,17.322L4.008,16.419C4.196,16.345 4.345,16.196 4.419,16.008L5.322,13.72Z" /> android:pathData="M5.322,13.72C5.564,13.104 6.436,13.104 6.678,13.72L7.581,16.008C7.655,16.196 7.804,16.345 7.992,16.419L10.28,17.322C10.896,17.564 10.896,18.436 10.28,18.678L7.992,19.581C7.804,19.655 7.655,19.804 7.581,19.992L6.678,22.28C6.436,22.896 5.564,22.896 5.322,22.28L4.419,19.992C4.345,19.804 4.196,19.655 4.008,19.581L1.72,18.678C1.104,18.436 1.104,17.564 1.72,17.322L4.008,16.419C4.196,16.345 4.345,16.196 4.419,16.008L5.322,13.72Z" />
<path <path
android:fillColor="#00C2D7" android:fillColor="#FFE0EC"
android:fillAlpha="0.3" android:fillAlpha="0.3"
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" /> 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" />
</group> </group>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#0A1929</color> <color name="ic_launcher_background">#B91D6F</color>
</resources> </resources>
@@ -7,6 +7,14 @@
<string name="github" translatable="false">GitHub</string> <string name="github" translatable="false">GitHub</string>
<string name="support_server">Support Server</string> <string name="support_server">Support Server</string>
<string name="installer">Installer</string> <string name="installer">Installer</string>
<string name="navigation_home">Home</string>
<string name="notif_channel_updates">Manager updates</string>
<string name="notif_update_title">Manager update available</string>
<string name="notif_update_text">v%1$s is now available</string>
<string name="setting_auto_update_check">Background update check</string>
<string name="setting_auto_update_check_desc">Show a notification when a new Manager version is released</string>
<string name="action_cancel">Cancel</string> <string name="action_cancel">Cancel</string>
<string name="action_retry">Retry</string> <string name="action_retry">Retry</string>
@@ -109,6 +117,7 @@
<string name="navigation_about">About</string> <string name="navigation_about">About</string>
<string name="navigation_logs">Logs</string> <string name="navigation_logs">Logs</string>
<string name="navigation_settings">Settings</string> <string name="navigation_settings">Settings</string>
<string name="navigation_refresh">Refresh</string>
<string name="contributors_lead">Lead</string> <string name="contributors_lead">Lead</string>
<string name="contributors">Contributors</string> <string name="contributors">Contributors</string>
+4
View File
@@ -4,7 +4,9 @@ agp = "9.0.0"
androidx-activity = "1.12.2" androidx-activity = "1.12.2"
androidx-core = "1.17.0" androidx-core = "1.17.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.10.0"
androidx-paging = "3.3.6"
androidx-splashscreen = "1.2.0" androidx-splashscreen = "1.2.0"
androidx-work = "2.10.0"
apksig = "9.0.0" apksig = "9.0.0"
axml = "1.0.1" axml = "1.0.1"
binary-resources = "2.1.0" binary-resources = "2.1.0"
@@ -43,6 +45,8 @@ androidx-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose",
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
# Coil (image library) # Coil (image library)
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }