mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-06-17 21:13:11 +10:00
Rewrite Manager UI <3
This commit is contained in:
@@ -191,6 +191,9 @@ dependencies {
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
debugImplementation(libs.compose.runtime.tracing)
|
||||
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
|
||||
implementation(libs.kotlinx.immutable)
|
||||
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.permissions.PermissionsModel
|
||||
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.widgets.updater.UpdaterDialog
|
||||
import com.meowarex.rlmobile.util.*
|
||||
@@ -123,16 +122,6 @@ class MainActivity : ComponentActivity() {
|
||||
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 -> {
|
||||
val path = intent.getStringExtra(EXTRA_FILE_PATH) ?: run {
|
||||
Log.w(BuildConfig.TAG, "Missing $EXTRA_FILE_PATH extra for intent $INTENT_IMPORT_COMPONENT")
|
||||
@@ -200,7 +189,6 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
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 EXTRA_PACKAGE_NAME = "rlmobile.packageName"
|
||||
|
||||
@@ -22,9 +22,9 @@ import com.meowarex.rlmobile.ui.screens.logs.LogsListScreenModel
|
||||
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenModel
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsModel
|
||||
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.widgets.updater.UpdaterViewModel
|
||||
import com.meowarex.rlmobile.updatechecker.UpdateCheckWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
@@ -57,7 +57,6 @@ class ManagerApplication : Application() {
|
||||
// UI Models
|
||||
modules(module {
|
||||
factoryOf(::HomeModel)
|
||||
factoryOf(::PluginsModel)
|
||||
factoryOf(::AboutModel)
|
||||
factoryOf(::PatchingScreenModel)
|
||||
factoryOf(::SettingsModel)
|
||||
@@ -101,5 +100,8 @@ class ManagerApplication : Application() {
|
||||
.fetcherCoroutineContext(Dispatchers.IO.limitedParallelism(5))
|
||||
.build()
|
||||
}
|
||||
|
||||
// Schedule periodic update check
|
||||
UpdateCheckWorker.schedule(this)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-10
@@ -12,16 +12,19 @@ import kotlinx.parcelize.Parcelize
|
||||
* that is captured by a receiver into something human readable.
|
||||
*/
|
||||
@Parcelize
|
||||
data class PMInstallerError(val status: Int) : InstallerResult.Error(), Parcelable {
|
||||
override fun getDebugReason() = when (status) {
|
||||
PackageInstaller.STATUS_FAILURE -> "Unknown failure"
|
||||
PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked"
|
||||
PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package"
|
||||
PackageInstaller.STATUS_FAILURE_CONFLICT -> "Package conflict"
|
||||
PackageInstaller.STATUS_FAILURE_STORAGE -> "Storage error"
|
||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Device incompatibility"
|
||||
/* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> "Operation timeout"
|
||||
else -> "Unknown code ($status)"
|
||||
data class PMInstallerError(val status: Int, val message: String? = null) : InstallerResult.Error(), Parcelable {
|
||||
override fun getDebugReason(): String {
|
||||
val reason = when (status) {
|
||||
PackageInstaller.STATUS_FAILURE -> "Unknown failure"
|
||||
PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked"
|
||||
PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package"
|
||||
PackageInstaller.STATUS_FAILURE_CONFLICT -> "Package conflict"
|
||||
PackageInstaller.STATUS_FAILURE_STORAGE -> "Storage error"
|
||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Device incompatibility"
|
||||
/* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> "Operation timeout"
|
||||
else -> "Unknown code ($status)"
|
||||
}
|
||||
return if (message != null) "$reason: $message" else reason
|
||||
}
|
||||
|
||||
override fun getLocalizedReason(context: Context): String {
|
||||
|
||||
@@ -60,13 +60,14 @@ class PMIntentReceiver : BroadcastReceiver() {
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallerResult.Cancelled(systemTriggered = true)
|
||||
|
||||
else -> {
|
||||
Log.w(BuildConfig.TAG, "PM failed with error code $status")
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
Log.w(BuildConfig.TAG, "PM failed with error code $status: $message")
|
||||
|
||||
if (status <= PackageInstaller.STATUS_SUCCESS) {
|
||||
// Unknown status code (not an error)
|
||||
return
|
||||
} else {
|
||||
PMInstallerError(status).also {
|
||||
PMInstallerError(status, message).also {
|
||||
Toast.makeText(
|
||||
/* context = */ context,
|
||||
/* text = */ it.getLocalizedReason(context),
|
||||
|
||||
@@ -14,4 +14,5 @@ class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager
|
||||
var keepPatchedApks by booleanPreference("keep_patched_apks", false)
|
||||
var showNetworkWarning by booleanPreference("show_network_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,
|
||||
)
|
||||
+27
@@ -45,6 +45,33 @@ class RadiantLyricsGithubService(
|
||||
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 {
|
||||
const val REPO_OWNER = BuildConfig.PATCHES_REPO_OWNER
|
||||
const val REPO_NAME = BuildConfig.PATCHES_REPO_NAME
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
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 { state.closestPageToPosition(it)?.prevKey }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -38,11 +38,25 @@ class SmaliPatchStep : Step(), IDexProvider, KoinComponent {
|
||||
|
||||
val patches = mutableListOf<LoadedPatch>()
|
||||
|
||||
// Load and parse all the patches from the smali patch archive
|
||||
// Load and parse all the patches from the smali patch archive.
|
||||
// Extension classes (extension/**/*.smali) are extracted into smaliDir
|
||||
// so they get assembled into the new dex alongside patched classes.
|
||||
container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}")
|
||||
smaliDir.mkdirs()
|
||||
ZipReader(patchesZip).use { zip ->
|
||||
for (patchFile in zip.entryNames) {
|
||||
container.log("Parsing patch file $patchFile")
|
||||
if (patchFile.endsWith("/")) continue
|
||||
|
||||
if (patchFile.endsWith(".smali") && patchFile.startsWith("extension/")) {
|
||||
val relative = patchFile.removePrefix("extension/")
|
||||
val out = smaliDir.resolve(relative)
|
||||
out.parentFile?.mkdirs()
|
||||
out.writeBytes(zip.openEntry(patchFile)!!.read())
|
||||
container.log("Extracted extension smali: $relative")
|
||||
continue
|
||||
}
|
||||
|
||||
if (!patchFile.endsWith(".patch")) continue
|
||||
|
||||
val lines = zip.openEntry(patchFile)!!.read()
|
||||
|
||||
@@ -15,6 +15,9 @@ object ManifestPatcher {
|
||||
private const val PACKAGE = "package"
|
||||
private const val COMPILE_SDK_VERSION = "compileSdkVersion"
|
||||
private const val COMPILE_SDK_VERSION_CODENAME = "compileSdkVersionCodename"
|
||||
private const val IS_SPLIT_REQUIRED = "isSplitRequired"
|
||||
private const val REQUIRED_SPLIT_TYPES = "requiredSplitTypes"
|
||||
private const val SPLIT_TYPES = "splitTypes"
|
||||
|
||||
fun patchManifest(
|
||||
manifestBytes: ByteArray,
|
||||
@@ -22,6 +25,18 @@ object ManifestPatcher {
|
||||
appName: String,
|
||||
debuggable: Boolean,
|
||||
): ByteArray {
|
||||
// Extract original package name so we can rewrite every reference to it
|
||||
// (permissions, provider authorities) to the new packageName.
|
||||
var originalPackage: String? = null
|
||||
AxmlReader(manifestBytes).accept(object : AxmlVisitor() {
|
||||
override fun child(ns: String?, name: String?) = object : NodeVisitor() {
|
||||
override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) {
|
||||
if (name == PACKAGE && value is String) originalPackage = value
|
||||
}
|
||||
}
|
||||
})
|
||||
val origPkg = originalPackage ?: "com.aspiro.tidal"
|
||||
|
||||
val reader = AxmlReader(manifestBytes)
|
||||
val writer = AxmlWriter()
|
||||
|
||||
@@ -42,6 +57,11 @@ object ManifestPatcher {
|
||||
COMPILE_SDK_VERSION_CODENAME to "6.0-2438415"
|
||||
)
|
||||
) {
|
||||
// Drop split-only manifest attributes — we merged all splits into one APK
|
||||
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
|
||||
if (name == IS_SPLIT_REQUIRED || name == REQUIRED_SPLIT_TYPES || name == SPLIT_TYPES) return
|
||||
super.attr(ns, name, resourceId, type, value)
|
||||
}
|
||||
private var addExternalStoragePerm = false
|
||||
|
||||
override fun child(ns: String?, name: String): NodeVisitor {
|
||||
@@ -84,7 +104,7 @@ object ManifestPatcher {
|
||||
super.attr(
|
||||
ns, name, resourceId, type,
|
||||
when (name) {
|
||||
"name" -> (value as String).replace("com.tidal.android", packageName)
|
||||
"name" -> (value as String).replace(origPkg, packageName)
|
||||
else -> value
|
||||
}
|
||||
)
|
||||
@@ -135,7 +155,7 @@ object ManifestPatcher {
|
||||
super.attr(
|
||||
ns, name, resourceId, type,
|
||||
if (name == "authorities") {
|
||||
(value as String).replace("com.tidal.android", packageName)
|
||||
(value as String).replace(origPkg, packageName)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
|
||||
-20
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
-22
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-77
@@ -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,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
-24
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-25
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-31
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
-94
@@ -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,
|
||||
)
|
||||
),
|
||||
)
|
||||
-31
@@ -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
|
||||
|
||||
import android.util.Log
|
||||
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.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.util.launchIO
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
class AboutModel(
|
||||
@Suppress("unused") private val http: HttpService,
|
||||
) : StateScreenModel<AboutScreenState>(
|
||||
AboutScreenState.Loaded(emptyList<Contributor>().toUnsafeImmutable())
|
||||
) {
|
||||
fun fetchContributors() = Unit
|
||||
private val github: RadiantLyricsGithubService,
|
||||
) : StateScreenModel<AboutScreenState>(AboutScreenState.Loading) {
|
||||
|
||||
init {
|
||||
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 cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.*
|
||||
import com.meowarex.rlmobile.ui.screens.about.components.LeadContributor
|
||||
@@ -69,7 +70,7 @@ fun AboutScreenContent(state: State<AboutScreenState>) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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 -> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,19 +11,23 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
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.screenModelScope
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.network.models.GithubCommit
|
||||
import com.meowarex.rlmobile.network.models.RLBuildInfo
|
||||
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.patcher.InstallMetadata
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
|
||||
import com.meowarex.rlmobile.ui.util.TidalVersion
|
||||
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -36,9 +40,14 @@ class HomeModel(
|
||||
private val github: RadiantLyricsGithubService,
|
||||
private val json: Json,
|
||||
) : ScreenModel {
|
||||
var installsState by mutableStateOf<InstallsState>(InstallsState.Fetching)
|
||||
|
||||
var state by mutableStateOf<HomeState>(HomeState.Loading)
|
||||
private set
|
||||
|
||||
val commits = Pager(PagingConfig(pageSize = 30)) {
|
||||
CommitsPagingSource(github)
|
||||
}.flow.cachedIn(screenModelScope)
|
||||
|
||||
private val refreshingLock = Mutex()
|
||||
private var remoteDataJson: RLBuildInfo? = null
|
||||
|
||||
@@ -48,36 +57,45 @@ class HomeModel(
|
||||
|
||||
fun refresh(delay: Boolean = false) = screenModelScope.launchIO {
|
||||
if (refreshingLock.isLocked) return@launchIO
|
||||
|
||||
if (delay) {
|
||||
delay(250)
|
||||
|
||||
if (refreshingLock.isLocked)
|
||||
return@launchIO
|
||||
if (refreshingLock.isLocked) return@launchIO
|
||||
}
|
||||
|
||||
refreshingLock.withLock {
|
||||
val packages = fetchRadiantLyricsPackages()
|
||||
val pkg = fetchInstalled()
|
||||
val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() }
|
||||
remote.await()
|
||||
|
||||
val jobs = listOf(
|
||||
screenModelScope.launch(Dispatchers.IO) {
|
||||
fetchInstallations(packages)
|
||||
},
|
||||
screenModelScope.launch(Dispatchers.IO) {
|
||||
if (remoteDataJson == null)
|
||||
fetchRemoteData()
|
||||
}
|
||||
)
|
||||
val install = pkg?.toInstallData()
|
||||
val latest = remoteDataJson?.tidalVersionCode
|
||||
|
||||
jobs.joinAll()
|
||||
mainThread { refreshInstallationsUpToDate(packages) }
|
||||
mainThread {
|
||||
state = HomeState.Loaded(
|
||||
install = install,
|
||||
latestTidalVersionCode = latest,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openApp(packageName: String) {
|
||||
val launchIntent = application.packageManager
|
||||
.getLaunchIntentForPackage(packageName)
|
||||
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) {
|
||||
val launchIntent = application.packageManager.getLaunchIntentForPackage(packageName)
|
||||
if (launchIntent != null) {
|
||||
application.startActivity(launchIntent)
|
||||
} else {
|
||||
@@ -89,7 +107,6 @@ class HomeModel(
|
||||
val launchIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.setData("package:$packageName".toUri())
|
||||
|
||||
application.startActivity(launchIntent)
|
||||
}
|
||||
|
||||
@@ -98,146 +115,74 @@ class HomeModel(
|
||||
val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0)
|
||||
val metadataFile = ZipReader(applicationInfo.publicSourceDir)
|
||||
.use { it.openEntry("rlmobile.json")?.read() }
|
||||
|
||||
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
|
||||
} 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
|
||||
}
|
||||
|
||||
val patchOptions = metadata?.options
|
||||
?: PatchOptions.Default.copy(packageName = packageName)
|
||||
|
||||
val patchOptions = metadata?.options ?: PatchOptions.Default.copy(packageName = packageName)
|
||||
return PatchOptionsScreen(prefilledOptions = patchOptions)
|
||||
}
|
||||
|
||||
private suspend fun fetchInstallations(packages: List<PackageInfo>) {
|
||||
mainThread {
|
||||
if (installsState !is InstallsState.Fetched)
|
||||
installsState = InstallsState.Fetching
|
||||
}
|
||||
private fun fetchInstalled(): PackageInfo? = application.packageManager
|
||||
.getInstalledPackages(PackageManager.GET_META_DATA)
|
||||
.firstOrNull { it.applicationInfo?.metaData?.containsKey("isRadiantLyrics") == true }
|
||||
|
||||
try {
|
||||
val packageManager = application.packageManager
|
||||
val rlMobileInstallations = packages.mapNotNull { pkg ->
|
||||
@Suppress("DEPRECATION")
|
||||
val versionCode = pkg.versionCode
|
||||
val versionName = pkg.versionName ?: return@mapNotNull null
|
||||
val applicationInfo = pkg.applicationInfo ?: return@mapNotNull null
|
||||
|
||||
InstallData(
|
||||
name = packageManager.getApplicationLabel(applicationInfo).toString(),
|
||||
packageName = pkg.packageName,
|
||||
isUpToDate = isInstallationUpToDate(pkg),
|
||||
icon = packageManager
|
||||
.getApplicationIcon(applicationInfo)
|
||||
.toBitmap()
|
||||
.asImageBitmap()
|
||||
.let(::BitmapPainter),
|
||||
version = TidalVersion.Existing(
|
||||
type = TidalVersion.parseVersionType(versionCode),
|
||||
name = versionName.split("-")[0].trim(),
|
||||
code = versionCode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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 fun PackageInfo.toInstallData(): InstallData {
|
||||
val pm = application.packageManager
|
||||
@Suppress("DEPRECATION") val versionCode = versionCode
|
||||
val versionName = versionName ?: ""
|
||||
val info = applicationInfo!!
|
||||
return InstallData(
|
||||
name = pm.getApplicationLabel(info).toString(),
|
||||
packageName = packageName,
|
||||
isUpToDate = isInstallationUpToDate(this),
|
||||
icon = pm.getApplicationIcon(info).toBitmap().asImageBitmap().let(::BitmapPainter),
|
||||
version = TidalVersion.Existing(
|
||||
type = TidalVersion.parseVersionType(versionCode),
|
||||
name = versionName.split("-")[0].trim(),
|
||||
code = versionCode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchRemoteData() {
|
||||
val release = try {
|
||||
github.getLatestRelease().let { response ->
|
||||
response.fold(
|
||||
success = { it },
|
||||
fail = {
|
||||
Log.w(BuildConfig.TAG, "Failed to fetch latest release", it)
|
||||
return
|
||||
},
|
||||
)
|
||||
}
|
||||
github.getLatestRelease().fold(
|
||||
success = { it },
|
||||
fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest release", it); return },
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(BuildConfig.TAG, "Failed to fetch remote data", t)
|
||||
mainThread { application.showToast(R.string.home_network_fail) }
|
||||
return
|
||||
}
|
||||
|
||||
val dataJsonUrl = release.assets
|
||||
.find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME }
|
||||
?.browserDownloadUrl
|
||||
?: run {
|
||||
Log.w(BuildConfig.TAG, "No data.json asset in latest release")
|
||||
return
|
||||
}
|
||||
?: return
|
||||
|
||||
github.getBuildInfo(dataJsonUrl).fold(
|
||||
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? {
|
||||
val remoteBuildData = remoteDataJson ?: return null
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val versionCode = pkg.versionCode
|
||||
|
||||
if (remoteBuildData.tidalVersionCode != versionCode) return false
|
||||
val remote = remoteDataJson ?: return null
|
||||
@Suppress("DEPRECATION") val versionCode = pkg.versionCode
|
||||
if (remote.tidalVersionCode != versionCode) return false
|
||||
|
||||
val apkPath = pkg.applicationInfo?.publicSourceDir ?: return false
|
||||
val installMetadata = try {
|
||||
val metadataFile = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() }
|
||||
?: return false
|
||||
|
||||
json.decodeFromStream<InstallMetadata>(metadataFile.inputStream())
|
||||
val mf = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() } ?: return false
|
||||
json.decodeFromStream<InstallMetadata>(mf.inputStream())
|
||||
} catch (t: Throwable) {
|
||||
Log.d(BuildConfig.TAG, "Failed to parse Radiant Lyrics InstallMetadata from package ${pkg.packageName}", t)
|
||||
return false
|
||||
}
|
||||
|
||||
if (installMetadata.options.customPatches != null) return true
|
||||
|
||||
return remoteBuildData.patchesVersion == installMetadata.patchesVersion
|
||||
return remote.patchesVersion == installMetadata.patchesVersion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
|
||||
package com.meowarex.rlmobile.ui.screens.home
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.LoadFailure
|
||||
import com.meowarex.rlmobile.ui.components.ProjectHeader
|
||||
import com.meowarex.rlmobile.ui.screens.home.components.*
|
||||
import com.meowarex.rlmobile.ui.components.SegmentedButton
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreen
|
||||
import com.meowarex.rlmobile.ui.screens.home.components.CommitList
|
||||
import com.meowarex.rlmobile.ui.screens.logs.LogsListScreen
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen
|
||||
import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides
|
||||
import com.meowarex.rlmobile.ui.util.paddings.exclude
|
||||
import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@@ -44,180 +44,164 @@ class HomeScreen : Screen, Parcelable {
|
||||
val scope = rememberCoroutineScope()
|
||||
val model = koinScreenModel<HomeModel>()
|
||||
|
||||
// Refresh installations list when the screen changes or activity resumes
|
||||
LifecycleResumeEffect(Unit) {
|
||||
model.refresh(delay = true)
|
||||
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { HomeAppBar() },
|
||||
) { padding ->
|
||||
when (val state = model.installsState) {
|
||||
is InstallsState.Fetched -> HomeScreenLoadedContent(
|
||||
state = state,
|
||||
padding = padding,
|
||||
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) },
|
||||
onUpdate = {
|
||||
scope.launchIO {
|
||||
val screen = model.createPrefilledPatchOptsScreen(it)
|
||||
mainThread { navigator.push(screen) }
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.navigation_home)) },
|
||||
actions = {
|
||||
IconButton(onClick = { model.refresh() }) {
|
||||
Icon(painterResource(R.drawable.ic_refresh), contentDescription = null)
|
||||
}
|
||||
IconButton(onClick = { navigator.push(AboutScreen()) }) {
|
||||
Icon(painterResource(R.drawable.ic_info), contentDescription = null)
|
||||
}
|
||||
IconButton(onClick = { navigator.push(LogsListScreen()) }) {
|
||||
Icon(painterResource(R.drawable.ic_receipt), contentDescription = null)
|
||||
}
|
||||
IconButton(onClick = { navigator.push(SettingsScreen()) }) {
|
||||
Icon(painterResource(R.drawable.ic_settings), contentDescription = null)
|
||||
}
|
||||
},
|
||||
onOpenApp = model::openApp,
|
||||
onOpenAppInfo = model::openAppInfo,
|
||||
onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins
|
||||
)
|
||||
},
|
||||
) { 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() }
|
||||
|
||||
InstallsState.Fetching -> HomeScreenLoadingContent(padding = padding)
|
||||
|
||||
InstallsState.None -> HomeScreenNoneContent(
|
||||
padding = padding,
|
||||
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) },
|
||||
)
|
||||
|
||||
InstallsState.Error -> HomeScreenFailureContent(padding = padding)
|
||||
is HomeState.Loaded -> HomeContent(
|
||||
state = state,
|
||||
commits = model.commits,
|
||||
onInstall = { navigator.pushOnce(PatchOptionsScreen()) },
|
||||
onReinstall = {
|
||||
scope.launchIO {
|
||||
val screen = model.createReinstallScreen() ?: return@launchIO
|
||||
mainThread { navigator.push(screen) }
|
||||
}
|
||||
},
|
||||
onLaunch = model::launchInstall,
|
||||
onInfo = model::openCurrentAppInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreenLoadingContent(padding: PaddingValues) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
|
||||
) {
|
||||
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,
|
||||
private fun ColumnScope.HomeContent(
|
||||
state: HomeState.Loaded,
|
||||
commits: kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<com.meowarex.rlmobile.network.models.GithubCommit>>,
|
||||
onInstall: () -> Unit,
|
||||
onReinstall: () -> Unit,
|
||||
onLaunch: () -> Unit,
|
||||
onInfo: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
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()
|
||||
}
|
||||
val install = state.install
|
||||
val currentVersionName = install?.version?.let { "v${it.toString()}" }
|
||||
val latestVersionName = state.latestTidalVersionCode?.let { "build $it" }
|
||||
|
||||
item(key = "ADD_INSTALL_BUTTON") {
|
||||
InstallButton(
|
||||
secondaryInstall = true,
|
||||
onClick = onClickInstall,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.height(50.dp)
|
||||
.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,
|
||||
if (install?.icon != null) {
|
||||
Image(
|
||||
painter = install.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.height(height = 50.dp)
|
||||
.fillMaxWidth()
|
||||
.size(60.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.ic_launcher),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.alpha(.7f)
|
||||
.fillMaxSize()
|
||||
.padding(bottom = 80.dp)
|
||||
) {
|
||||
Text(
|
||||
text = """ /ᐠﹷ ‸ ﹷ ᐟ\ノ""",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
.copy(fontSize = 38.sp),
|
||||
)
|
||||
.size(60.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
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 = stringResource(R.string.installs_no_installs),
|
||||
text = "Current: ${currentVersionName ?: "-"}",
|
||||
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
|
||||
fun HomeScreenFailureContent(
|
||||
padding: PaddingValues,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
Button(
|
||||
onClick = if (install == null) onInstall else onReinstall,
|
||||
enabled = state.latestTidalVersionCode != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ProjectHeader()
|
||||
LoadFailure(modifier = Modifier.fillMaxSize())
|
||||
val label = when {
|
||||
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
|
||||
}
|
||||
+115
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-42
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
-88
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-123
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
-152
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-154
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
-150
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-46
@@ -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,
|
||||
)
|
||||
}
|
||||
-35
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-31
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
-56
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
-12
@@ -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)
|
||||
}
|
||||
-36
@@ -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.manager.*
|
||||
import com.meowarex.rlmobile.ui.theme.Theme
|
||||
import com.meowarex.rlmobile.updatechecker.UpdateCheckWorker
|
||||
import com.meowarex.rlmobile.util.*
|
||||
|
||||
class SettingsModel(
|
||||
@@ -58,6 +59,11 @@ class SettingsModel(
|
||||
preferences.keepPatchedApks = value
|
||||
}
|
||||
|
||||
fun setAutoUpdateCheck(value: Boolean) {
|
||||
preferences.autoUpdateCheck = value
|
||||
if (value) UpdateCheckWorker.schedule(application) else UpdateCheckWorker.cancel(application)
|
||||
}
|
||||
|
||||
fun clearCache() = screenModelScope.launchIO {
|
||||
paths.clearCache()
|
||||
|
||||
|
||||
+10
@@ -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") {
|
||||
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.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.MainActivity
|
||||
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(applicationContext, MainActivity::class.java).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:translateY="18">
|
||||
<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" />
|
||||
<path
|
||||
android:fillColor="#00C2D7"
|
||||
android:fillColor="#FFE0EC"
|
||||
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" />
|
||||
<path
|
||||
android:fillColor="#00C2D7"
|
||||
android:fillColor="#FFE0EC"
|
||||
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" />
|
||||
</group>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0A1929</color>
|
||||
<color name="ic_launcher_background">#B91D6F</color>
|
||||
</resources>
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
<string name="github" translatable="false">GitHub</string>
|
||||
<string name="support_server">Support Server</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_retry">Retry</string>
|
||||
|
||||
Reference in New Issue
Block a user