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:
@@ -56,19 +56,11 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [[ "$tidal_src" == *.apkm ]]; then
|
if [[ "$tidal_src" == *.apkm ]]; then
|
||||||
echo "Extracting & merging splits from $tidal_src"
|
echo "Merging splits from $tidal_src via APKEditor"
|
||||||
workdir=$(mktemp -d)
|
curl -sLo /tmp/APKEditor.jar https://github.com/REAndroid/APKEditor/releases/download/V1.4.3/APKEditor-1.4.3.jar
|
||||||
unzip -q "$tidal_src" -d "$workdir"
|
java -jar /tmp/APKEditor.jar m -i "$tidal_src" -o ./dist/tidal-stock.apk
|
||||||
ls "$workdir"
|
|
||||||
dist_abs=$(realpath ./dist)/tidal-stock.apk
|
|
||||||
|
|
||||||
javac scripts/MergeApk.java -d scripts
|
|
||||||
splits=("$workdir"/split_*.apk)
|
|
||||||
java -cp scripts MergeApk "$dist_abs" "$workdir/base.apk" "${splits[@]}"
|
|
||||||
|
|
||||||
rm -rf "$workdir"
|
|
||||||
echo "Merged tidal-stock.apk:"
|
echo "Merged tidal-stock.apk:"
|
||||||
ls -la "$dist_abs"
|
ls -la ./dist/tidal-stock.apk
|
||||||
else
|
else
|
||||||
cp "$tidal_src" ./dist/tidal-stock.apk
|
cp "$tidal_src" ./dist/tidal-stock.apk
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ dependencies {
|
|||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
debugImplementation(libs.compose.runtime.tracing)
|
debugImplementation(libs.compose.runtime.tracing)
|
||||||
|
|
||||||
|
implementation(libs.androidx.paging.compose)
|
||||||
|
implementation(libs.androidx.work.runtime)
|
||||||
|
|
||||||
implementation(libs.kotlinx.immutable)
|
implementation(libs.kotlinx.immutable)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen
|
|||||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||||
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel
|
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel
|
||||||
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreen
|
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreen
|
||||||
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen
|
|
||||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||||
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterDialog
|
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterDialog
|
||||||
import com.meowarex.rlmobile.util.*
|
import com.meowarex.rlmobile.util.*
|
||||||
@@ -123,16 +122,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
navigator.push(handleReinstall(packageName))
|
navigator.push(handleReinstall(packageName))
|
||||||
}
|
}
|
||||||
|
|
||||||
INTENT_OPEN_PLUGINS -> {
|
|
||||||
// TODO: per-install plugins screen
|
|
||||||
// val packageName = intent.getStringExtra(EXTRA_PACKAGE_NAME) ?: run {
|
|
||||||
// Log.w(BuildConfig.TAG, "Missing $EXTRA_PACKAGE_NAME extra for intent $INTENT_REINSTALL")
|
|
||||||
// return@launchBlock
|
|
||||||
// }
|
|
||||||
|
|
||||||
navigator.push(PluginsScreen())
|
|
||||||
}
|
|
||||||
|
|
||||||
INTENT_IMPORT_COMPONENT -> {
|
INTENT_IMPORT_COMPONENT -> {
|
||||||
val path = intent.getStringExtra(EXTRA_FILE_PATH) ?: run {
|
val path = intent.getStringExtra(EXTRA_FILE_PATH) ?: run {
|
||||||
Log.w(BuildConfig.TAG, "Missing $EXTRA_FILE_PATH extra for intent $INTENT_IMPORT_COMPONENT")
|
Log.w(BuildConfig.TAG, "Missing $EXTRA_FILE_PATH extra for intent $INTENT_IMPORT_COMPONENT")
|
||||||
@@ -200,7 +189,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val INTENT_REINSTALL = "com.meowarex.rlmobile.REINSTALL"
|
const val INTENT_REINSTALL = "com.meowarex.rlmobile.REINSTALL"
|
||||||
const val INTENT_OPEN_PLUGINS = "com.meowarex.rlmobile.OPEN_PLUGINS"
|
|
||||||
const val INTENT_IMPORT_COMPONENT = "com.meowarex.rlmobile.IMPORT_COMPONENT"
|
const val INTENT_IMPORT_COMPONENT = "com.meowarex.rlmobile.IMPORT_COMPONENT"
|
||||||
|
|
||||||
const val EXTRA_PACKAGE_NAME = "rlmobile.packageName"
|
const val EXTRA_PACKAGE_NAME = "rlmobile.packageName"
|
||||||
|
|||||||
@@ -22,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.patching.PatchingScreenModel
|
||||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsModel
|
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsModel
|
||||||
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel
|
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel
|
||||||
import com.meowarex.rlmobile.ui.screens.plugins.PluginsModel
|
|
||||||
import com.meowarex.rlmobile.ui.screens.settings.SettingsModel
|
import com.meowarex.rlmobile.ui.screens.settings.SettingsModel
|
||||||
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterViewModel
|
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterViewModel
|
||||||
|
import com.meowarex.rlmobile.updatechecker.UpdateCheckWorker
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
@@ -57,7 +57,6 @@ class ManagerApplication : Application() {
|
|||||||
// UI Models
|
// UI Models
|
||||||
modules(module {
|
modules(module {
|
||||||
factoryOf(::HomeModel)
|
factoryOf(::HomeModel)
|
||||||
factoryOf(::PluginsModel)
|
|
||||||
factoryOf(::AboutModel)
|
factoryOf(::AboutModel)
|
||||||
factoryOf(::PatchingScreenModel)
|
factoryOf(::PatchingScreenModel)
|
||||||
factoryOf(::SettingsModel)
|
factoryOf(::SettingsModel)
|
||||||
@@ -101,5 +100,8 @@ class ManagerApplication : Application() {
|
|||||||
.fetcherCoroutineContext(Dispatchers.IO.limitedParallelism(5))
|
.fetcherCoroutineContext(Dispatchers.IO.limitedParallelism(5))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule periodic update check
|
||||||
|
UpdateCheckWorker.schedule(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import kotlinx.parcelize.Parcelize
|
|||||||
* that is captured by a receiver into something human readable.
|
* that is captured by a receiver into something human readable.
|
||||||
*/
|
*/
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class PMInstallerError(val status: Int) : InstallerResult.Error(), Parcelable {
|
data class PMInstallerError(val status: Int, val message: String? = null) : InstallerResult.Error(), Parcelable {
|
||||||
override fun getDebugReason() = when (status) {
|
override fun getDebugReason(): String {
|
||||||
|
val reason = when (status) {
|
||||||
PackageInstaller.STATUS_FAILURE -> "Unknown failure"
|
PackageInstaller.STATUS_FAILURE -> "Unknown failure"
|
||||||
PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked"
|
PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked"
|
||||||
PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package"
|
PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package"
|
||||||
@@ -23,6 +24,8 @@ data class PMInstallerError(val status: Int) : InstallerResult.Error(), Parcelab
|
|||||||
/* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> "Operation timeout"
|
/* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> "Operation timeout"
|
||||||
else -> "Unknown code ($status)"
|
else -> "Unknown code ($status)"
|
||||||
}
|
}
|
||||||
|
return if (message != null) "$reason: $message" else reason
|
||||||
|
}
|
||||||
|
|
||||||
override fun getLocalizedReason(context: Context): String {
|
override fun getLocalizedReason(context: Context): String {
|
||||||
val string = when (status) {
|
val string = when (status) {
|
||||||
|
|||||||
@@ -60,13 +60,14 @@ class PMIntentReceiver : BroadcastReceiver() {
|
|||||||
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallerResult.Cancelled(systemTriggered = true)
|
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallerResult.Cancelled(systemTriggered = true)
|
||||||
|
|
||||||
else -> {
|
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) {
|
if (status <= PackageInstaller.STATUS_SUCCESS) {
|
||||||
// Unknown status code (not an error)
|
// Unknown status code (not an error)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
PMInstallerError(status).also {
|
PMInstallerError(status, message).also {
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
/* context = */ context,
|
/* context = */ context,
|
||||||
/* text = */ it.getLocalizedReason(context),
|
/* text = */ it.getLocalizedReason(context),
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager
|
|||||||
var keepPatchedApks by booleanPreference("keep_patched_apks", false)
|
var keepPatchedApks by booleanPreference("keep_patched_apks", false)
|
||||||
var showNetworkWarning by booleanPreference("show_network_warning", true)
|
var showNetworkWarning by booleanPreference("show_network_warning", true)
|
||||||
var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true)
|
var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true)
|
||||||
|
var autoUpdateCheck by booleanPreference("auto_update_check", true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.meowarex.rlmobile.network.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GithubCommit(
|
||||||
|
val sha: String,
|
||||||
|
@SerialName("html_url")
|
||||||
|
val htmlUrl: String,
|
||||||
|
val commit: CommitDetails,
|
||||||
|
val author: Author? = null,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class CommitDetails(
|
||||||
|
val message: String,
|
||||||
|
val author: AuthorMeta,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthorMeta(
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val date: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Author(
|
||||||
|
val login: String,
|
||||||
|
@SerialName("avatar_url")
|
||||||
|
val avatarUrl: String,
|
||||||
|
@SerialName("html_url")
|
||||||
|
val htmlUrl: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.meowarex.rlmobile.network.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GithubContributor(
|
||||||
|
val login: String,
|
||||||
|
@SerialName("avatar_url")
|
||||||
|
val avatarUrl: String,
|
||||||
|
val contributions: Int,
|
||||||
|
val type: String? = null,
|
||||||
|
)
|
||||||
+27
@@ -45,6 +45,33 @@ class RadiantLyricsGithubService(
|
|||||||
header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60")
|
header(HttpHeaders.CacheControl, "public, max-age=60, s-maxage=60")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the contributors list from GitHub for the repo.
|
||||||
|
*/
|
||||||
|
suspend fun getContributors(): ApiResponse<List<com.meowarex.rlmobile.network.models.GithubContributor>> =
|
||||||
|
http.request {
|
||||||
|
url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/contributors?per_page=100")
|
||||||
|
header(HttpHeaders.CacheControl, "public, max-age=600, s-maxage=600")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the latest commit on the default branch.
|
||||||
|
*/
|
||||||
|
suspend fun getLatestCommit(): ApiResponse<com.meowarex.rlmobile.network.models.GithubCommit> =
|
||||||
|
http.request {
|
||||||
|
url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/commits/HEAD")
|
||||||
|
header(HttpHeaders.CacheControl, "public, max-age=120, s-maxage=120")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a page of commits (paginated). Used by the Home screen's commit list.
|
||||||
|
*/
|
||||||
|
suspend fun getCommits(page: Int, perPage: Int = 30): ApiResponse<List<com.meowarex.rlmobile.network.models.GithubCommit>> =
|
||||||
|
http.request {
|
||||||
|
url("https://api.github.com/repos/${BuildConfig.PATCHES_REPO_OWNER}/${BuildConfig.PATCHES_REPO_NAME}/commits?per_page=$perPage&page=${page + 1}")
|
||||||
|
header(HttpHeaders.CacheControl, "public, max-age=120, s-maxage=120")
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val REPO_OWNER = BuildConfig.PATCHES_REPO_OWNER
|
const val REPO_OWNER = BuildConfig.PATCHES_REPO_OWNER
|
||||||
const val REPO_NAME = BuildConfig.PATCHES_REPO_NAME
|
const val REPO_NAME = BuildConfig.PATCHES_REPO_NAME
|
||||||
|
|||||||
+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>()
|
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}")
|
container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}")
|
||||||
|
smaliDir.mkdirs()
|
||||||
ZipReader(patchesZip).use { zip ->
|
ZipReader(patchesZip).use { zip ->
|
||||||
for (patchFile in zip.entryNames) {
|
for (patchFile in zip.entryNames) {
|
||||||
container.log("Parsing patch file $patchFile")
|
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
|
if (!patchFile.endsWith(".patch")) continue
|
||||||
|
|
||||||
val lines = zip.openEntry(patchFile)!!.read()
|
val lines = zip.openEntry(patchFile)!!.read()
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ object ManifestPatcher {
|
|||||||
private const val PACKAGE = "package"
|
private const val PACKAGE = "package"
|
||||||
private const val COMPILE_SDK_VERSION = "compileSdkVersion"
|
private const val COMPILE_SDK_VERSION = "compileSdkVersion"
|
||||||
private const val COMPILE_SDK_VERSION_CODENAME = "compileSdkVersionCodename"
|
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(
|
fun patchManifest(
|
||||||
manifestBytes: ByteArray,
|
manifestBytes: ByteArray,
|
||||||
@@ -22,6 +25,18 @@ object ManifestPatcher {
|
|||||||
appName: String,
|
appName: String,
|
||||||
debuggable: Boolean,
|
debuggable: Boolean,
|
||||||
): ByteArray {
|
): 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 reader = AxmlReader(manifestBytes)
|
||||||
val writer = AxmlWriter()
|
val writer = AxmlWriter()
|
||||||
|
|
||||||
@@ -42,6 +57,11 @@ object ManifestPatcher {
|
|||||||
COMPILE_SDK_VERSION_CODENAME to "6.0-2438415"
|
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
|
private var addExternalStoragePerm = false
|
||||||
|
|
||||||
override fun child(ns: String?, name: String): NodeVisitor {
|
override fun child(ns: String?, name: String): NodeVisitor {
|
||||||
@@ -84,7 +104,7 @@ object ManifestPatcher {
|
|||||||
super.attr(
|
super.attr(
|
||||||
ns, name, resourceId, type,
|
ns, name, resourceId, type,
|
||||||
when (name) {
|
when (name) {
|
||||||
"name" -> (value as String).replace("com.tidal.android", packageName)
|
"name" -> (value as String).replace(origPkg, packageName)
|
||||||
else -> value
|
else -> value
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -135,7 +155,7 @@ object ManifestPatcher {
|
|||||||
super.attr(
|
super.attr(
|
||||||
ns, name, resourceId, type,
|
ns, name, resourceId, type,
|
||||||
if (name == "authorities") {
|
if (name == "authorities") {
|
||||||
(value as String).replace("com.tidal.android", packageName)
|
(value as String).replace(origPkg, packageName)
|
||||||
} else {
|
} else {
|
||||||
value
|
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
|
package com.meowarex.rlmobile.ui.screens.about
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||||
|
import cafe.adriel.voyager.core.model.screenModelScope
|
||||||
|
import com.meowarex.rlmobile.BuildConfig
|
||||||
import com.meowarex.rlmobile.network.models.Contributor
|
import com.meowarex.rlmobile.network.models.Contributor
|
||||||
import com.meowarex.rlmobile.network.services.HttpService
|
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
|
||||||
|
import com.meowarex.rlmobile.network.utils.ApiResponse
|
||||||
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
|
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
|
||||||
|
import com.meowarex.rlmobile.util.launchIO
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
class AboutModel(
|
class AboutModel(
|
||||||
@Suppress("unused") private val http: HttpService,
|
private val github: RadiantLyricsGithubService,
|
||||||
) : StateScreenModel<AboutScreenState>(
|
) : StateScreenModel<AboutScreenState>(AboutScreenState.Loading) {
|
||||||
AboutScreenState.Loaded(emptyList<Contributor>().toUnsafeImmutable())
|
|
||||||
) {
|
init {
|
||||||
fun fetchContributors() = Unit
|
fetchContributors()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchContributors() = screenModelScope.launchIO {
|
||||||
|
mutableState.value = AboutScreenState.Loading
|
||||||
|
|
||||||
|
when (val result = github.getContributors()) {
|
||||||
|
is ApiResponse.Success -> {
|
||||||
|
val list = result.data
|
||||||
|
.filter { it.type == null || it.type == "User" }
|
||||||
|
.map { c ->
|
||||||
|
Contributor(
|
||||||
|
username = c.login,
|
||||||
|
avatarUrl = c.avatarUrl,
|
||||||
|
commits = c.contributions,
|
||||||
|
repositories = persistentListOf(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toUnsafeImmutable()
|
||||||
|
mutableState.value = AboutScreenState.Loaded(list)
|
||||||
|
}
|
||||||
|
is ApiResponse.Error,
|
||||||
|
is ApiResponse.Failure -> {
|
||||||
|
Log.w(BuildConfig.TAG, "Failed to fetch contributors: $result")
|
||||||
|
mutableState.value = AboutScreenState.Failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import cafe.adriel.voyager.core.screen.Screen
|
import cafe.adriel.voyager.core.screen.Screen
|
||||||
import cafe.adriel.voyager.koin.koinScreenModel
|
import cafe.adriel.voyager.koin.koinScreenModel
|
||||||
|
import com.meowarex.rlmobile.BuildConfig
|
||||||
import com.meowarex.rlmobile.R
|
import com.meowarex.rlmobile.R
|
||||||
import com.meowarex.rlmobile.ui.components.*
|
import com.meowarex.rlmobile.ui.components.*
|
||||||
import com.meowarex.rlmobile.ui.screens.about.components.LeadContributor
|
import com.meowarex.rlmobile.ui.screens.about.components.LeadContributor
|
||||||
@@ -69,7 +70,7 @@ fun AboutScreenContent(state: State<AboutScreenState>) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
LeadContributor("meowarex", "Radiant Lyrics")
|
LeadContributor(BuildConfig.PATCHES_REPO_OWNER, "Radiant Lyrics")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +101,8 @@ fun AboutScreenContent(state: State<AboutScreenState>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
is AboutScreenState.Loaded -> {
|
is AboutScreenState.Loaded -> {
|
||||||
items(state.contributors, key = { it.username }) { user ->
|
val others = state.contributors.filter { it.username != BuildConfig.PATCHES_REPO_OWNER }
|
||||||
|
items(others, key = { it.username }) { user ->
|
||||||
ContributorCommitsItem(user)
|
ContributorCommitsItem(user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,19 +11,23 @@ import androidx.compose.ui.graphics.asImageBitmap
|
|||||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.paging.Pager
|
||||||
|
import androidx.paging.PagingConfig
|
||||||
|
import androidx.paging.cachedIn
|
||||||
import cafe.adriel.voyager.core.model.ScreenModel
|
import cafe.adriel.voyager.core.model.ScreenModel
|
||||||
import cafe.adriel.voyager.core.model.screenModelScope
|
import cafe.adriel.voyager.core.model.screenModelScope
|
||||||
import com.github.diamondminer88.zip.ZipReader
|
import com.github.diamondminer88.zip.ZipReader
|
||||||
import com.meowarex.rlmobile.BuildConfig
|
import com.meowarex.rlmobile.BuildConfig
|
||||||
import com.meowarex.rlmobile.R
|
import com.meowarex.rlmobile.R
|
||||||
|
import com.meowarex.rlmobile.network.models.GithubCommit
|
||||||
import com.meowarex.rlmobile.network.models.RLBuildInfo
|
import com.meowarex.rlmobile.network.models.RLBuildInfo
|
||||||
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
|
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
|
||||||
|
import com.meowarex.rlmobile.network.utils.CommitsPagingSource
|
||||||
import com.meowarex.rlmobile.network.utils.fold
|
import com.meowarex.rlmobile.network.utils.fold
|
||||||
import com.meowarex.rlmobile.patcher.InstallMetadata
|
import com.meowarex.rlmobile.patcher.InstallMetadata
|
||||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
|
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
|
||||||
import com.meowarex.rlmobile.ui.util.TidalVersion
|
import com.meowarex.rlmobile.ui.util.TidalVersion
|
||||||
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
|
|
||||||
import com.meowarex.rlmobile.util.*
|
import com.meowarex.rlmobile.util.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -36,9 +40,14 @@ class HomeModel(
|
|||||||
private val github: RadiantLyricsGithubService,
|
private val github: RadiantLyricsGithubService,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
) : ScreenModel {
|
) : ScreenModel {
|
||||||
var installsState by mutableStateOf<InstallsState>(InstallsState.Fetching)
|
|
||||||
|
var state by mutableStateOf<HomeState>(HomeState.Loading)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
val commits = Pager(PagingConfig(pageSize = 30)) {
|
||||||
|
CommitsPagingSource(github)
|
||||||
|
}.flow.cachedIn(screenModelScope)
|
||||||
|
|
||||||
private val refreshingLock = Mutex()
|
private val refreshingLock = Mutex()
|
||||||
private var remoteDataJson: RLBuildInfo? = null
|
private var remoteDataJson: RLBuildInfo? = null
|
||||||
|
|
||||||
@@ -48,36 +57,45 @@ class HomeModel(
|
|||||||
|
|
||||||
fun refresh(delay: Boolean = false) = screenModelScope.launchIO {
|
fun refresh(delay: Boolean = false) = screenModelScope.launchIO {
|
||||||
if (refreshingLock.isLocked) return@launchIO
|
if (refreshingLock.isLocked) return@launchIO
|
||||||
|
|
||||||
if (delay) {
|
if (delay) {
|
||||||
delay(250)
|
delay(250)
|
||||||
|
if (refreshingLock.isLocked) return@launchIO
|
||||||
if (refreshingLock.isLocked)
|
|
||||||
return@launchIO
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshingLock.withLock {
|
refreshingLock.withLock {
|
||||||
val packages = fetchRadiantLyricsPackages()
|
val pkg = fetchInstalled()
|
||||||
|
val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() }
|
||||||
|
remote.await()
|
||||||
|
|
||||||
val jobs = listOf(
|
val install = pkg?.toInstallData()
|
||||||
screenModelScope.launch(Dispatchers.IO) {
|
val latest = remoteDataJson?.tidalVersionCode
|
||||||
fetchInstallations(packages)
|
|
||||||
},
|
mainThread {
|
||||||
screenModelScope.launch(Dispatchers.IO) {
|
state = HomeState.Loaded(
|
||||||
if (remoteDataJson == null)
|
install = install,
|
||||||
fetchRemoteData()
|
latestTidalVersionCode = latest,
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
jobs.joinAll()
|
|
||||||
mainThread { refreshInstallationsUpToDate(packages) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchInstall() {
|
||||||
|
val current = (state as? HomeState.Loaded)?.install ?: return
|
||||||
|
openApp(current.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openCurrentAppInfo() {
|
||||||
|
val current = (state as? HomeState.Loaded)?.install ?: return
|
||||||
|
openAppInfo(current.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createReinstallScreen(): PatchOptionsScreen? {
|
||||||
|
val current = (state as? HomeState.Loaded)?.install ?: return null
|
||||||
|
return createPrefilledPatchOptsScreen(current.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
fun openApp(packageName: String) {
|
fun openApp(packageName: String) {
|
||||||
val launchIntent = application.packageManager
|
val launchIntent = application.packageManager.getLaunchIntentForPackage(packageName)
|
||||||
.getLaunchIntentForPackage(packageName)
|
|
||||||
|
|
||||||
if (launchIntent != null) {
|
if (launchIntent != null) {
|
||||||
application.startActivity(launchIntent)
|
application.startActivity(launchIntent)
|
||||||
} else {
|
} else {
|
||||||
@@ -89,7 +107,6 @@ class HomeModel(
|
|||||||
val launchIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
val launchIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
.setData("package:$packageName".toUri())
|
.setData("package:$packageName".toUri())
|
||||||
|
|
||||||
application.startActivity(launchIntent)
|
application.startActivity(launchIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,42 +115,30 @@ class HomeModel(
|
|||||||
val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0)
|
val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0)
|
||||||
val metadataFile = ZipReader(applicationInfo.publicSourceDir)
|
val metadataFile = ZipReader(applicationInfo.publicSourceDir)
|
||||||
.use { it.openEntry("rlmobile.json")?.read() }
|
.use { it.openEntry("rlmobile.json")?.read() }
|
||||||
|
|
||||||
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
|
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.w(BuildConfig.TAG, "Failed to parse Radiant Lyrics install metadata from package $packageName", t)
|
Log.w(BuildConfig.TAG, "Failed to parse install metadata for $packageName", t)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val patchOptions = metadata?.options
|
val patchOptions = metadata?.options ?: PatchOptions.Default.copy(packageName = packageName)
|
||||||
?: PatchOptions.Default.copy(packageName = packageName)
|
|
||||||
|
|
||||||
return PatchOptionsScreen(prefilledOptions = patchOptions)
|
return PatchOptionsScreen(prefilledOptions = patchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchInstallations(packages: List<PackageInfo>) {
|
private fun fetchInstalled(): PackageInfo? = application.packageManager
|
||||||
mainThread {
|
.getInstalledPackages(PackageManager.GET_META_DATA)
|
||||||
if (installsState !is InstallsState.Fetched)
|
.firstOrNull { it.applicationInfo?.metaData?.containsKey("isRadiantLyrics") == true }
|
||||||
installsState = InstallsState.Fetching
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
private fun PackageInfo.toInstallData(): InstallData {
|
||||||
val packageManager = application.packageManager
|
val pm = application.packageManager
|
||||||
val rlMobileInstallations = packages.mapNotNull { pkg ->
|
@Suppress("DEPRECATION") val versionCode = versionCode
|
||||||
@Suppress("DEPRECATION")
|
val versionName = versionName ?: ""
|
||||||
val versionCode = pkg.versionCode
|
val info = applicationInfo!!
|
||||||
val versionName = pkg.versionName ?: return@mapNotNull null
|
return InstallData(
|
||||||
val applicationInfo = pkg.applicationInfo ?: return@mapNotNull null
|
name = pm.getApplicationLabel(info).toString(),
|
||||||
|
packageName = packageName,
|
||||||
InstallData(
|
isUpToDate = isInstallationUpToDate(this),
|
||||||
name = packageManager.getApplicationLabel(applicationInfo).toString(),
|
icon = pm.getApplicationIcon(info).toBitmap().asImageBitmap().let(::BitmapPainter),
|
||||||
packageName = pkg.packageName,
|
|
||||||
isUpToDate = isInstallationUpToDate(pkg),
|
|
||||||
icon = packageManager
|
|
||||||
.getApplicationIcon(applicationInfo)
|
|
||||||
.toBitmap()
|
|
||||||
.asImageBitmap()
|
|
||||||
.let(::BitmapPainter),
|
|
||||||
version = TidalVersion.Existing(
|
version = TidalVersion.Existing(
|
||||||
type = TidalVersion.parseVersionType(versionCode),
|
type = TidalVersion.parseVersionType(versionCode),
|
||||||
name = versionName.split("-")[0].trim(),
|
name = versionName.split("-")[0].trim(),
|
||||||
@@ -142,102 +147,42 @@ class HomeModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
mainThread {
|
|
||||||
installsState = if (rlMobileInstallations.isNotEmpty()) {
|
|
||||||
InstallsState.Fetched(data = rlMobileInstallations.toUnsafeImmutable())
|
|
||||||
} else {
|
|
||||||
InstallsState.None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.e(BuildConfig.TAG, "Failed to query Radiant Lyrics installations", t)
|
|
||||||
mainThread { installsState = InstallsState.Error }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshInstallationsUpToDate(packages: List<PackageInfo>) {
|
|
||||||
val installations = mainThread { (installsState as? InstallsState.Fetched)?.data }
|
|
||||||
?: return
|
|
||||||
|
|
||||||
try {
|
|
||||||
val newInstallations = installations.map { data ->
|
|
||||||
val packageInfo = packages.find { it.packageName == data.packageName }
|
|
||||||
?: throw IllegalStateException("Checking up-to-date status for package that has not been fetched")
|
|
||||||
|
|
||||||
data.copy(isUpToDate = isInstallationUpToDate(packageInfo))
|
|
||||||
}
|
|
||||||
|
|
||||||
mainThread { installsState = InstallsState.Fetched(data = newInstallations.toUnsafeImmutable()) }
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.e(BuildConfig.TAG, "Failed to check installations up-to-date", t)
|
|
||||||
mainThread { installsState = InstallsState.Error }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchRemoteData() {
|
private suspend fun fetchRemoteData() {
|
||||||
val release = try {
|
val release = try {
|
||||||
github.getLatestRelease().let { response ->
|
github.getLatestRelease().fold(
|
||||||
response.fold(
|
|
||||||
success = { it },
|
success = { it },
|
||||||
fail = {
|
fail = { Log.w(BuildConfig.TAG, "Failed to fetch latest release", it); return },
|
||||||
Log.w(BuildConfig.TAG, "Failed to fetch latest release", it)
|
|
||||||
return
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.w(BuildConfig.TAG, "Failed to fetch remote data", t)
|
Log.w(BuildConfig.TAG, "Failed to fetch remote data", t)
|
||||||
mainThread { application.showToast(R.string.home_network_fail) }
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val dataJsonUrl = release.assets
|
val dataJsonUrl = release.assets
|
||||||
.find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME }
|
.find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME }
|
||||||
?.browserDownloadUrl
|
?.browserDownloadUrl
|
||||||
?: run {
|
?: return
|
||||||
Log.w(BuildConfig.TAG, "No data.json asset in latest release")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
github.getBuildInfo(dataJsonUrl).fold(
|
github.getBuildInfo(dataJsonUrl).fold(
|
||||||
success = { remoteDataJson = it },
|
success = { remoteDataJson = it },
|
||||||
fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build info", it) },
|
fail = { Log.w(BuildConfig.TAG, "Failed to fetch build info", it) },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (remoteDataJson == null) {
|
|
||||||
mainThread { application.showToast(R.string.home_network_fail) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchRadiantLyricsPackages(): List<PackageInfo> {
|
|
||||||
return application.packageManager
|
|
||||||
.getInstalledPackages(PackageManager.GET_META_DATA)
|
|
||||||
.filter {
|
|
||||||
it.applicationInfo?.metaData?.containsKey("isRadiantLyrics") == true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isInstallationUpToDate(pkg: PackageInfo): Boolean? {
|
private fun isInstallationUpToDate(pkg: PackageInfo): Boolean? {
|
||||||
val remoteBuildData = remoteDataJson ?: return null
|
val remote = remoteDataJson ?: return null
|
||||||
|
@Suppress("DEPRECATION") val versionCode = pkg.versionCode
|
||||||
@Suppress("DEPRECATION")
|
if (remote.tidalVersionCode != versionCode) return false
|
||||||
val versionCode = pkg.versionCode
|
|
||||||
|
|
||||||
if (remoteBuildData.tidalVersionCode != versionCode) return false
|
|
||||||
|
|
||||||
val apkPath = pkg.applicationInfo?.publicSourceDir ?: return false
|
val apkPath = pkg.applicationInfo?.publicSourceDir ?: return false
|
||||||
val installMetadata = try {
|
val installMetadata = try {
|
||||||
val metadataFile = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() }
|
val mf = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() } ?: return false
|
||||||
?: return false
|
json.decodeFromStream<InstallMetadata>(mf.inputStream())
|
||||||
|
|
||||||
json.decodeFromStream<InstallMetadata>(metadataFile.inputStream())
|
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.d(BuildConfig.TAG, "Failed to parse Radiant Lyrics InstallMetadata from package ${pkg.packageName}", t)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (installMetadata.options.customPatches != null) return true
|
if (installMetadata.options.customPatches != null) return true
|
||||||
|
return remote.patchesVersion == installMetadata.patchesVersion
|
||||||
return remoteBuildData.patchesVersion == installMetadata.patchesVersion
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
|
|
||||||
package com.meowarex.rlmobile.ui.screens.home
|
package com.meowarex.rlmobile.ui.screens.home
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.MutableTransitionState
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.foundation.basicMarquee
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
import cafe.adriel.voyager.core.screen.Screen
|
import cafe.adriel.voyager.core.screen.Screen
|
||||||
import cafe.adriel.voyager.koin.koinScreenModel
|
import cafe.adriel.voyager.koin.koinScreenModel
|
||||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
import com.meowarex.rlmobile.R
|
import com.meowarex.rlmobile.R
|
||||||
import com.meowarex.rlmobile.ui.components.LoadFailure
|
import com.meowarex.rlmobile.ui.components.SegmentedButton
|
||||||
import com.meowarex.rlmobile.ui.components.ProjectHeader
|
import com.meowarex.rlmobile.ui.screens.about.AboutScreen
|
||||||
import com.meowarex.rlmobile.ui.screens.home.components.*
|
import com.meowarex.rlmobile.ui.screens.home.components.CommitList
|
||||||
|
import com.meowarex.rlmobile.ui.screens.logs.LogsListScreen
|
||||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
|
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
|
||||||
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen
|
import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
|
||||||
import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides
|
|
||||||
import com.meowarex.rlmobile.ui.util.paddings.exclude
|
|
||||||
import com.meowarex.rlmobile.util.*
|
import com.meowarex.rlmobile.util.*
|
||||||
import kotlinx.parcelize.IgnoredOnParcel
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
@@ -44,180 +44,164 @@ class HomeScreen : Screen, Parcelable {
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val model = koinScreenModel<HomeModel>()
|
val model = koinScreenModel<HomeModel>()
|
||||||
|
|
||||||
// Refresh installations list when the screen changes or activity resumes
|
|
||||||
LifecycleResumeEffect(Unit) {
|
LifecycleResumeEffect(Unit) {
|
||||||
model.refresh(delay = true)
|
model.refresh(delay = true)
|
||||||
|
|
||||||
onPauseOrDispose {}
|
onPauseOrDispose {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { HomeAppBar() },
|
topBar = {
|
||||||
) { padding ->
|
TopAppBar(
|
||||||
when (val state = model.installsState) {
|
title = { Text(stringResource(R.string.navigation_home)) },
|
||||||
is InstallsState.Fetched -> HomeScreenLoadedContent(
|
actions = {
|
||||||
|
IconButton(onClick = { model.refresh() }) {
|
||||||
|
Icon(painterResource(R.drawable.ic_refresh), contentDescription = 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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { pv ->
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(pv)
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
val state = model.state
|
||||||
|
when (state) {
|
||||||
|
HomeState.Loading -> Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
|
||||||
|
is HomeState.Loaded -> HomeContent(
|
||||||
state = state,
|
state = state,
|
||||||
padding = padding,
|
commits = model.commits,
|
||||||
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) },
|
onInstall = { navigator.pushOnce(PatchOptionsScreen()) },
|
||||||
onUpdate = {
|
onReinstall = {
|
||||||
scope.launchIO {
|
scope.launchIO {
|
||||||
val screen = model.createPrefilledPatchOptsScreen(it)
|
val screen = model.createReinstallScreen() ?: return@launchIO
|
||||||
mainThread { navigator.push(screen) }
|
mainThread { navigator.push(screen) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpenApp = model::openApp,
|
onLaunch = model::launchInstall,
|
||||||
onOpenAppInfo = model::openAppInfo,
|
onInfo = model::openCurrentAppInfo,
|
||||||
onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
InstallsState.Fetching -> HomeScreenLoadingContent(padding = padding)
|
|
||||||
|
|
||||||
InstallsState.None -> HomeScreenNoneContent(
|
|
||||||
padding = padding,
|
|
||||||
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) },
|
|
||||||
)
|
|
||||||
|
|
||||||
InstallsState.Error -> HomeScreenFailureContent(padding = padding)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreenLoadingContent(padding: PaddingValues) {
|
private fun ColumnScope.HomeContent(
|
||||||
Column(
|
state: HomeState.Loaded,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
commits: kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<com.meowarex.rlmobile.network.models.GithubCommit>>,
|
||||||
modifier = Modifier
|
onInstall: () -> Unit,
|
||||||
.fillMaxSize()
|
onReinstall: () -> Unit,
|
||||||
.padding(padding)
|
onLaunch: () -> Unit,
|
||||||
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
|
onInfo: () -> Unit,
|
||||||
) {
|
|
||||||
ProjectHeader()
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visibleState = remember { MutableTransitionState(false) }.apply { targetState = true },
|
|
||||||
enter = fadeIn(animationSpec = tween(durationMillis = 800)),
|
|
||||||
exit = ExitTransition.None,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
content = { CircularProgressIndicator() },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HomeScreenLoadedContent(
|
|
||||||
state: InstallsState.Fetched,
|
|
||||||
padding: PaddingValues,
|
|
||||||
onClickInstall: () -> Unit,
|
|
||||||
onUpdate: (packageName: String) -> Unit,
|
|
||||||
onOpenApp: (packageName: String) -> Unit,
|
|
||||||
onOpenAppInfo: (packageName: String) -> Unit,
|
|
||||||
onOpenPlugins: (packageName: String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
val install = state.install
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
val currentVersionName = install?.version?.let { "v${it.toString()}" }
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
val latestVersionName = state.latestTidalVersionCode?.let { "build $it" }
|
||||||
contentPadding = padding
|
|
||||||
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding.exclude(PaddingValuesSides.Bottom))
|
|
||||||
.padding(top = 16.dp, start = 16.dp, end = 16.dp),
|
|
||||||
) {
|
|
||||||
item(key = "PROJECT_HEADER") {
|
|
||||||
ProjectHeader()
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "ADD_INSTALL_BUTTON") {
|
if (install?.icon != null) {
|
||||||
InstallButton(
|
Image(
|
||||||
secondaryInstall = true,
|
painter = install.icon,
|
||||||
onClick = onClickInstall,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(vertical = 4.dp)
|
.size(60.dp)
|
||||||
.height(50.dp)
|
.clip(CircleShape),
|
||||||
.fillMaxWidth()
|
)
|
||||||
|
} else {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.mipmap.ic_launcher),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(60.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
items(state.data, key = { it.packageName }) { item ->
|
|
||||||
InstalledItemCard(
|
|
||||||
data = item,
|
|
||||||
onUpdate = { onUpdate(item.packageName) },
|
|
||||||
onOpenApp = { onOpenApp(item.packageName) },
|
|
||||||
onOpenInfo = { onOpenAppInfo(item.packageName) },
|
|
||||||
onOpenPlugins = { onOpenPlugins(item.packageName) },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HomeScreenNoneContent(
|
|
||||||
padding: PaddingValues,
|
|
||||||
onClickInstall: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = modifier
|
|
||||||
.padding(padding)
|
|
||||||
.padding(16.dp)
|
|
||||||
.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
ProjectHeader()
|
|
||||||
|
|
||||||
InstallButton(
|
|
||||||
secondaryInstall = false,
|
|
||||||
onClick = onClickInstall,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(12.dp)
|
|
||||||
.height(height = 50.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier
|
|
||||||
.alpha(.7f)
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = 80.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = """ /ᐠﹷ ‸ ﹷ ᐟ\ノ""",
|
|
||||||
style = MaterialTheme.typography.labelLarge
|
|
||||||
.copy(fontSize = 38.sp),
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.installs_no_installs),
|
text = install?.name ?: stringResource(R.string.app_name),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(visible = currentVersionName != null) {
|
||||||
|
Text(
|
||||||
|
text = "Current: ${currentVersionName ?: "-"}",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
modifier = Modifier.padding(start = 10.dp),
|
color = LocalContentColor.current.copy(alpha = 0.5f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(visible = latestVersionName != null) {
|
||||||
|
Text(
|
||||||
|
text = "Latest: ${latestVersionName ?: "-"}",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = LocalContentColor.current.copy(alpha = 0.5f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
Button(
|
||||||
fun HomeScreenFailureContent(
|
onClick = if (install == null) onInstall else onReinstall,
|
||||||
padding: PaddingValues,
|
enabled = state.latestTidalVersionCode != null,
|
||||||
) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(padding)
|
|
||||||
.padding(16.dp)
|
|
||||||
.fillMaxSize(),
|
|
||||||
) {
|
) {
|
||||||
ProjectHeader()
|
val label = when {
|
||||||
LoadFailure(modifier = Modifier.fillMaxSize())
|
state.latestTidalVersionCode == null -> "Loading…"
|
||||||
|
install == null -> "Install"
|
||||||
|
install.isUpToDate == false -> "Update"
|
||||||
|
else -> "Reinstall"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.basicMarquee()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = install != null) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(16.dp)),
|
||||||
|
) {
|
||||||
|
SegmentedButton(
|
||||||
|
icon = painterResource(R.drawable.ic_launch),
|
||||||
|
text = stringResource(R.string.action_launch),
|
||||||
|
onClick = onLaunch,
|
||||||
|
)
|
||||||
|
SegmentedButton(
|
||||||
|
icon = painterResource(R.drawable.ic_info),
|
||||||
|
text = stringResource(R.string.action_open_info),
|
||||||
|
onClick = onInfo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxSize()) {
|
||||||
|
CommitList(commits = commits.collectAsLazyPagingItems())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.meowarex.rlmobile.ui.screens.home
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
sealed interface HomeState {
|
||||||
|
data object Loading : HomeState
|
||||||
|
data class Loaded(
|
||||||
|
val install: InstallData?,
|
||||||
|
val latestTidalVersionCode: Int?,
|
||||||
|
) : HomeState
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.meowarex.rlmobile.ui.screens.home
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
sealed interface InstallsState {
|
|
||||||
data object None : InstallsState
|
|
||||||
data object Error : InstallsState
|
|
||||||
data object Fetching : InstallsState
|
|
||||||
data class Fetched(val data: ImmutableList<InstallData>) : InstallsState
|
|
||||||
}
|
|
||||||
+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.di.ActivityProvider
|
||||||
import com.meowarex.rlmobile.manager.*
|
import com.meowarex.rlmobile.manager.*
|
||||||
import com.meowarex.rlmobile.ui.theme.Theme
|
import com.meowarex.rlmobile.ui.theme.Theme
|
||||||
|
import com.meowarex.rlmobile.updatechecker.UpdateCheckWorker
|
||||||
import com.meowarex.rlmobile.util.*
|
import com.meowarex.rlmobile.util.*
|
||||||
|
|
||||||
class SettingsModel(
|
class SettingsModel(
|
||||||
@@ -58,6 +59,11 @@ class SettingsModel(
|
|||||||
preferences.keepPatchedApks = value
|
preferences.keepPatchedApks = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAutoUpdateCheck(value: Boolean) {
|
||||||
|
preferences.autoUpdateCheck = value
|
||||||
|
if (value) UpdateCheckWorker.schedule(application) else UpdateCheckWorker.cancel(application)
|
||||||
|
}
|
||||||
|
|
||||||
fun clearCache() = screenModelScope.launchIO {
|
fun clearCache() = screenModelScope.launchIO {
|
||||||
paths.clearCache()
|
paths.clearCache()
|
||||||
|
|
||||||
|
|||||||
+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") {
|
item(key = "HEADER_INSTALL", contentType = "DIVIDER") {
|
||||||
SettingsHeader(stringResource(R.string.settings_header_installation))
|
SettingsHeader(stringResource(R.string.settings_header_installation))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.meowarex.rlmobile.updatechecker
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.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:translateX="16"
|
||||||
android:translateY="18">
|
android:translateY="18">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#00C2D7"
|
android:fillColor="#FFE0EC"
|
||||||
android:pathData="M13.837,2.948C14.253,1.893 15.747,1.893 16.163,2.948L17.629,6.667C17.756,6.989 18.011,7.244 18.333,7.371L22.052,8.837C23.107,9.253 23.107,10.747 22.052,11.163L18.333,12.629C18.011,12.756 17.756,13.011 17.629,13.333L16.163,17.052C15.747,18.107 14.253,18.107 13.837,17.052L12.371,13.333C12.244,13.011 11.989,12.756 11.667,12.629L7.948,11.163C6.893,10.747 6.893,9.253 7.948,8.837L11.667,7.371C11.989,7.244 12.244,6.989 12.371,6.667L13.837,2.948Z" />
|
android:pathData="M13.837,2.948C14.253,1.893 15.747,1.893 16.163,2.948L17.629,6.667C17.756,6.989 18.011,7.244 18.333,7.371L22.052,8.837C23.107,9.253 23.107,10.747 22.052,11.163L18.333,12.629C18.011,12.756 17.756,13.011 17.629,13.333L16.163,17.052C15.747,18.107 14.253,18.107 13.837,17.052L12.371,13.333C12.244,13.011 11.989,12.756 11.667,12.629L7.948,11.163C6.893,10.747 6.893,9.253 7.948,8.837L11.667,7.371C11.989,7.244 12.244,6.989 12.371,6.667L13.837,2.948Z" />
|
||||||
<path
|
<path
|
||||||
android:fillColor="#00C2D7"
|
android:fillColor="#FFE0EC"
|
||||||
android:fillAlpha="0.6"
|
android:fillAlpha="0.6"
|
||||||
android:pathData="M5.322,13.72C5.564,13.104 6.436,13.104 6.678,13.72L7.581,16.008C7.655,16.196 7.804,16.345 7.992,16.419L10.28,17.322C10.896,17.564 10.896,18.436 10.28,18.678L7.992,19.581C7.804,19.655 7.655,19.804 7.581,19.992L6.678,22.28C6.436,22.896 5.564,22.896 5.322,22.28L4.419,19.992C4.345,19.804 4.196,19.655 4.008,19.581L1.72,18.678C1.104,18.436 1.104,17.564 1.72,17.322L4.008,16.419C4.196,16.345 4.345,16.196 4.419,16.008L5.322,13.72Z" />
|
android:pathData="M5.322,13.72C5.564,13.104 6.436,13.104 6.678,13.72L7.581,16.008C7.655,16.196 7.804,16.345 7.992,16.419L10.28,17.322C10.896,17.564 10.896,18.436 10.28,18.678L7.992,19.581C7.804,19.655 7.655,19.804 7.581,19.992L6.678,22.28C6.436,22.896 5.564,22.896 5.322,22.28L4.419,19.992C4.345,19.804 4.196,19.655 4.008,19.581L1.72,18.678C1.104,18.436 1.104,17.564 1.72,17.322L4.008,16.419C4.196,16.345 4.345,16.196 4.419,16.008L5.322,13.72Z" />
|
||||||
<path
|
<path
|
||||||
android:fillColor="#00C2D7"
|
android:fillColor="#FFE0EC"
|
||||||
android:fillAlpha="0.3"
|
android:fillAlpha="0.3"
|
||||||
android:pathData="M5.209,1.737C5.313,1.473 5.687,1.473 5.791,1.737L6.157,2.667C6.189,2.747 6.253,2.811 6.333,2.843L7.263,3.209C7.527,3.313 7.527,3.687 7.263,3.791L6.333,4.157C6.253,4.189 6.189,4.253 6.157,4.333L5.791,5.263C5.687,5.527 5.313,5.527 5.209,5.263L4.843,4.333C4.811,4.253 4.747,4.189 4.667,4.157L3.737,3.791C3.473,3.687 3.473,3.313 3.737,3.209L4.667,2.843C4.747,2.811 4.811,2.747 4.843,2.667L5.209,1.737Z" />
|
android:pathData="M5.209,1.737C5.313,1.473 5.687,1.473 5.791,1.737L6.157,2.667C6.189,2.747 6.253,2.811 6.333,2.843L7.263,3.209C7.527,3.313 7.527,3.687 7.263,3.791L6.333,4.157C6.253,4.189 6.189,4.253 6.157,4.333L5.791,5.263C5.687,5.527 5.313,5.527 5.209,5.263L4.843,4.333C4.811,4.253 4.747,4.189 4.667,4.157L3.737,3.791C3.473,3.687 3.473,3.313 3.737,3.209L4.667,2.843C4.747,2.811 4.811,2.747 4.843,2.667L5.209,1.737Z" />
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0A1929</color>
|
<color name="ic_launcher_background">#B91D6F</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
<string name="github" translatable="false">GitHub</string>
|
<string name="github" translatable="false">GitHub</string>
|
||||||
<string name="support_server">Support Server</string>
|
<string name="support_server">Support Server</string>
|
||||||
<string name="installer">Installer</string>
|
<string name="installer">Installer</string>
|
||||||
|
<string name="navigation_home">Home</string>
|
||||||
|
|
||||||
|
<string name="notif_channel_updates">Manager updates</string>
|
||||||
|
<string name="notif_update_title">Manager update available</string>
|
||||||
|
<string name="notif_update_text">v%1$s is now available</string>
|
||||||
|
|
||||||
|
<string name="setting_auto_update_check">Background update check</string>
|
||||||
|
<string name="setting_auto_update_check_desc">Show a notification when a new Manager version is released</string>
|
||||||
|
|
||||||
<string name="action_cancel">Cancel</string>
|
<string name="action_cancel">Cancel</string>
|
||||||
<string name="action_retry">Retry</string>
|
<string name="action_retry">Retry</string>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ agp = "9.0.0"
|
|||||||
androidx-activity = "1.12.2"
|
androidx-activity = "1.12.2"
|
||||||
androidx-core = "1.17.0"
|
androidx-core = "1.17.0"
|
||||||
androidx-lifecycle = "2.10.0"
|
androidx-lifecycle = "2.10.0"
|
||||||
|
androidx-paging = "3.3.6"
|
||||||
androidx-splashscreen = "1.2.0"
|
androidx-splashscreen = "1.2.0"
|
||||||
|
androidx-work = "2.10.0"
|
||||||
apksig = "9.0.0"
|
apksig = "9.0.0"
|
||||||
axml = "1.0.1"
|
axml = "1.0.1"
|
||||||
binary-resources = "2.1.0"
|
binary-resources = "2.1.0"
|
||||||
@@ -43,6 +45,8 @@ androidx-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose",
|
|||||||
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" }
|
||||||
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||||
androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
|
androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
|
||||||
|
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
|
||||||
|
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
|
||||||
|
|
||||||
# Coil (image library)
|
# Coil (image library)
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
|
|||||||
@@ -9,11 +9,17 @@ A Manager app for Android that patches TIDAL to bring the Radiant Lyrics experie
|
|||||||
- Hides the ugly cover art when in lyrics mode
|
- Hides the ugly cover art when in lyrics mode
|
||||||
- Syllable Level lyrics (SoonTM)
|
- Syllable Level lyrics (SoonTM)
|
||||||
|
|
||||||
|
|
||||||
|
### Disclaimer
|
||||||
|
|
||||||
|
This project is not affiliated with TIDAL in any way. Use at your own risk.
|
||||||
|
- I can't guarantee using this project won't result in any platform enforcement **(Bans)**
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Batteries <3
|
### Batteries <3
|
||||||
- Android 8.0+
|
- Android 10.0+ & >340 DPI
|
||||||
- TIDAL app (v2.192.0 / build 9089)
|
- Paid TIDAL Account
|
||||||
|
|
||||||
### Steps
|
### Steps
|
||||||
1. Download the latest `rl-manager.apk` from [Releases](../../releases/latest)
|
1. Download the latest `rl-manager.apk` from [Releases](../../releases/latest)
|
||||||
|
|||||||
Reference in New Issue
Block a user