This commit is contained in:
2026-05-20 19:47:33 +10:00
commit dbb6302bd1
313 changed files with 17869 additions and 0 deletions
@@ -0,0 +1,31 @@
package com.meowarex.rlmobile.ui.components
import androidx.activity.compose.LocalActivity
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.util.back
/**
* Standalone back button for interacting with the current navigator.
*/
@Composable
fun BackButton() {
val navigator = LocalNavigator.current
val activity = LocalActivity.current
IconButton(
onClick = {
navigator?.back(activity)
},
) {
Icon(
painter = painterResource(R.drawable.ic_back),
contentDescription = stringResource(R.string.navigation_back),
)
}
}
@@ -0,0 +1,72 @@
package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import coil3.compose.SubcomposeAsyncImage
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.network.models.Contributor
import com.valentinilk.shimmer.shimmer
@Composable
fun ContributorCommitsItem(
user: Contributor,
modifier: Modifier = Modifier,
) {
val uriHandler = LocalUriHandler.current
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
.clickable { uriHandler.openUri("https://github.com/${user.username}") }
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
) {
SubcomposeAsyncImage(
model = user.avatarUrl,
contentDescription = user.username,
error = {
Surface(
content = {},
tonalElevation = 2.dp,
modifier = Modifier
.fillMaxSize()
.shimmer(),
)
},
modifier = Modifier
.padding(top = 6.dp)
.size(45.dp)
.clip(CircleShape)
)
Column {
Text(
text = user.username,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = stringResource(R.string.contributors_contributions, user.commits),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
)
Text(
text = user.repositories.joinToString { it.name },
fontStyle = FontStyle.Italic,
style = MaterialTheme.typography.bodySmall
.copy(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)),
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@@ -0,0 +1,113 @@
/**
* MIT License
*
* Copyright (c) 2025 zt64
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// Adapted from compose-pipette:
// https://github.com/zt64/compose-pipette/blob/3e9fd958a315dceb142bf30250b4614ecde4e723/sample/src/commonMain/kotlin/dev/zt64/compose/pipette/sample/SampleSlider.kt
package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.*
import androidx.compose.ui.unit.dp
@Composable
fun InteractiveSlider(
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
brush: Brush,
thumbColor: Color = MaterialTheme.colorScheme.primary,
modifier: Modifier = Modifier,
) {
val interactionSource = remember(::MutableInteractionSource)
Slider(
value = value,
onValueChange = onValueChange,
thumb = {
val interactions = remember { mutableStateListOf<Interaction>() }
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> interactions.add(interaction)
is PressInteraction.Release -> interactions.remove(interaction.press)
is PressInteraction.Cancel -> interactions.remove(interaction.press)
is DragInteraction.Start -> interactions.add(interaction)
is DragInteraction.Stop -> interactions.remove(interaction.start)
is DragInteraction.Cancel -> interactions.remove(interaction.start)
}
}
}
Box(
modifier = Modifier
.size(28.dp)
.hoverable(interactionSource = interactionSource),
) {
val visualSize by remember {
derivedStateOf {
if (interactions.isNotEmpty()) 28.dp else 24.dp
}
}
Spacer(
modifier = Modifier
.size(visualSize)
.align(Alignment.Center)
.background(thumbColor, CircleShape),
)
}
},
track = {
Canvas(
modifier = Modifier
.widthIn(max = 700.dp)
.height(12.dp)
.fillMaxWidth(),
) {
drawLine(
brush = brush,
start = Offset(0f, size.center.y),
end = Offset(size.width, size.center.y),
strokeWidth = size.height,
cap = StrokeCap.Round,
)
}
},
valueRange = valueRange,
interactionSource = interactionSource,
modifier = modifier,
)
}
@@ -0,0 +1,42 @@
package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.ui.util.thenIf
@Composable
fun Label(
name: String,
description: String?,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.thenIf(description == null) { padding(bottom = 4.dp) },
)
if (description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.alpha(.7f)
.padding(bottom = 4.dp),
)
}
content()
}
}
@@ -0,0 +1,33 @@
package com.meowarex.rlmobile.ui.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 LoadFailure(modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier,
) {
Icon(
painter = painterResource(R.drawable.ic_warning),
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
modifier = Modifier.size(34.dp),
)
Text(
text = stringResource(R.string.network_load_fail),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp),
)
}
}
@@ -0,0 +1,49 @@
package com.meowarex.rlmobile.ui.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.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun MainActionButton(
text: String,
icon: Painter,
onClick: () -> Unit,
enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
modifier: Modifier = Modifier,
) {
FilledTonalIconButton(
shape = MaterialTheme.shapes.medium,
colors = colors,
onClick = onClick,
enabled = enabled,
modifier = modifier
.fillMaxWidth()
.height(46.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center,
)
}
}
}
@@ -0,0 +1,51 @@
package com.meowarex.rlmobile.ui.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.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.meowarex.rlmobile.R
@Composable
fun ProjectHeader(modifier: Modifier = Modifier) {
val uriHandler = LocalUriHandler.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier,
) {
Text(
text = stringResource(R.string.rlmobile),
style = MaterialTheme.typography.titleMedium.copy(fontSize = 26.sp)
)
Text(
text = stringResource(R.string.app_description),
style = MaterialTheme.typography.titleSmall.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = .6f)
),
textAlign = TextAlign.Center,
)
Row(
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = { uriHandler.openUri("https://github.com/meowarex/rl-mobile") }) {
Icon(
painter = painterResource(R.drawable.ic_account_github_white_24dp),
contentDescription = null,
modifier = Modifier.padding(end = ButtonDefaults.IconSpacing),
)
Text(text = stringResource(R.string.github))
}
}
}
}
@@ -0,0 +1,37 @@
package com.meowarex.rlmobile.ui.components
import androidx.compose.animation.*
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.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.util.mirrorVertically
@Composable
fun ResetToDefaultButton(
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = enabled,
enter = fadeIn() + slideInHorizontally(),
exit = fadeOut() + slideOutHorizontally(),
modifier = modifier,
) {
IconButton(onClick = onClick) {
Icon(
painter = painterResource(R.drawable.ic_refresh),
tint = MaterialTheme.colorScheme.secondary,
contentDescription = stringResource(R.string.action_reset_default),
modifier = Modifier
.mirrorVertically()
.size(26.dp),
)
}
}
}
@@ -0,0 +1,45 @@
package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.*
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.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
@Composable
fun RowScope.SegmentedButton(
icon: Painter,
iconColor: Color = MaterialTheme.colorScheme.primary,
iconDescription: String? = null,
text: String,
textColor: Color = MaterialTheme.colorScheme.primary,
onClick: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
modifier = Modifier
.clickable(onClick = onClick)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.weight(1f)
.padding(12.dp)
) {
Icon(
painter = icon,
contentDescription = iconDescription,
tint = iconColor,
)
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = textColor,
maxLines = 1,
modifier = Modifier.basicMarquee(),
)
}
}
@@ -0,0 +1,35 @@
package com.meowarex.rlmobile.ui.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.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun TextDivider(
text: String,
style: TextStyle = MaterialTheme.typography.bodyMedium,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth(),
) {
HorizontalDivider(Modifier.weight(1f))
Text(
text = text,
style = style,
color = MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.SemiBold,
)
HorizontalDivider(Modifier.weight(1f))
}
}
@@ -0,0 +1,30 @@
package com.meowarex.rlmobile.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import com.meowarex.rlmobile.ui.util.TidalVersion
@Composable
fun VersionDisplay(
version: TidalVersion,
prefix: (@Composable AnnotatedString.Builder.() -> Unit)? = null,
style: TextStyle = MaterialTheme.typography.labelLarge,
modifier: Modifier = Modifier,
) {
Text(
text = buildAnnotatedString {
prefix?.invoke(this)
if (version is TidalVersion.Existing) {
append(version.name)
append(" - ")
}
append(version.toDisplayName())
},
style = style,
modifier = modifier,
)
}
@@ -0,0 +1,26 @@
package com.meowarex.rlmobile.ui.components
import android.view.WindowManager
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import cafe.adriel.voyager.navigator.currentOrThrow
/**
* Maintain an active screen wakelock as long as [active] is true and this component is in scope.
*/
@Composable
fun Wakelock(active: Boolean = false) {
val window = LocalActivity.currentOrThrow.window
DisposableEffect(active) {
if (active) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
@@ -0,0 +1,62 @@
package com.meowarex.rlmobile.ui.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 InstallerAbortDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
FilledTonalButton(
onClick = onConfirm,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Text(stringResource(R.string.action_exit_anyways))
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer
),
) {
Text(stringResource(R.string.action_cancel))
}
},
title = {
Text(stringResource(R.string.installer_abort_title))
},
text = {
Text(
text = stringResource(R.string.installer_abort_body),
textAlign = TextAlign.Center,
)
},
icon = {
Icon(
painter = painterResource(R.drawable.ic_warning),
contentDescription = null,
modifier = Modifier.size(32.dp),
)
},
containerColor = MaterialTheme.colorScheme.errorContainer,
iconContentColor = MaterialTheme.colorScheme.onErrorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
textContentColor = MaterialTheme.colorScheme.onErrorContainer
)
}
@@ -0,0 +1,96 @@
package com.meowarex.rlmobile.ui.components.dialogs
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.meowarex.rlmobile.R
@Composable
fun NetworkWarningDialog(
onConfirm: (neverShow: Boolean) -> Unit,
onDismiss: (neverShow: Boolean) -> Unit,
) {
val interactionSource = remember(::MutableInteractionSource)
var neverShow by rememberSaveable { mutableStateOf(false) }
val rememberedNeverShow by rememberUpdatedState(neverShow)
AlertDialog(
onDismissRequest = { onDismiss(rememberedNeverShow) },
properties = DialogProperties(
dismissOnClickOutside = false,
),
confirmButton = {
FilledTonalButton(
onClick = { onConfirm(rememberedNeverShow) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) {
Text(stringResource(R.string.action_continue))
}
},
dismissButton = {
TextButton(
onClick = { onDismiss(rememberedNeverShow) },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer
),
) {
Text(stringResource(R.string.navigation_back))
}
},
title = { Text(stringResource(R.string.network_warning_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.network_warning_body),
textAlign = TextAlign.Center,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { neverShow = !rememberedNeverShow },
)
.padding(end = 16.dp)
) {
Checkbox(
checked = neverShow,
onCheckedChange = { neverShow = it },
interactionSource = interactionSource,
)
Text(stringResource(R.string.network_warning_disable))
}
}
},
icon = {
Icon(
painter = painterResource(R.drawable.ic_warning),
contentDescription = null,
modifier = Modifier.size(32.dp),
)
},
containerColor = MaterialTheme.colorScheme.errorContainer,
iconContentColor = MaterialTheme.colorScheme.onErrorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
)
}
@@ -0,0 +1,103 @@
package com.meowarex.rlmobile.ui.components.dialogs
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.theme.customColors
@Composable
fun PlayProtectDialog(
onDismiss: (neverShow: Boolean) -> Unit,
) {
val activity = LocalActivity.currentOrThrow
val interactionSource = remember(::MutableInteractionSource)
var neverShow by rememberSaveable { mutableStateOf(false) }
val rememberedNeverShow by rememberUpdatedState(neverShow)
AlertDialog(
onDismissRequest = { onDismiss(rememberedNeverShow) },
dismissButton = {
FilledTonalButton(onClick = activity::launchPlayProtect) {
Text(stringResource(R.string.play_protect_warning_open_gpp))
}
},
confirmButton = {
FilledTonalButton(
onClick = { onDismiss(rememberedNeverShow) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Text(stringResource(R.string.play_protect_warning_ok))
}
},
icon = {
Icon(
painter = painterResource(R.drawable.ic_protect_warning),
tint = MaterialTheme.customColors.warning,
contentDescription = null,
modifier = Modifier.size(36.dp),
)
},
title = { Text(stringResource(R.string.play_protect_warning_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.play_protect_warning_desc),
textAlign = TextAlign.Center,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { neverShow = !rememberedNeverShow },
)
.padding(end = 16.dp)
) {
Checkbox(
checked = neverShow,
onCheckedChange = { neverShow = it },
interactionSource = interactionSource,
)
Text(stringResource(R.string.play_protect_warning_disable))
}
}
},
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
modifier = Modifier
.padding(25.dp),
)
}
private fun Activity.launchPlayProtect() {
Intent("com.google.android.gms.settings.VERIFY_APPS_SETTINGS")
.setPackage("com.google.android.gms")
.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
.also(::startActivity)
}
@@ -0,0 +1,17 @@
package com.meowarex.rlmobile.ui.components.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.ui.components.TextDivider
@Composable
fun SettingsHeader(
text: String,
) {
TextDivider(
text = text,
modifier = Modifier.padding(18.dp, 20.dp, 18.dp, 10.dp)
)
}
@@ -0,0 +1,57 @@
package com.meowarex.rlmobile.ui.components.settings
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SettingsItem(
text: @Composable () -> Unit,
secondaryText: @Composable (() -> Unit) = { },
icon: @Composable (() -> Unit) = { },
modifier: Modifier = Modifier,
trailing: @Composable (() -> Unit) = { },
) {
Row(
modifier = modifier
.heightIn(min = 64.dp)
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.weight(2f, true)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.size(20.dp)) {
icon()
}
ProvideTextStyle(MaterialTheme.typography.titleSmall) {
text()
}
}
ProvideTextStyle(
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface.copy(0.6f)
)
) {
secondaryText()
}
}
Spacer(Modifier.weight(0.05f, true))
trailing()
}
}
@@ -0,0 +1,35 @@
package com.meowarex.rlmobile.ui.components.settings
import androidx.compose.foundation.clickable
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun SettingsSwitch(
label: String,
secondaryLabel: String? = null,
disabled: Boolean = false,
icon: @Composable () -> Unit = {},
pref: Boolean,
onPrefChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
SettingsItem(
modifier = modifier.clickable(enabled = !disabled) { onPrefChange(!pref) },
text = { Text(text = label, softWrap = true) },
icon = icon,
secondaryText = {
secondaryLabel?.let {
Text(text = it)
}
}
) {
Switch(
checked = pref,
enabled = !disabled,
onCheckedChange = { onPrefChange(!pref) }
)
}
}
@@ -0,0 +1,29 @@
package com.meowarex.rlmobile.ui.components.settings
import androidx.compose.foundation.layout.*
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun SettingsTextField(
label: String,
disabled: Boolean = false,
pref: String,
error: Boolean = false,
onPrefChange: (String) -> Unit,
) {
Box(modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp)) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = pref,
onValueChange = onPrefChange,
enabled = !disabled,
label = { Text(label) },
isError = error,
singleLine = true
)
}
}
@@ -0,0 +1,66 @@
package com.meowarex.rlmobile.ui.previews
import android.content.res.Configuration
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.*
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.patching.components.TextBanner
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import com.meowarex.rlmobile.ui.theme.customColors
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun ButtonVotePreview(
@PreviewParameter(TextBannerParametersProvider::class)
parameters: TextBannerParameters,
) {
ManagerTheme {
TextBanner(
text = parameters.text(),
icon = parameters.icon(),
iconColor = parameters.iconColor(),
outlineColor = parameters.outlineColor(),
containerColor = parameters.containerColor(),
)
}
}
private data class TextBannerParameters(
val text: @Composable () -> String,
val icon: @Composable () -> Painter,
val iconColor: @Composable () -> Color,
val outlineColor: @Composable () -> Color?,
val containerColor: @Composable () -> Color,
)
private class TextBannerParametersProvider : PreviewParameterProvider<TextBannerParameters> {
override val values = sequenceOf(
TextBannerParameters(
text = { stringResource(R.string.installer_banner_minimization) },
icon = { painterResource(R.drawable.ic_warning) },
iconColor = { MaterialTheme.customColors.onWarningContainer },
outlineColor = { MaterialTheme.customColors.warning },
containerColor = { MaterialTheme.customColors.warningContainer },
),
TextBannerParameters(
text = { stringResource(R.string.installer_banner_failure) },
icon = { painterResource(R.drawable.ic_warning) },
iconColor = { MaterialTheme.colorScheme.error },
outlineColor = { null },
containerColor = { MaterialTheme.colorScheme.errorContainer },
),
TextBannerParameters(
text = { stringResource(R.string.installer_banner_success) },
icon = { painterResource(R.drawable.ic_check_circle) },
iconColor = { Color(0xFF59B463) },
outlineColor = { MaterialTheme.colorScheme.surfaceVariant },
containerColor = { MaterialTheme.colorScheme.surfaceContainerHigh },
)
)
}
@@ -0,0 +1,19 @@
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.logs.components.dialogs.DeleteLogsDialog
import com.meowarex.rlmobile.ui.theme.ManagerTheme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun DeleteLogsDialogPreview() {
ManagerTheme {
DeleteLogsDialog(
onConfirm = {},
onDismiss = {},
)
}
}
@@ -0,0 +1,19 @@
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.components.dialogs.InstallerAbortDialog
import com.meowarex.rlmobile.ui.theme.ManagerTheme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun InstallerAbortDialogPreview() {
ManagerTheme {
InstallerAbortDialog(
onConfirm = {},
onDismiss = {},
)
}
}
@@ -0,0 +1,19 @@
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.components.dialogs.NetworkWarningDialog
import com.meowarex.rlmobile.ui.theme.ManagerTheme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun NetworkWarningDialogPreview() {
ManagerTheme {
NetworkWarningDialog(
onConfirm = {},
onDismiss = {},
)
}
}
@@ -0,0 +1,16 @@
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.components.dialogs.PlayProtectDialog
import com.meowarex.rlmobile.ui.theme.ManagerTheme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun PlayProtectDialogPreview() {
ManagerTheme {
PlayProtectDialog(onDismiss = {})
}
}
@@ -0,0 +1,23 @@
package com.meowarex.rlmobile.ui.previews.dialogs
import android.content.res.Configuration
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.settings.components.ThemeDialog
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import com.meowarex.rlmobile.ui.theme.Theme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun ThemeDialogPreview() {
val (theme, setTheme) = remember { mutableStateOf(Theme.System) }
ManagerTheme {
ThemeDialog(
currentTheme = theme,
onDismiss = {},
onConfirm = setTheme,
)
}
}
@@ -0,0 +1,20 @@
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 = {},
)
}
}
@@ -0,0 +1,80 @@
package com.meowarex.rlmobile.ui.previews.screens
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.*
import com.meowarex.rlmobile.network.utils.SemVer
import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsScreenContent
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
// 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 ComponentOptionsScreenPreview(
@PreviewParameter(ComponentOptionsParametersProvider::class)
parameters: ComponentOptionsParameters,
) {
ManagerTheme {
ComponentOptionsScreenContent(
componentType = parameters.componentType,
components = parameters.components,
selected = parameters.selected,
onSelectComponent = {},
onDeleteComponent = {},
onBackPressed = {},
)
}
}
private data class ComponentOptionsParameters(
val componentType: PatchComponent.Type,
val components: ImmutableList<PatchComponent>,
val selected: PatchComponent?,
)
private class ComponentOptionsParametersProvider : PreviewParameterProvider<ComponentOptionsParameters> {
private val components = persistentListOf(
PatchComponent(
type = PatchComponent.Type.Injector,
version = SemVer(1, 2, 3),
timestamp = Clock.System.now(),
),
PatchComponent(
type = PatchComponent.Type.Injector,
version = SemVer(2, 3, 1),
timestamp = Clock.System.now() - 10.minutes,
),
PatchComponent(
type = PatchComponent.Type.Injector,
version = SemVer(2, 3, 1),
timestamp = Clock.System.now() - 1.days,
),
PatchComponent(
type = PatchComponent.Type.Injector,
version = SemVer(0, 0, 1),
timestamp = Clock.System.now() - 10.hours,
),
PatchComponent(
type = PatchComponent.Type.Injector,
version = SemVer(3, 0, 2),
timestamp = Clock.System.now() - 7.days,
),
)
override val values = sequenceOf(
ComponentOptionsParameters(
componentType = PatchComponent.Type.Injector,
components = components,
selected = null,
),
)
}
@@ -0,0 +1,99 @@
package com.meowarex.rlmobile.ui.previews.screens
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.*
import com.meowarex.rlmobile.network.utils.SemVer
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
import com.meowarex.rlmobile.ui.screens.patchopts.*
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import kotlin.time.Clock
// 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 PatchOptionsScreenPreview(
@PreviewParameter(PatchOptionsParametersProvider::class)
parameters: PatchOptionsParameters,
) {
ManagerTheme {
PatchOptionsScreenContent(
isUpdate = parameters.isUpdate,
isDevMode = parameters.isDevMode,
debuggable = parameters.debuggable,
setDebuggable = {},
appName = parameters.appName,
appNameIsError = parameters.appNameIsError,
setAppName = {},
packageName = parameters.packageName,
packageNameState = parameters.packageNameState,
setPackageName = {},
customInjector = parameters.customInjector,
onSelectCustomInjector = {},
customPatches = parameters.customPatches,
onSelectCustomPatches = {},
isConfigValid = parameters.isConfigValid,
onInstall = {},
)
}
}
private data class PatchOptionsParameters(
val isUpdate: Boolean,
val isDevMode: Boolean,
val debuggable: Boolean,
val appName: String,
val appNameIsError: Boolean,
val packageName: String,
val packageNameState: PackageNameState,
val customInjector: PatchComponent?,
val customPatches: PatchComponent?,
val isConfigValid: Boolean,
)
private class PatchOptionsParametersProvider : PreviewParameterProvider<PatchOptionsParameters> {
override val values = sequenceOf(
PatchOptionsParameters(
isUpdate = false,
isDevMode = false,
debuggable = false,
appName = PatchOptions.Default.appName,
appNameIsError = false,
packageName = PatchOptions.Default.packageName,
packageNameState = PackageNameState.Ok,
customInjector = null,
customPatches = null,
isConfigValid = true,
),
PatchOptionsParameters(
isUpdate = true,
isDevMode = false,
debuggable = false,
appName = "an invalid app name.",
appNameIsError = true,
packageName = "a b",
packageNameState = PackageNameState.Invalid,
customInjector = null,
customPatches = null,
isConfigValid = false,
),
PatchOptionsParameters(
isUpdate = false,
isDevMode = true,
debuggable = true,
appName = PatchOptions.Default.appName,
appNameIsError = false,
packageName = PatchOptions.Default.packageName,
packageNameState = PackageNameState.Taken,
customInjector = PatchComponent(
type = PatchComponent.Type.Injector,
version = SemVer(1, 2, 3),
timestamp = Clock.System.now(),
),
customPatches = null,
isConfigValid = true,
),
)
}
@@ -0,0 +1,30 @@
package com.meowarex.rlmobile.ui.previews.screens
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.manager.InstallerSetting
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreenContent
import com.meowarex.rlmobile.ui.theme.ManagerTheme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
fun PermissionsScreenPreview() {
ManagerTheme {
PermissionsScreenContent(
installer = InstallerSetting.PackageInstaller,
openInstallersDialog = {},
storagePermsGranted = true,
onGrantStoragePerms = {},
unknownSourcesPermsGranted = true,
onGrantUnknownSourcesPerms = {},
notificationsPermsGranted = false,
onGrantNotificationsPerms = {},
batteryPermsGranted = false,
onGrantBatteryPerms = {},
canContinue = true,
onContinue = {},
)
}
}
@@ -0,0 +1,19 @@
package com.meowarex.rlmobile.ui.previews.screens.about
import android.content.res.Configuration
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent
import com.meowarex.rlmobile.ui.screens.about.AboutScreenState
import com.meowarex.rlmobile.ui.theme.ManagerTheme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun AboutScreenFailedPreview() {
ManagerTheme {
AboutScreenContent(
state = remember { mutableStateOf(AboutScreenState.Failure) },
)
}
}
@@ -0,0 +1,49 @@
package com.meowarex.rlmobile.ui.previews.screens.about
import android.content.res.Configuration
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.*
import com.meowarex.rlmobile.network.models.Contributor
import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent
import com.meowarex.rlmobile.ui.screens.about.AboutScreenState
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import com.meowarex.rlmobile.ui.util.emptyImmutableList
import com.meowarex.rlmobile.util.serialization.ImmutableListSerializer
import kotlinx.collections.immutable.*
import kotlinx.serialization.json.Json
// This preview has scrollable content 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 AboutScreenLoadedPreview(
@PreviewParameter(ContributorsProvider::class)
contributors: ImmutableList<Contributor>,
) {
ManagerTheme {
AboutScreenContent(
state = remember { mutableStateOf(AboutScreenState.Loaded(contributors)) },
)
}
}
private class ContributorsProvider : PreviewParameterProvider<ImmutableList<Contributor>> {
@Suppress("unused")
private val realDataRaw =
"[{\"username\":\"meowarex\",\"avatarUrl\":\"https://avatars.githubusercontent.com/u/0?v=4\",\"commits\":1,\"repositories\":[{\"name\":\"Radiant Lyrics\",\"commits\":1}]}]"
private val realData = Json.decodeFromString(ImmutableListSerializer(Contributor.serializer()), realDataRaw)
override val values = sequenceOf(
emptyImmutableList<Contributor>(),
persistentListOf(
Contributor(
username = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
avatarUrl = "UNUSED",
commits = Int.MAX_VALUE,
repositories = (realData[0].repositories + realData[0].repositories).toImmutableList(),
)
),
realData,
)
}
@@ -0,0 +1,22 @@
package com.meowarex.rlmobile.ui.previews.screens.about
import android.content.res.Configuration
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent
import com.meowarex.rlmobile.ui.screens.about.AboutScreenState
import com.meowarex.rlmobile.ui.theme.ManagerTheme
// This preview cannot be properly viewed from an IDE
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun AboutScreenFailedPreview() {
ManagerTheme {
AboutScreenContent(
state = remember { mutableStateOf(AboutScreenState.Loading) },
)
}
}
@@ -0,0 +1,22 @@
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)
}
}
}
@@ -0,0 +1,77 @@
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,
)
)
),
)
}
@@ -0,0 +1,24 @@
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)
}
}
}
@@ -0,0 +1,25 @@
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 = {},
)
}
}
}
@@ -0,0 +1,78 @@
package com.meowarex.rlmobile.ui.previews.screens.logs
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.logs.LogEntry
import com.meowarex.rlmobile.ui.screens.logs.LogsScreenContent
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import kotlinx.collections.immutable.persistentListOf
import java.util.UUID
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun LogsListScreenNonePreview() {
ManagerTheme {
LogsScreenContent(
logs = logs,
onOpenLog = {},
onDeleteLogs = {},
)
}
}
private val logs: SnapshotStateList<LogEntry> = mutableStateListOf(
LogEntry(
id = UUID.randomUUID().toString(),
isError = false,
installDate = "5 min. ago, 10:18AM",
durationSecs = 18.555f,
stacktracePreview = null,
),
LogEntry(
id = UUID.randomUUID().toString(),
isError = true,
installDate = "7 min. ago, 10:17AM",
durationSecs = 73.095f,
stacktracePreview = persistentListOf(
"kotlinx.coroutines.JobCancellationException: Job was cancelled; job=SupervisorJobImpl{Cancelling}@833e76f]",
),
),
LogEntry(
id = UUID.randomUUID().toString(),
isError = false,
installDate = "Yesterday, 11:37PM",
durationSecs = 58.439f,
stacktracePreview = null,
),
LogEntry(
id = UUID.randomUUID().toString(),
isError = true,
installDate = "Yesterday, 11:17PM",
durationSecs = 24.405f,
stacktracePreview = persistentListOf(
"java.lang.Error: Installation was aborted or cancelled",
),
),
LogEntry(
id = UUID.randomUUID().toString(),
isError = true,
installDate = "Yesterday, 11:17PM",
durationSecs = 0.057f,
stacktracePreview = persistentListOf(
"java.lang.IllegalStateException: balls",
"\tat com.meowarex.rlmobile.patcher.steps.prepare.FetchInfoStep.execute(FetchInfoStep.kt:31)",
"\tat com.meowarex.rlmobile.patcher.steps.prepare.FetchInfoStep\$execute\\$1.invokeSuspend(Unknown Source:15)",
),
),
LogEntry(
id = UUID.randomUUID().toString(),
isError = false,
installDate = "Yesterday, 1:11PM",
durationSecs = 210.539f,
stacktracePreview = null,
),
)
@@ -0,0 +1,20 @@
package com.meowarex.rlmobile.ui.previews.screens.logs
import android.content.res.Configuration
import androidx.compose.runtime.*
import androidx.compose.ui.tooling.preview.Preview
import com.meowarex.rlmobile.ui.screens.logs.LogsScreenContent
import com.meowarex.rlmobile.ui.theme.ManagerTheme
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun LogsListScreenNonePreview() {
ManagerTheme {
LogsScreenContent(
logs = remember { mutableStateListOf() },
onOpenLog = {},
onDeleteLogs = {},
)
}
}
@@ -0,0 +1,31 @@
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 = {}
)
}
}
@@ -0,0 +1,94 @@
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,
)
),
)
@@ -0,0 +1,31 @@
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 = {}
)
}
}
@@ -0,0 +1,14 @@
package com.meowarex.rlmobile.ui.screens.about
import cafe.adriel.voyager.core.model.StateScreenModel
import com.meowarex.rlmobile.network.models.Contributor
import com.meowarex.rlmobile.network.services.HttpService
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
class AboutModel(
@Suppress("unused") private val http: HttpService,
) : StateScreenModel<AboutScreenState>(
AboutScreenState.Loaded(emptyList<Contributor>().toUnsafeImmutable())
) {
fun fetchContributors() = Unit
}
@@ -0,0 +1,110 @@
package com.meowarex.rlmobile.ui.screens.about
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.*
import com.meowarex.rlmobile.ui.screens.about.components.LeadContributor
import com.meowarex.rlmobile.ui.util.paddings.*
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
class AboutScreen : Screen, Parcelable {
@IgnoredOnParcel
override val key = "About"
@Composable
override fun Content() {
val model = koinScreenModel<AboutModel>()
AboutScreenContent(state = model.state.collectAsState())
}
}
@Composable
fun AboutScreenContent(state: State<AboutScreenState>) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.navigation_about)) },
navigationIcon = { BackButton() },
)
}
) { paddingValues ->
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = paddingValues
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
.add(PaddingValues(vertical = 16.dp)),
modifier = Modifier
.padding(paddingValues.exclude(PaddingValuesSides.Bottom))
.padding(horizontal = 14.dp),
) {
item(key = "PROJECT_HEADER") {
ProjectHeader()
}
item(key = "HEADER_DIVIDER") {
TextDivider(
text = stringResource(R.string.contributors_lead),
modifier = Modifier.padding(top = 18.dp, bottom = 20.dp),
)
}
item(key = "MAIN_CONTRIBUTORS") {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
LeadContributor("meowarex", "Radiant Lyrics")
}
}
item(key = "CONTRIBUTORS_DIVIDER") {
TextDivider(
text = stringResource(R.string.contributors),
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp)
)
}
when (val state = state.value) {
AboutScreenState.Loading -> item(key = "CONTRIBUTIONS_LOADING") {
Box(
contentAlignment = Alignment.Center,
content = { CircularProgressIndicator() },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 38.dp),
)
}
AboutScreenState.Failure -> item(key = "LOAD_FAILURE") {
LoadFailure(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 38.dp)
)
}
is AboutScreenState.Loaded -> {
items(state.contributors, key = { it.username }) { user ->
ContributorCommitsItem(user)
}
}
}
}
}
}
@@ -0,0 +1,10 @@
package com.meowarex.rlmobile.ui.screens.about
import com.meowarex.rlmobile.network.models.Contributor
import kotlinx.collections.immutable.ImmutableList
sealed interface AboutScreenState {
data object Loading : AboutScreenState
data object Failure : AboutScreenState
data class Loaded(val contributors: ImmutableList<Contributor>) : AboutScreenState
}
@@ -0,0 +1,74 @@
package com.meowarex.rlmobile.ui.screens.about.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.SubcomposeAsyncImage
import com.valentinilk.shimmer.shimmer
@Composable
fun LeadContributor(
name: String,
roles: String,
username: String = name,
modifier: Modifier = Modifier,
) {
val uriHandler = LocalUriHandler.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
.clickable(
onClick = { uriHandler.openUri("https://github.com/$username") },
indication = ripple(bounded = false, radius = 90.dp),
interactionSource = remember(::MutableInteractionSource)
)
.widthIn(min = 100.dp)
) {
SubcomposeAsyncImage(
model = "https://github.com/$username.png",
contentDescription = username,
error = {
Surface(
content = {},
tonalElevation = 2.dp,
modifier = Modifier
.fillMaxSize()
.shimmer(),
)
},
modifier = Modifier
.size(80.dp)
.clip(CircleShape),
)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium.copy(
fontSize = 18.sp
)
)
Text(
text = roles,
style = MaterialTheme.typography.titleSmall.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
)
}
}
}
@@ -0,0 +1,70 @@
package com.meowarex.rlmobile.ui.screens.componentopts
import android.app.Application
import androidx.compose.runtime.*
import cafe.adriel.voyager.core.model.screenModelScope
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.PathManager
import com.meowarex.rlmobile.network.utils.SemVer
import com.meowarex.rlmobile.ui.util.ScreenModelWithResult
import com.meowarex.rlmobile.ui.util.ScreenResultKey
import com.meowarex.rlmobile.util.*
import kotlinx.coroutines.launch
import kotlin.time.Instant
class ComponentOptionsModel(
screenResultKey: ScreenResultKey,
private val paths: PathManager,
private val context: Application,
) : ScreenModelWithResult<PatchComponent?>(screenResultKey) {
val components = mutableStateListOf<PatchComponent>()
var selected by mutableStateOf<PatchComponent?>(null)
private set
fun selectComponent(component: PatchComponent?) {
selected = component
}
fun deleteComponent(component: PatchComponent) = screenModelScope.launchIO {
component.getFile(paths).delete()
mainThread {
components.remove(component)
context.showToast(R.string.componentopts_deleted)
}
}
/**
* Loads the available imported custom components for a specified type.
*/
suspend fun refreshComponents(type: PatchComponent.Type) {
val files = when (type) {
PatchComponent.Type.Injector -> paths.customInjectors()
PatchComponent.Type.Patches -> paths.customSmaliPatches()
}
// ${timestamp}_${componentVersion}.${componentFile.extension}
val componentNameRegex = """^(\d+)_(\d+\.\d+.\d+)\.\w+$""".toRegex()
val newComponents = files.mapNotNull { file ->
val match = componentNameRegex.find(file.name)
?: return@mapNotNull null
val (_, timestamp, version) = match.groupValues
PatchComponent(
type = type,
version = SemVer.parse(version),
timestamp = Instant.fromEpochMilliseconds(timestamp.toLong()),
)
}.sortedByDescending { it.timestamp }
mainThread {
components.clear()
components.addAll(newComponents)
}
}
override fun onDispose() {
screenModelScope.launch { setResult(selected) }
}
}
@@ -0,0 +1,131 @@
package com.meowarex.rlmobile.ui.screens.componentopts
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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.componentopts.components.*
import com.meowarex.rlmobile.ui.util.ScreenWithResult
import com.meowarex.rlmobile.ui.util.paddings.*
import com.meowarex.rlmobile.util.back
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.core.parameter.parametersOf
@Parcelize
class ComponentOptionsScreen(
/**
* The type of custom component that this screen will be selecting.
*/
private val componentType: PatchComponent.Type,
/**
* A previously selected custom component that should be pre-selected on this screen.
*/
private val default: PatchComponent?,
) : ScreenWithResult<PatchComponent?>(), Parcelable {
@IgnoredOnParcel
override val key = "ComponentOptions-$componentType"
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val model = koinScreenModel<ComponentOptionsModel> { parametersOf(this.resultKey) }
LaunchedEffect(Unit) {
model.components.clear()
withContext(Dispatchers.IO) {
model.refreshComponents(componentType)
}
if (default in model.components) {
model.selectComponent(default)
}
}
ComponentOptionsScreenContent(
componentType = componentType,
components = model.components.toImmutableList(),
selected = model.selected,
onSelectComponent = model::selectComponent,
onDeleteComponent = model::deleteComponent,
onBackPressed = { navigator.back(null) },
)
}
}
@Composable
fun ComponentOptionsScreenContent(
componentType: PatchComponent.Type,
components: ImmutableList<PatchComponent>,
selected: PatchComponent?,
onSelectComponent: (PatchComponent?) -> Unit,
onDeleteComponent: (PatchComponent) -> Unit,
onBackPressed: () -> Unit,
) {
Scaffold(
topBar = { ComponentOptionsAppBar(componentType = componentType) },
) { paddingValues ->
LazyColumn(
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = paddingValues
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
.add(PaddingValues(16.dp)),
modifier = Modifier
.padding(paddingValues.exclude(PaddingValuesSides.Bottom)),
) {
item(key = "NONE") {
PatchComponentCardBase(
selected = selected == null,
onSelect = { onSelectComponent(null) },
) {
Text(
text = stringResource(R.string.componentopts_selected_none),
style = MaterialTheme.typography.titleMedium,
)
}
}
items(
items = components,
contentType = { "COMPONENT" },
key = { it },
) { component ->
PatchComponentCard(
version = component.version,
timestamp = component.timestamp,
selected = selected == component,
onSelect = { onSelectComponent(component) },
onDelete = { onDeleteComponent(component) },
)
}
item("EXIT_BTN") {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
) {
FilledTonalButton(
onClick = onBackPressed,
) {
Text(stringResource(R.string.action_confirm))
}
}
}
}
}
}
@@ -0,0 +1,61 @@
package com.meowarex.rlmobile.ui.screens.componentopts
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import com.meowarex.rlmobile.manager.PathManager
import com.meowarex.rlmobile.network.utils.SemVer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.io.File
import kotlin.time.Instant
/**
* A custom component that was deployed to this device with
* the `deployWithAdb` task and imported by Manager.
*/
@Immutable
@Parcelize
@Serializable
data class PatchComponent(
/**
* The type of this custom component.
*/
val type: Type,
/**
* The build version of this custom component.
*/
val version: SemVer,
/**
* The time at which this custom component was deployed to the device and imported by manager.
*/
val timestamp: Instant,
) : Parcelable {
@Parcelize
@Serializable
enum class Type : Parcelable {
@SerialName("injector")
Injector,
@SerialName("patches")
Patches,
}
/**
* Returns the imported file where this custom component should be stored.
* This is not guaranteed to exist.
*/
fun getFile(paths: PathManager): File {
val dir = when (type) {
Type.Injector -> paths.customInjectorsDir
Type.Patches -> paths.customPatchesDir
}
val ext = when (type) {
Type.Injector -> "dex"
Type.Patches -> "zip"
}
// ${timestamp}_${componentVersion}.${componentFile.extension}
return dir.resolve("${timestamp.toEpochMilliseconds()}_$version.$ext")
}
}
@@ -0,0 +1,34 @@
package com.meowarex.rlmobile.ui.screens.componentopts.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.BackButton
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
@Composable
fun ComponentOptionsAppBar(
componentType: PatchComponent.Type,
) {
TopAppBar(
navigationIcon = { BackButton() },
title = {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(stringResource(R.string.componentopts_screen_title, componentType.name))
Text(
text = stringResource(R.string.componentopts_screen_desc),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(.7f),
)
}
},
)
}
@@ -0,0 +1,115 @@
package com.meowarex.rlmobile.ui.screens.componentopts.components
import android.text.format.DateUtils
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.network.utils.SemVer
import kotlin.time.Instant
@Composable
fun PatchComponentCard(
version: SemVer,
timestamp: Instant,
selected: Boolean,
onSelect: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
PatchComponentCardBase(
selected = selected,
onSelect = onSelect,
modifier = modifier,
) {
Icon(
painter = painterResource(R.drawable.ic_page),
contentDescription = null,
modifier = Modifier
.alpha(.8f)
.padding(end = 4.dp),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "v$version",
style = MaterialTheme.typography.titleMedium,
)
Text(
text = DateUtils.getRelativeDateTimeString(
/* c = */ LocalContext.current,
/* time = */ timestamp.toEpochMilliseconds(),
/* minResolution = */ DateUtils.SECOND_IN_MILLIS,
/* transitionResolution = */ DateUtils.WEEK_IN_MILLIS,
/* flags = */ DateUtils.FORMAT_ABBREV_ALL,
).toString(),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(.6f),
)
}
Spacer(Modifier.weight(1f, fill = true))
IconButton(
onClick = onDelete,
) {
Icon(
painter = painterResource(R.drawable.ic_delete_forever),
tint = MaterialTheme.colorScheme.error,
contentDescription = stringResource(R.string.action_delete),
)
}
}
}
@Composable
fun PatchComponentCardBase(
selected: Boolean,
onSelect: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit,
) {
val interaction = remember(::MutableInteractionSource)
Surface(
tonalElevation = 1.dp,
shadowElevation = 1.dp,
modifier = modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable(
interactionSource = interaction,
role = Role.RadioButton,
onClick = onSelect,
),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(10.dp),
) {
RadioButton(
selected = selected,
onClick = onSelect,
interactionSource = interaction,
)
content()
}
}
}
@@ -0,0 +1,243 @@
package com.meowarex.rlmobile.ui.screens.home
import android.app.Application
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.provider.Settings
import android.util.Log
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.github.diamondminer88.zip.ZipReader
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.network.models.RLBuildInfo
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
import com.meowarex.rlmobile.network.utils.fold
import com.meowarex.rlmobile.patcher.InstallMetadata
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
import com.meowarex.rlmobile.ui.util.TidalVersion
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
import com.meowarex.rlmobile.util.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
class HomeModel(
private val application: Application,
private val github: RadiantLyricsGithubService,
private val json: Json,
) : ScreenModel {
var installsState by mutableStateOf<InstallsState>(InstallsState.Fetching)
private set
private val refreshingLock = Mutex()
private var remoteDataJson: RLBuildInfo? = null
init {
refresh()
}
fun refresh(delay: Boolean = false) = screenModelScope.launchIO {
if (refreshingLock.isLocked) return@launchIO
if (delay) {
delay(250)
if (refreshingLock.isLocked)
return@launchIO
}
refreshingLock.withLock {
val packages = fetchRadiantLyricsPackages()
val jobs = listOf(
screenModelScope.launch(Dispatchers.IO) {
fetchInstallations(packages)
},
screenModelScope.launch(Dispatchers.IO) {
if (remoteDataJson == null)
fetchRemoteData()
}
)
jobs.joinAll()
mainThread { refreshInstallationsUpToDate(packages) }
}
}
fun openApp(packageName: String) {
val launchIntent = application.packageManager
.getLaunchIntentForPackage(packageName)
if (launchIntent != null) {
application.startActivity(launchIntent)
} else {
application.showToast(R.string.launch_app_fail)
}
}
fun openAppInfo(packageName: String) {
val launchIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setData("package:$packageName".toUri())
application.startActivity(launchIntent)
}
fun createPrefilledPatchOptsScreen(packageName: String): PatchOptionsScreen {
val metadata = try {
val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0)
val metadataFile = ZipReader(applicationInfo.publicSourceDir)
.use { it.openEntry("rlmobile.json")?.read() }
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
} catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to parse Radiant Lyrics install metadata from package $packageName", t)
null
}
val patchOptions = metadata?.options
?: PatchOptions.Default.copy(packageName = packageName)
return PatchOptionsScreen(prefilledOptions = patchOptions)
}
private suspend fun fetchInstallations(packages: List<PackageInfo>) {
mainThread {
if (installsState !is InstallsState.Fetched)
installsState = InstallsState.Fetching
}
try {
val packageManager = application.packageManager
val rlMobileInstallations = packages.mapNotNull { pkg ->
@Suppress("DEPRECATION")
val versionCode = pkg.versionCode
val versionName = pkg.versionName ?: return@mapNotNull null
val applicationInfo = pkg.applicationInfo ?: return@mapNotNull null
InstallData(
name = packageManager.getApplicationLabel(applicationInfo).toString(),
packageName = pkg.packageName,
isUpToDate = isInstallationUpToDate(pkg),
icon = packageManager
.getApplicationIcon(applicationInfo)
.toBitmap()
.asImageBitmap()
.let(::BitmapPainter),
version = TidalVersion.Existing(
type = TidalVersion.parseVersionType(versionCode),
name = versionName.split("-")[0].trim(),
code = versionCode,
),
)
}
mainThread {
installsState = if (rlMobileInstallations.isNotEmpty()) {
InstallsState.Fetched(data = rlMobileInstallations.toUnsafeImmutable())
} else {
InstallsState.None
}
}
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to query Radiant Lyrics installations", t)
mainThread { installsState = InstallsState.Error }
}
}
private suspend fun refreshInstallationsUpToDate(packages: List<PackageInfo>) {
val installations = mainThread { (installsState as? InstallsState.Fetched)?.data }
?: return
try {
val newInstallations = installations.map { data ->
val packageInfo = packages.find { it.packageName == data.packageName }
?: throw IllegalStateException("Checking up-to-date status for package that has not been fetched")
data.copy(isUpToDate = isInstallationUpToDate(packageInfo))
}
mainThread { installsState = InstallsState.Fetched(data = newInstallations.toUnsafeImmutable()) }
} catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to check installations up-to-date", t)
mainThread { installsState = InstallsState.Error }
}
}
private suspend fun fetchRemoteData() {
val release = try {
github.getLatestRelease().let { response ->
response.fold(
success = { it },
fail = {
Log.w(BuildConfig.TAG, "Failed to fetch latest release", it)
return
},
)
}
} catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to fetch remote data", t)
mainThread { application.showToast(R.string.home_network_fail) }
return
}
val dataJsonUrl = release.assets
.find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME }
?.browserDownloadUrl
?: run {
Log.w(BuildConfig.TAG, "No data.json asset in latest release")
return
}
github.getBuildInfo(dataJsonUrl).fold(
success = { remoteDataJson = it },
fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build info", it) },
)
if (remoteDataJson == null) {
mainThread { application.showToast(R.string.home_network_fail) }
}
}
private fun fetchRadiantLyricsPackages(): List<PackageInfo> {
return application.packageManager
.getInstalledPackages(PackageManager.GET_META_DATA)
.filter {
it.applicationInfo?.metaData?.containsKey("isRadiantLyrics") == true
}
}
private fun isInstallationUpToDate(pkg: PackageInfo): Boolean? {
val remoteBuildData = remoteDataJson ?: return null
@Suppress("DEPRECATION")
val versionCode = pkg.versionCode
if (remoteBuildData.tidalVersionCode != versionCode) return false
val apkPath = pkg.applicationInfo?.publicSourceDir ?: return false
val installMetadata = try {
val metadataFile = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() }
?: return false
json.decodeFromStream<InstallMetadata>(metadataFile.inputStream())
} catch (t: Throwable) {
Log.d(BuildConfig.TAG, "Failed to parse Radiant Lyrics InstallMetadata from package ${pkg.packageName}", t)
return false
}
if (installMetadata.options.customPatches != null) return true
return remoteBuildData.patchesVersion == installMetadata.patchesVersion
}
}
@@ -0,0 +1,223 @@
package com.meowarex.rlmobile.ui.screens.home
import android.os.Parcelable
import androidx.compose.animation.*
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.LifecycleResumeEffect
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.LoadFailure
import com.meowarex.rlmobile.ui.components.ProjectHeader
import com.meowarex.rlmobile.ui.screens.home.components.*
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen
import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides
import com.meowarex.rlmobile.ui.util.paddings.exclude
import com.meowarex.rlmobile.util.*
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
class HomeScreen : Screen, Parcelable {
@IgnoredOnParcel
override val key = "Home"
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val model = koinScreenModel<HomeModel>()
// Refresh installations list when the screen changes or activity resumes
LifecycleResumeEffect(Unit) {
model.refresh(delay = true)
onPauseOrDispose {}
}
Scaffold(
topBar = { HomeAppBar() },
) { padding ->
when (val state = model.installsState) {
is InstallsState.Fetched -> HomeScreenLoadedContent(
state = state,
padding = padding,
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) },
onUpdate = {
scope.launchIO {
val screen = model.createPrefilledPatchOptsScreen(it)
mainThread { navigator.push(screen) }
}
},
onOpenApp = model::openApp,
onOpenAppInfo = model::openAppInfo,
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
fun HomeScreenLoadingContent(padding: PaddingValues) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
) {
ProjectHeader()
AnimatedVisibility(
visibleState = remember { MutableTransitionState(false) }.apply { targetState = true },
enter = fadeIn(animationSpec = tween(durationMillis = 800)),
exit = ExitTransition.None,
) {
Box(
contentAlignment = Alignment.Center,
content = { CircularProgressIndicator() },
modifier = Modifier
.fillMaxSize(),
)
}
}
}
@Composable
fun HomeScreenLoadedContent(
state: InstallsState.Fetched,
padding: PaddingValues,
onClickInstall: () -> Unit,
onUpdate: (packageName: String) -> Unit,
onOpenApp: (packageName: String) -> Unit,
onOpenAppInfo: (packageName: String) -> Unit,
onOpenPlugins: (packageName: String) -> Unit,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = padding
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top),
modifier = Modifier
.fillMaxSize()
.padding(padding.exclude(PaddingValuesSides.Bottom))
.padding(top = 16.dp, start = 16.dp, end = 16.dp),
) {
item(key = "PROJECT_HEADER") {
ProjectHeader()
}
item(key = "ADD_INSTALL_BUTTON") {
InstallButton(
secondaryInstall = true,
onClick = onClickInstall,
modifier = Modifier
.padding(vertical = 4.dp)
.height(50.dp)
.fillMaxWidth()
)
}
items(state.data, key = { it.packageName }) { item ->
InstalledItemCard(
data = item,
onUpdate = { onUpdate(item.packageName) },
onOpenApp = { onOpenApp(item.packageName) },
onOpenInfo = { onOpenAppInfo(item.packageName) },
onOpenPlugins = { onOpenPlugins(item.packageName) },
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
fun HomeScreenNoneContent(
padding: PaddingValues,
onClickInstall: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize(),
) {
ProjectHeader()
InstallButton(
secondaryInstall = false,
onClick = onClickInstall,
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 = stringResource(R.string.installs_no_installs),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(start = 10.dp),
)
}
}
}
@Composable
fun HomeScreenFailureContent(
padding: PaddingValues,
) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize(),
) {
ProjectHeader()
LoadFailure(modifier = Modifier.fillMaxSize())
}
}
@@ -0,0 +1,14 @@
package com.meowarex.rlmobile.ui.screens.home
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.painter.BitmapPainter
import com.meowarex.rlmobile.ui.util.TidalVersion
@Immutable
data class InstallData(
val name: String,
val packageName: String,
val version: TidalVersion,
val icon: BitmapPainter,
val isUpToDate: Boolean?,
)
@@ -0,0 +1,12 @@
package com.meowarex.rlmobile.ui.screens.home
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
@Immutable
sealed interface InstallsState {
data object None : InstallsState
data object Error : InstallsState
data object Fetching : InstallsState
data class Fetched(val data: ImmutableList<InstallData>) : InstallsState
}
@@ -0,0 +1,42 @@
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),
)
}
}
)
}
@@ -0,0 +1,88 @@
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,
)
}
}
}
}
@@ -0,0 +1,123 @@
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,
)
}
}
}
}
}
@@ -0,0 +1,125 @@
package com.meowarex.rlmobile.ui.screens.log
import android.os.Parcelable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.screen.ScreenKey
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.InstallLogData
import com.meowarex.rlmobile.ui.components.Label
import com.meowarex.rlmobile.ui.screens.log.components.LogAppBar
import com.meowarex.rlmobile.ui.screens.log.components.LogTextArea
import com.meowarex.rlmobile.util.back
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.core.parameter.parametersOf
@Parcelize
class LogScreen(private val installId: String) : Screen, Parcelable {
@IgnoredOnParcel
override val key: ScreenKey
get() = "LogScreen-$installId"
@Composable
override fun Content() {
val model = koinScreenModel<LogScreenModel> { parametersOf(installId) }
val navigator = LocalNavigator.currentOrThrow
if (model.shouldCloseScreen) {
navigator.back(currentActivity = null)
}
model.data?.let {
LogScreenContent(
data = it,
onExportLog = model::saveLog,
onShareLog = model::shareLog,
)
}
}
}
@Composable
fun LogScreenContent(
data: InstallLogData,
onExportLog: () -> Unit,
onShareLog: () -> Unit,
) {
Scaffold(
topBar = {
LogAppBar(
onExportLog = onExportLog,
onShareLog = onShareLog,
)
},
) { paddingValues ->
LazyColumn(
verticalArrangement = Arrangement.spacedBy(28.dp),
modifier = Modifier
.padding(paddingValues)
.padding(vertical = 10.dp, horizontal = 22.dp)
) {
item("INSTALL_INFO") {
Label(
name = stringResource(R.string.log_section_install_info),
description = null,
) {
LogTextArea(
text = """
Installation ID: ${data.id}
Installation Date: ${data.installDate}
""".trimIndent(),
modifier = Modifier.fillMaxWidth(),
)
}
}
item("ENVIRONMENT_INFO") {
Label(
name = stringResource(R.string.log_section_env_info),
description = null,
) {
LogTextArea(
text = data.environmentInfo,
modifier = Modifier.fillMaxWidth(),
)
}
}
if (data.errorStacktrace != null) {
item("ERROR_STACKTRACE") {
Label(
name = stringResource(R.string.log_section_error),
description = null,
) {
LogTextArea(
text = data.errorStacktrace,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
item("LOG") {
Label(
name = stringResource(R.string.log_section_log),
description = null,
) {
LogTextArea(
text = data.installationLog,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
@@ -0,0 +1,94 @@
package com.meowarex.rlmobile.ui.screens.log
import android.app.Application
import android.content.Intent
import android.util.Log
import androidx.compose.runtime.*
import androidx.core.content.FileProvider
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.InstallLogData
import com.meowarex.rlmobile.manager.InstallLogManager
import com.meowarex.rlmobile.util.*
class LogScreenModel(
private val installId: String,
private val logs: InstallLogManager,
private val application: Application,
) : ScreenModel {
var shouldCloseScreen by mutableStateOf(false)
private set
var data by mutableStateOf<InstallLogData?>(null)
private set
init {
loadLogData()
}
/**
* Formats the log data into a file and writes it to the downloads folder.
*/
fun saveLog() = screenModelScope.launchIO {
val data = data ?: return@launchIO
val formattedDate = data.getFormattedInstallDate()
val content = data.getLogFileContents()
application.saveFile("RadiantLyrics Install $formattedDate.log", content)
}
/**
* Writes the log to internal cache and launches a share intent of the log file.
*/
fun shareLog() {
val data = data ?: return
val formattedDate = data.getFormattedInstallDate()
val formattedName = "RadiantLyrics Install $formattedDate.log"
val content = data.getLogFileContents()
val file = application.cacheDir.resolve(formattedName)
val fileUri = FileProvider.getUriForFile(
/* context = */ application,
/* authority = */ "${BuildConfig.APPLICATION_ID}.provider",
/* file = */ file,
/* displayName = */ formattedName,
)
val intent = Intent(Intent.ACTION_SEND)
.setType("text/*")
.putExtra(Intent.EXTRA_STREAM, fileUri)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.let {
Intent.createChooser(
/* target = */ it,
/* title = */ application.getString(R.string.log_action_share),
)
}
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
file.writeText(content)
file.deleteOnExit()
application.startActivity(intent)
} catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to share log", t)
application.showToast(R.string.status_failed)
}
}
private fun loadLogData() = screenModelScope.launchIO {
val result = logs.fetchInstallData(installId)
mainThread {
if (result != null) {
data = result
} else {
shouldCloseScreen = true
application.showToast(R.string.network_load_fail)
}
}
}
}
@@ -0,0 +1,33 @@
package com.meowarex.rlmobile.ui.screens.log.components
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.BackButton
@Composable
fun LogAppBar(
onExportLog: () -> Unit,
onShareLog: () -> Unit,
) {
TopAppBar(
title = { Text(stringResource(R.string.log_title)) },
navigationIcon = { BackButton() },
actions = {
IconButton(onClick = onExportLog) {
Icon(
painter = painterResource(R.drawable.ic_save),
contentDescription = stringResource(R.string.log_action_export),
)
}
IconButton(onClick = onShareLog) {
Icon(
painter = painterResource(R.drawable.ic_share),
contentDescription = stringResource(R.string.log_action_share),
)
}
},
)
}
@@ -0,0 +1,42 @@
package com.meowarex.rlmobile.ui.screens.log.components
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.meowarex.rlmobile.ui.util.horizontalScrollbar
import com.meowarex.rlmobile.ui.util.thenIf
@Composable
fun LogTextArea(
text: String,
modifier: Modifier = Modifier.Companion,
) {
val scrollState = rememberScrollState()
val scrollable by remember { derivedStateOf { scrollState.maxValue > 0 } }
SelectionContainer {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
lineHeight = 18.sp,
fontFamily = FontFamily.Companion.Monospace,
softWrap = false,
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(start = 18.dp, end = 18.dp, top = 14.dp, bottom = 14.dp)
.horizontalScroll(scrollState)
.horizontalScrollbar(scrollState)
.thenIf(scrollable) { padding(bottom = 10.dp) }
)
}
}
@@ -0,0 +1,99 @@
package com.meowarex.rlmobile.ui.screens.logs
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.Scaffold
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.screen.ScreenKey
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.ui.screens.log.LogScreen
import com.meowarex.rlmobile.ui.screens.logs.components.*
import com.meowarex.rlmobile.ui.screens.logs.components.dialogs.DeleteLogsDialog
import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides
import com.meowarex.rlmobile.ui.util.paddings.exclude
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
class LogsListScreen : Screen, Parcelable {
@IgnoredOnParcel
override val key: ScreenKey
get() = "LogsScreen"
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val model = koinScreenModel<LogsListScreenModel>()
var showWipeConfirmDialog by remember { mutableStateOf(false) }
if (showWipeConfirmDialog) {
DeleteLogsDialog(
onConfirm = {
showWipeConfirmDialog = false
model.deleteLogs()
},
onDismiss = {
showWipeConfirmDialog = false
},
)
}
LogsScreenContent(
logs = model.logEntries,
onOpenLog = { navigator.push(LogScreen(installId = it)) },
onDeleteLogs = { showWipeConfirmDialog = true },
)
}
}
@Composable
fun LogsScreenContent(
logs: SnapshotStateList<LogEntry>,
onOpenLog: (id: String) -> Unit,
onDeleteLogs: () -> Unit,
) {
Scaffold(
topBar = {
LogsListAppBar(
onDeleteLogs = onDeleteLogs,
)
},
) { paddingValues ->
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = paddingValues.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top),
modifier = Modifier
.padding(paddingValues.exclude(PaddingValuesSides.Bottom))
.padding(vertical = 12.dp, horizontal = 22.dp)
) {
if (logs.isEmpty()) {
item(key = "EMPTY") {
LogsNone(
modifier = Modifier.fillParentMaxSize(),
)
}
}
items(
items = logs,
contentType = { "LOG" },
key = { it.id },
) { data ->
LogEntryCard(
data = data,
onClick = remember(onOpenLog, data.id) { { onOpenLog(data.id) } },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
@@ -0,0 +1,71 @@
package com.meowarex.rlmobile.ui.screens.logs
import android.app.Application
import android.text.format.DateUtils
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.mutableStateListOf
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.InstallLogManager
import com.meowarex.rlmobile.util.*
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
class LogsListScreenModel(
private val logsManager: InstallLogManager,
private val application: Application,
) : ScreenModel {
/**
* All the loaded log entries sorted descending by creation date.
*/
val logEntries = mutableStateListOf<LogEntry>()
init {
loadLogsList()
}
fun deleteLogs() = screenModelScope.launchIO {
logsManager.deleteAllEntries()
mainThread {
logEntries.clear()
application.showToast(R.string.logs_status_delete_success)
}
}
private fun loadLogsList() = screenModelScope.launchIO {
for (installId in logsManager.fetchInstallDataEntries()) {
val data = logsManager.fetchInstallData(id = installId)
?: continue
val entry = LogEntry(
id = data.id,
isError = data.isError,
installDate = DateUtils.getRelativeDateTimeString(
/* c = */ application,
/* time = */ data.installDate.toEpochMilliseconds(),
/* minResolution = */ DateUtils.SECOND_IN_MILLIS,
/* transitionResolution = */ DateUtils.WEEK_IN_MILLIS,
/* flags = */ DateUtils.FORMAT_ABBREV_ALL,
).toString(),
durationSecs = data.installDuration.inWholeMilliseconds / 1000f,
stacktracePreview = data.errorStacktrace
?.splitToSequence('\n')
?.take(3)
?.toImmutableList(),
)
mainThread { logEntries += entry }
}
}
}
@Immutable
data class LogEntry(
val id: String,
val isError: Boolean,
val installDate: String,
val durationSecs: Float,
val stacktracePreview: ImmutableList<String>?,
)
@@ -0,0 +1,122 @@
package com.meowarex.rlmobile.ui.screens.logs.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.patcher.steps.base.StepState
import com.meowarex.rlmobile.ui.screens.logs.LogEntry
import com.meowarex.rlmobile.ui.screens.patching.components.StepStateIcon
import com.meowarex.rlmobile.ui.screens.patching.components.TimeElapsed
@Composable
fun LogEntryCard(
data: LogEntry,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val errorColor = MaterialTheme.colorScheme.error
ElevatedCard(
shape = RectangleShape,
modifier = modifier
.clickable(onClick = onClick)
.clip(RoundedCornerShape(topStart = 6.dp, 12.0.dp, bottomStart = 6.dp, bottomEnd = 12.0.dp))
.drawWithCache {
val color = when (data.isError) {
true -> errorColor
false -> Color(0xFF59B463)
}
onDrawWithContent {
drawContent()
drawRect(
color = color,
alpha = .8f,
topLeft = Offset.Zero,
size = Size(4.dp.toPx(), size.height),
)
}
}
) {
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 18.dp, horizontal = 22.dp),
) {
StepStateIcon(
state = if (data.isError) StepState.Error else StepState.Success,
size = 24.dp,
)
Column {
Text(
text = when (data.isError) {
true -> stringResource(R.string.status_failed)
false -> stringResource(R.string.status_success)
},
)
Text(
text = data.installDate,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.alpha(.6f),
)
}
Spacer(Modifier.weight(1f, fill = true))
TimeElapsed(
seconds = data.durationSecs,
modifier = Modifier.alpha(.9f),
)
}
if (data.stacktracePreview != null) {
Column(
modifier = Modifier
.padding(start = 26.dp, end = 20.dp, bottom = 18.dp)
// https://stackoverflow.com/a/76270310/13964629
.graphicsLayer(
alpha = .95f,
compositingStrategy = CompositingStrategy.Offscreen,
)
.drawWithContent {
val colors = listOf(Color.Black, Color.Black, Color.Transparent)
drawContent()
drawRect(
brush = Brush.verticalGradient(colors),
blendMode = BlendMode.DstIn,
)
}
) {
// The stacktrace is separated into multiple Text elements because ellipsis is not supported per-line
for (line in data.stacktracePreview) key(line) {
Text(
text = line,
softWrap = false,
overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Companion.Monospace,
)
}
}
}
}
}
@@ -0,0 +1,37 @@
package com.meowarex.rlmobile.ui.screens.logs.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.components.BackButton
import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
@Composable
fun LogsListAppBar(
onDeleteLogs: () -> Unit,
) {
TopAppBar(
navigationIcon = { BackButton() },
title = { Text(stringResource(R.string.logs_title)) },
actions = {
val navigator = LocalNavigator.current
IconButton(onClick = onDeleteLogs) {
Icon(
painter = painterResource(R.drawable.ic_delete_forever),
contentDescription = stringResource(R.string.logs_action_delete_all)
)
}
IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
Icon(
painter = painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings)
)
}
}
)
}
@@ -0,0 +1,33 @@
package com.meowarex.rlmobile.ui.screens.logs.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.draw.alpha
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 LogsNone(modifier: Modifier = Modifier) {
Box(modifier = modifier) {
Column(
modifier = Modifier.align(Alignment.Center),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
painter = painterResource(R.drawable.ic_reciept_off),
contentDescription = null,
)
Text(
text = stringResource(R.string.logs_none),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.alpha(.8f),
)
}
}
}
@@ -0,0 +1,53 @@
package com.meowarex.rlmobile.ui.screens.logs.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 DeleteLogsDialog(
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.logs_wipe_title)) },
text = {
Text(
text = stringResource(R.string.logs_wipe_desc),
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))
}
}
)
}
@@ -0,0 +1,316 @@
package com.meowarex.rlmobile.ui.screens.patching
import android.os.Parcelable
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
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.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.patcher.steps.StepGroup
import com.meowarex.rlmobile.ui.components.MainActionButton
import com.meowarex.rlmobile.ui.components.Wakelock
import com.meowarex.rlmobile.ui.components.dialogs.InstallerAbortDialog
import com.meowarex.rlmobile.ui.components.dialogs.NetworkWarningDialog
import com.meowarex.rlmobile.ui.screens.log.LogScreen
import com.meowarex.rlmobile.ui.screens.patching.components.*
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
import com.meowarex.rlmobile.ui.theme.customColors
import com.meowarex.rlmobile.ui.util.paddings.*
import com.meowarex.rlmobile.ui.util.spacedByLastAtBottom
import com.meowarex.rlmobile.ui.util.thenIf
import com.meowarex.rlmobile.util.back
import com.meowarex.rlmobile.util.isIgnoringBatteryOptimizations
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.filter
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.core.parameter.parametersOf
val VERTICAL_PADDING: Dp = 18.dp
@Parcelize
class PatchingScreen(
/**
* User-selected patching options. This may originate from [PatchOptionsScreen] or
* from an existing installation, to update it.
*/
private val options: PatchOptions,
) : Screen, Parcelable {
@IgnoredOnParcel
override val key = "Patching"
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val model = koinScreenModel<PatchingScreenModel> { parametersOf(options) }
val state by model.state.collectAsState()
val listState = rememberLazyListState()
val showMinimizationWarning = rememberSaveable { !context.isIgnoringBatteryOptimizations() }
// Exit warning dialog (dismiss itself if install process state changes, esp. for Success)
var showAbortWarning by rememberSaveable(model.state.collectAsState().value) { mutableStateOf(false) }
// The currently expanded step group on this screen
var expandedGroup by rememberSaveable { mutableStateOf<StepGroup?>(StepGroup.Prepare) }
// Only show exit warning if currently working
val onTryExit: () -> Unit = remember {
{
// Show cancellation if currently running
if (state == PatchingScreenState.Working && !model.devMode) {
showAbortWarning = true
}
// Go home directly if install was successful
else if (state is PatchingScreenState.Success) {
navigator.popUntilRoot()
}
// Go back to the patch options screen
else {
navigator.back(currentActivity = null)
}
}
}
// Prevent screen from turning off while working
Wakelock(active = state is PatchingScreenState.Working)
LaunchedEffect(state) {
when (state) {
// Go home directly if screen model mandates so (usually caused by cancelled PackageInstaller dialog)
PatchingScreenState.CloseScreen -> navigator.popUntilRoot()
// Close all groups when successfully finished everything
PatchingScreenState.Success -> {
expandedGroup = null
}
else -> {}
}
listState.animateScrollToItem(0)
}
if (model.showNetworkWarningDialog) {
NetworkWarningDialog(
onConfirm = { neverShow ->
model.hideNetworkWarning(neverShow)
model.install()
},
onDismiss = { neverShow ->
model.hideNetworkWarning(neverShow)
navigator.pop()
},
)
}
if (showAbortWarning) {
InstallerAbortDialog(
onDismiss = { showAbortWarning = false },
onConfirm = {
navigator.back(currentActivity = null)
model.cancelInstall()
},
)
} else {
BackHandler(onBack = onTryExit)
}
Scaffold(
topBar = { PatchingAppBar(onTryExit) },
) { paddingValues ->
Column(
verticalArrangement = Arrangement.spacedBy(VERTICAL_PADDING),
modifier = Modifier
.padding(paddingValues.exclude(PaddingValuesSides.Bottom)),
) {
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedByLastAtBottom(0.dp),
contentPadding = paddingValues
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
.add(PaddingValues(bottom = 25.dp)),
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxSize(),
) {
item(key = "MINIMIZATION_WARNING") {
BannerSection(visible = showMinimizationWarning && !state.isFinished) {
TextBanner(
text = stringResource(R.string.installer_banner_minimization),
icon = painterResource(R.drawable.ic_warning),
iconColor = MaterialTheme.customColors.onWarningContainer,
outlineColor = MaterialTheme.customColors.warning,
containerColor = MaterialTheme.customColors.warningContainer,
modifier = Modifier
.padding(bottom = VERTICAL_PADDING)
.fillMaxWidth(),
)
}
}
item(key = "FAILED_BANNER") {
BannerSection(visible = state is PatchingScreenState.Failed) {
val handler = LocalUriHandler.current
TextBanner(
text = stringResource(R.string.installer_banner_failure),
icon = painterResource(R.drawable.ic_warning),
iconColor = MaterialTheme.colorScheme.error,
outlineColor = null,
containerColor = MaterialTheme.colorScheme.errorContainer,
onClick = { handler.openUri("https://tidal.gg/${BuildConfig.SUPPORT_SERVER}") },
modifier = Modifier
.padding(bottom = VERTICAL_PADDING)
.fillMaxWidth(),
)
}
}
item(key = "INSTALLED_BANNER") {
BannerSection(visible = state is PatchingScreenState.Success) {
TextBanner(
text = stringResource(R.string.installer_banner_success),
icon = painterResource(R.drawable.ic_check_circle),
iconColor = Color(0xFF59B463),
outlineColor = MaterialTheme.colorScheme.surfaceVariant,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier
.padding(bottom = VERTICAL_PADDING)
.fillMaxWidth(),
)
}
}
for ((group, steps) in model.steps?.entries ?: persistentListOf()) {
item(key = System.identityHashCode(group)) {
StepGroupCard(
name = stringResource(group.localizedName),
subSteps = steps,
isExpanded = expandedGroup == group,
onExpand = { expandedGroup = group },
modifier = Modifier
.padding(bottom = VERTICAL_PADDING)
.fillMaxWidth()
.thenIf(state is PatchingScreenState.Success) { alpha(.5f) }
)
}
}
item(key = "BUTTONS") {
var cacheCleared by rememberSaveable { mutableStateOf(false) }
val filteredState by remember { model.state.filter { it.isProgressChange } }
.collectAsState(initial = PatchingScreenState.Working)
AnimatedVisibility(
visible = filteredState != PatchingScreenState.Working,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically { it * -2 },
) {
Column(
verticalArrangement = Arrangement.spacedBy(VERTICAL_PADDING / 2),
) {
HorizontalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(bottom = VERTICAL_PADDING / 2)
)
when (filteredState) {
PatchingScreenState.Working -> {}
PatchingScreenState.CloseScreen -> error("unreachable")
PatchingScreenState.Success -> {
MainActionButton(
text = stringResource(R.string.action_launch),
icon = painterResource(R.drawable.ic_launch),
onClick = model::launchApp,
)
}
is PatchingScreenState.Failed -> {
MainActionButton(
text = stringResource(R.string.action_retry_install),
icon = painterResource(R.drawable.ic_refresh),
onClick = model::install,
)
MainActionButton(
text = stringResource(R.string.action_open_error_log),
icon = painterResource(R.drawable.ic_launch),
onClick = { navigator.push(LogScreen(installId = model.getCurrentInstallId()!!)) },
)
}
}
MainActionButton(
text = stringResource(R.string.settings_clear_cache),
icon = painterResource(R.drawable.ic_delete_forever),
enabled = !cacheCleared,
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
),
onClick = {
cacheCleared = true
model.clearCache()
},
)
}
}
}
item(key = "FUN_FACT") {
FunFact(
text = stringResource(model.funFact),
state = state,
)
}
}
}
}
}
}
@Composable
private fun BannerSection(
visible: Boolean,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically(),
modifier = modifier
.padding(bottom = VERTICAL_PADDING),
) {
Column {
content()
HorizontalDivider(
thickness = 1.dp,
modifier = Modifier.padding(vertical = 4.dp),
)
}
}
}
@@ -0,0 +1,220 @@
package com.meowarex.rlmobile.ui.screens.patching
import android.annotation.SuppressLint
import android.app.Application
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.runtime.*
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.*
import com.meowarex.rlmobile.patcher.TidalPatchRunner
import com.meowarex.rlmobile.patcher.StepRunner
import com.meowarex.rlmobile.patcher.steps.StepGroup
import com.meowarex.rlmobile.patcher.steps.base.*
import com.meowarex.rlmobile.patcher.steps.install.InstallStep
import com.meowarex.rlmobile.patcher.util.InsufficientStorageException
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
import com.meowarex.rlmobile.util.*
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.coroutines.*
import java.util.UUID
import kotlin.time.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class PatchingScreenModel(
private val options: PatchOptions,
private val paths: PathManager,
private val prefs: PreferencesManager,
private val application: Application,
private val installLogs: InstallLogManager,
) : StateScreenModel<PatchingScreenState>(PatchingScreenState.Working) {
private var installId: String? = null
private var startTime: Instant? = null
private var runnerJob: Job? = null
private var stepRunner: StepRunner? = null
val devMode get() = prefs.devMode
var showNetworkWarningDialog by mutableStateOf(!alreadyShownNetworkWarning && application.isNetworkDangerous())
private set
var steps by mutableStateOf<ImmutableMap<StepGroup, ImmutableList<Step>>?>(null)
private set
@get:StringRes
var funFact by mutableIntStateOf(0)
private set
init {
if (!prefs.showNetworkWarning)
showNetworkWarningDialog = false
if (!showNetworkWarningDialog)
install()
// Rotate fun facts every so often
screenModelScope.launch {
while (true) {
funFact = FUN_FACTS.random()
delay(8.seconds)
}
}
}
fun hideNetworkWarning(neverShow: Boolean) {
showNetworkWarningDialog = false
alreadyShownNetworkWarning = true
prefs.showNetworkWarning = !neverShow
}
fun launchApp() {
if (state.value !is PatchingScreenState.Success)
return
val launchIntent = application.packageManager
.getLaunchIntentForPackage(options.packageName)
if (launchIntent != null) {
application.startActivity(launchIntent)
} else {
application.showToast(R.string.launch_app_fail)
}
}
fun clearCache() = screenModelScope.launchIO {
paths.clearCache()
mainThread { application.showToast(R.string.action_cleared_cache) }
}
fun getCurrentInstallId(): String? = installId
fun cancelInstall() = screenModelScope.launchIO {
runnerJob?.cancel("Manual cancellation")
// Delete any in-progress downloads to be safe
stepRunner?.also { container ->
val incompleteDownloadStep = container.steps
.filterIsInstance<DownloadStep<*>>()
.lastOrNull { it.state == StepState.Running }
incompleteDownloadStep?.getStoredFile(container)?.delete()
}
paths.patchingWorkingDir.deleteRecursively()
}
fun install() = screenModelScope.launchBlock {
runnerJob?.cancel("Manual cancellation")
mainThread { steps = null }
@SuppressLint("MemberExtensionConflict")
installId = UUID.randomUUID().toString()
startTime = Clock.System.now()
mutableState.value = PatchingScreenState.Working
runnerJob = screenModelScope.launch(Dispatchers.Default) {
Log.i(BuildConfig.TAG, "Starting installation with environment:\n" + installLogs.getEnvironmentInfo())
try {
startPatchRunner()
} catch (_: CancellationException) {
Log.w(BuildConfig.TAG, "Installation was cancelled before completion")
mutableState.value = PatchingScreenState.CloseScreen
} catch (error: Throwable) {
Log.e(BuildConfig.TAG, "Failed to orchestrate patch runner", error)
mutableState.value = PatchingScreenState.Failed(installId = installId!!)
installLogs.storeInstallData(
id = installId!!,
installDate = startTime!!,
installDuration = Duration.ZERO,
options = options,
log = "- Failed to initialize patch runner",
error = error,
)
}
}
}
private suspend fun startPatchRunner() {
val runner = TidalPatchRunner(options)
.also { stepRunner = it }
val newSteps = runner.steps.groupBy { it.group }
.mapValues { it.value.toUnsafeImmutable() }
.toUnsafeImmutable()
mainThread { steps = newSteps }
// Intentionally delay to show the state change of the first step when it runs in the UI.
// Without this, on a fast internet connection the step just immediately shows as "Success".
delay(400)
// Execute all the steps and catch any errors
val error = when (val error = runner.executeAll()) {
null -> {
// If install step is marked skipped then the installation was manually aborted
// and if so, immediately close install screen
if (runner.getStep<InstallStep>().state == StepState.Skipped) {
mutableState.value = PatchingScreenState.CloseScreen
Error("Installation was aborted or cancelled")
.apply { stackTrace = emptyArray() }
}
// At this point, the installation has successfully completed
else {
mutableState.value = PatchingScreenState.Success
null
}
}
else -> {
Log.e(BuildConfig.TAG, "Failed to perform installation process", error)
mutableState.value = PatchingScreenState.Failed(installId = installId!!)
if (error is InsufficientStorageException) {
mainThread { application.showToast(R.string.installer_insufficient_storage) }
}
error
}
}
installLogs.storeInstallData(
id = installId!!,
installDate = startTime!!,
installDuration = runner.steps.sumOf { it.getDuration() }.milliseconds,
options = options,
log = runner.getLog(),
error = error,
)
}
companion object {
// Global state to avoid showing the warning more than once per launch
private var alreadyShownNetworkWarning = false
/**
* Random fun facts to show on the installation screen.
*/
private val FUN_FACTS = arrayOf(
R.string.fun_fact_1,
R.string.fun_fact_2,
R.string.fun_fact_3,
R.string.fun_fact_4,
R.string.fun_fact_5,
R.string.fun_fact_6,
R.string.fun_fact_7,
R.string.fun_fact_8,
R.string.fun_fact_9,
R.string.fun_fact_10,
R.string.fun_fact_11,
R.string.fun_fact_12,
)
}
}
@@ -0,0 +1,16 @@
package com.meowarex.rlmobile.ui.screens.patching
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenState.CloseScreen
sealed interface PatchingScreenState {
data object Working : PatchingScreenState
data object Success : PatchingScreenState
data class Failed(val installId: String) : PatchingScreenState
data object CloseScreen : PatchingScreenState
}
val PatchingScreenState.isProgressChange: Boolean
inline get() = this != CloseScreen
val PatchingScreenState.isFinished: Boolean
inline get() = isProgressChange || this == CloseScreen
@@ -0,0 +1,50 @@
package com.meowarex.rlmobile.ui.screens.patching.components
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenState
import com.meowarex.rlmobile.ui.screens.patching.VERTICAL_PADDING
@Composable
fun FunFact(
text: String,
state: PatchingScreenState,
) {
AnimatedVisibility(
visible = state !is PatchingScreenState.Failed,
enter = fadeIn() + slideInVertically { it * 2 },
exit = fadeOut() + slideOutVertically { it * 2 },
label = "fun fact visibility"
) {
AnimatedContent(
targetState = text,
label = "fun fact change",
transitionSpec = {
val inSpec = fadeIn(tween(220, delayMillis = 90)) + slideInHorizontally { it * -2 }
val outSpec = fadeOut(tween(90)) + slideOutHorizontally { it * 2 }
inSpec togetherWith outSpec
}
) { text ->
Text(
text = stringResource(R.string.fun_fact_prefix, text),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = VERTICAL_PADDING, bottom = 25.dp, start = VERTICAL_PADDING, end = VERTICAL_PADDING)
.fillMaxWidth()
.alpha(.6f)
)
}
}
}
@@ -0,0 +1,24 @@
package com.meowarex.rlmobile.ui.screens.patching.components
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.meowarex.rlmobile.R
@Composable
fun PatchingAppBar(
onBack: () -> Unit,
) {
TopAppBar(
title = { Text(stringResource(R.string.installer)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.ic_back),
contentDescription = stringResource(R.string.navigation_back),
)
}
}
)
}
@@ -0,0 +1,115 @@
package com.meowarex.rlmobile.ui.screens.patching.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.patcher.steps.base.Step
import com.meowarex.rlmobile.patcher.steps.base.StepState
import kotlinx.collections.immutable.ImmutableList
@Composable
fun StepGroupCard(
name: String,
subSteps: ImmutableList<Step>,
isExpanded: Boolean,
onExpand: () -> Unit,
modifier: Modifier = Modifier,
) {
val groupState by remember(subSteps) {
derivedStateOf {
when {
// If all steps are pending then show pending
subSteps.all { it.state == StepState.Pending } -> StepState.Pending
// If any step has finished with an error then default to error
subSteps.any { it.state == StepState.Error } -> StepState.Error
// If all steps have finished as Skipped/Success then show success
subSteps.all { it.state.isFinished } -> StepState.Success
else -> StepState.Running
}
}
}
val totalSeconds = remember(groupState.isFinished) {
if (!groupState.isFinished) {
0f
} else {
subSteps
.sumOf { step -> step.getDuration() }
.div(1000f)
}
}
LaunchedEffect(groupState) {
if (groupState == StepState.Running)
onExpand()
}
Column(
modifier = modifier
.clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.clickable(true, onClick = onExpand)
.fillMaxWidth()
.padding(20.dp)
) {
StepStateIcon(
state = groupState,
size = 24.dp,
)
Text(
text = name,
modifier = Modifier
.basicMarquee()
.weight(0.05f),
)
TimeElapsed(
enabled = groupState.isFinished,
seconds = totalSeconds,
)
if (isExpanded) {
Icon(
painter = painterResource(R.drawable.ic_arrow_up_small),
contentDescription = stringResource(R.string.action_collapse)
)
} else {
Icon(
painter = painterResource(R.drawable.ic_arrow_down_small),
contentDescription = stringResource(R.string.action_expand)
)
}
}
AnimatedVisibility(visible = isExpanded) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(0.6f))
.fillMaxWidth()
.padding(20.dp)
.padding(start = 4.dp)
) {
for (step in subSteps) key(step) {
StepItem(step)
}
}
}
}
}
@@ -0,0 +1,49 @@
package com.meowarex.rlmobile.ui.screens.patching.components
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
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.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.patcher.steps.base.Step
import com.meowarex.rlmobile.patcher.steps.base.StepState
import com.meowarex.rlmobile.ui.util.thenIf
@Composable
fun StepItem(
step: Step,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
StepStateIcon(
size = 18.dp,
state = step.state,
stepProgress = step.progress,
)
Text(
text = stringResource(step.localizedName),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f, true)
.thenIf(step.state == StepState.Running) { basicMarquee() },
)
TimeElapsed(
enabled = step.state != StepState.Pending,
seconds = step.collectDurationAsState().value,
)
}
}
@@ -0,0 +1,88 @@
package com.meowarex.rlmobile.ui.screens.patching.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.patcher.steps.base.StepState
import kotlin.math.floor
import kotlin.math.roundToInt
@Composable
fun StepStateIcon(
state: StepState,
size: Dp,
stepProgress: Float = -1f,
) {
val animatedProgress by animateFloatAsState(
targetValue = stepProgress,
animationSpec = spring(stiffness = Spring.StiffnessVeryLow),
label = "Progress",
)
Crossfade(targetState = state, label = "State CrossFade") { animatedState ->
when (animatedState) {
StepState.Pending -> Icon(
painter = painterResource(R.drawable.ic_circle),
contentDescription = stringResource(R.string.status_queued),
tint = MaterialTheme.colorScheme.onSurface.copy(.2f),
modifier = Modifier.size(size)
)
StepState.Running -> {
val strokeWidth = Dp(floor(size.value / 10) + 1)
if (stepProgress > .05f) {
CircularProgressIndicator(
progress = { animatedProgress },
strokeWidth = strokeWidth,
modifier = Modifier
.size(size)
.semantics { contentDescription = "${(stepProgress * 100).roundToInt()}%" },
)
} else {
val description = stringResource(R.string.status_ongoing)
// Infinite spinner
CircularProgressIndicator(
strokeWidth = strokeWidth,
modifier = Modifier
.size(size)
.semantics { contentDescription = description },
)
}
}
StepState.Success -> Icon(
painter = painterResource(R.drawable.ic_check_circle),
contentDescription = stringResource(R.string.status_success),
tint = Color(0xFF59B463),
modifier = Modifier.size(size)
)
StepState.Error -> Icon(
painter = painterResource(R.drawable.ic_canceled),
contentDescription = stringResource(R.string.status_failed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(size)
)
StepState.Skipped -> Icon(
painter = painterResource(R.drawable.ic_check_circle),
contentDescription = stringResource(R.string.status_skipped),
tint = Color(0xFFAEAEAE),
modifier = Modifier.size(size)
)
}
}
}
@@ -0,0 +1,58 @@
package com.meowarex.rlmobile.ui.screens.patching.components
import androidx.compose.foundation.*
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.ui.util.thenIf
@Composable
fun TextBanner(
text: String,
icon: Painter,
iconColor: Color,
outlineColor: Color?,
containerColor: Color,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.thenIf(outlineColor) { color ->
border(
width = 2.dp,
color = color,
shape = MaterialTheme.shapes.medium,
)
}
.clip(MaterialTheme.shapes.medium)
.background(containerColor)
.thenIf(onClick) { clickable(onClick = it) }
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(horizontal = 20.dp, vertical = 14.dp)
) {
Icon(
painter = icon,
tint = iconColor,
contentDescription = null,
modifier = Modifier.size(28.dp),
)
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
@@ -0,0 +1,30 @@
package com.meowarex.rlmobile.ui.screens.patching.components
import androidx.compose.animation.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.meowarex.rlmobile.R
@Composable
fun TimeElapsed(
seconds: Float,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = enabled,
enter = fadeIn(),
exit = ExitTransition.None,
label = "TimeElapsed Visibility"
) {
Text(
text = stringResource(R.string.time_elapsed_seconds, seconds),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
modifier = modifier,
)
}
}
@@ -0,0 +1,47 @@
package com.meowarex.rlmobile.ui.screens.patchopts
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Immutable
@Parcelize
@Serializable
data class PatchOptions(
/**
* The app name that's user-facing in launchers.
*/
val appName: String,
/**
* Changes the installation package name.
*/
val packageName: String,
/**
* Adding the debuggable APK flag.
*/
val debuggable: Boolean,
/**
* A custom build of injector that was used rather than the latest.
*/
val customInjector: PatchComponent? = null,
/**
* A custom smali patches bundle that was used rather than the latest.
*/
val customPatches: PatchComponent? = null,
) : Parcelable {
companion object {
val Default = PatchOptions(
appName = "TIDAL",
packageName = "com.tidal.music",
debuggable = false,
customInjector = null,
customPatches = null,
)
}
}
@@ -0,0 +1,136 @@
package com.meowarex.rlmobile.ui.screens.patchopts
import android.content.Context
import android.content.pm.PackageManager.NameNotFoundException
import androidx.compose.runtime.*
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.Navigator
import com.meowarex.rlmobile.manager.PreferencesManager
import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsScreen
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
import com.meowarex.rlmobile.ui.util.pushForResult
import com.meowarex.rlmobile.util.*
import kotlinx.coroutines.launch
class PatchOptionsModel(
prefilledOptions: PatchOptions,
private val context: Context,
private val prefs: PreferencesManager,
) : ScreenModel {
// ---------- Package name state ----------
var packageName by mutableStateOf(prefilledOptions.packageName)
private set
var packageNameState by mutableStateOf(PackageNameState.Ok)
private set
fun changePackageName(newPackageName: String) {
packageName = newPackageName
fetchPkgNameStateDebounced()
}
// ---------- App name state ----------
var appName by mutableStateOf(prefilledOptions.appName)
private set
var appNameIsError by mutableStateOf(false)
private set
fun changeAppName(newAppName: String) {
appName = newAppName
appNameIsError = newAppName.length !in (1..150)
}
// ---------- Debuggable state ----------
var debuggable by mutableStateOf(prefilledOptions.debuggable)
private set
fun changeDebuggable(value: Boolean) {
debuggable = value
}
// ---------- Custom components state ----------
var customInjector by mutableStateOf<PatchComponent?>(null)
private set
var customPatches by mutableStateOf<PatchComponent?>(null)
private set
fun selectCustomInjector(navigator: Navigator) = screenModelScope.launch {
customInjector = navigator.pushForResult(
ComponentOptionsScreen(
default = customInjector,
componentType = PatchComponent.Type.Injector,
)
)
}
fun selectCustomPatches(navigator: Navigator) = screenModelScope.launch {
customPatches = navigator.pushForResult(
ComponentOptionsScreen(
default = customPatches,
componentType = PatchComponent.Type.Patches,
)
)
}
// ---------- Config generation ----------
val isConfigValid by derivedStateOf {
val invalidChecks = arrayOf(
packageNameState == PackageNameState.Invalid,
appNameIsError,
)
invalidChecks.none { it }
}
fun generateConfig(): PatchOptions {
if (!isConfigValid) error("invalid config state")
return PatchOptions(
appName = appName,
packageName = packageName,
debuggable = debuggable,
customInjector = customInjector,
customPatches = customPatches,
)
}
// ---------- Other ----------
val isDevMode: Boolean
get() = prefs.devMode
// A throttled variant of fetchPkgNameState()
private val fetchPkgNameStateDebounced: () -> Unit =
screenModelScope.debounce(100L, function = ::fetchPkgNameState)
private suspend fun fetchPkgNameState() {
val state = if (packageName.length !in (3..150) || !PACKAGE_REGEX.matches(this.packageName)) {
PackageNameState.Invalid
} else {
try {
context.packageManager.getPackageInfo(packageName, 0)
PackageNameState.Taken
} catch (_: NameNotFoundException) {
PackageNameState.Ok
}
}
mainThread { packageNameState = state }
}
init {
screenModelScope.launchBlock { fetchPkgNameState() }
}
companion object {
private val PACKAGE_REGEX = """^[a-z]\w*(\.[a-z]\w*)+$"""
.toRegex(RegexOption.IGNORE_CASE)
}
}
enum class PackageNameState {
Ok,
Invalid,
Taken,
}
@@ -0,0 +1,210 @@
package com.meowarex.rlmobile.ui.screens.patchopts
import android.os.Parcelable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.*
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen
import com.meowarex.rlmobile.ui.screens.patchopts.components.PackageNameStateLabel
import com.meowarex.rlmobile.ui.screens.patchopts.components.PatchOptionsAppBar
import com.meowarex.rlmobile.ui.screens.patchopts.components.options.*
import com.meowarex.rlmobile.ui.util.spacedByLastAtBottom
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.core.parameter.parametersOf
@Parcelize
class PatchOptionsScreen(
private val prefilledOptions: PatchOptions? = null,
) : Screen, Parcelable {
@IgnoredOnParcel
override val key = "PatchOptions"
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val model = koinScreenModel<PatchOptionsModel> { parametersOf(prefilledOptions ?: PatchOptions.Default) }
PatchOptionsScreenContent(
isUpdate = prefilledOptions != null,
isDevMode = model.isDevMode,
debuggable = model.debuggable,
setDebuggable = model::changeDebuggable,
appName = model.appName,
appNameIsError = model.appNameIsError,
setAppName = model::changeAppName,
packageName = model.packageName,
packageNameState = model.packageNameState,
setPackageName = model::changePackageName,
customInjector = model.customInjector,
customPatches = model.customPatches,
onSelectCustomInjector = { model.selectCustomInjector(navigator) },
onSelectCustomPatches = { model.selectCustomPatches(navigator) },
isConfigValid = model.isConfigValid,
onInstall = {
navigator.push(PatchingScreen(model.generateConfig()))
},
)
}
}
@Composable
fun PatchOptionsScreenContent(
isUpdate: Boolean,
isDevMode: Boolean,
debuggable: Boolean,
setDebuggable: (Boolean) -> Unit,
appName: String,
appNameIsError: Boolean,
setAppName: (String) -> Unit,
packageName: String,
packageNameState: PackageNameState,
setPackageName: (String) -> Unit,
customInjector: PatchComponent?,
onSelectCustomInjector: () -> Unit,
customPatches: PatchComponent?,
onSelectCustomPatches: () -> Unit,
isConfigValid: Boolean,
onInstall: () -> Unit,
) {
Scaffold(
topBar = { PatchOptionsAppBar(isUpdate = isUpdate) },
) { paddingValues ->
Column(
verticalArrangement = Arrangement.spacedByLastAtBottom(20.dp),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(paddingValues)
.padding(horizontal = 20.dp, vertical = 10.dp)
) {
Text(
text = stringResource(R.string.patchopts_title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
TextDivider(text = stringResource(R.string.patchopts_divider_basic))
val appNameIsDefault by remember {
derivedStateOf {
appName == PatchOptions.Default.appName
}
}
TextPatchOption(
name = stringResource(R.string.patchopts_appname_title),
description = stringResource(R.string.patchopts_appname_desc),
value = appName,
valueIsError = appNameIsError,
valueIsDefault = appNameIsDefault,
onValueChange = setAppName,
onValueReset = { setAppName(PatchOptions.Default.appName) },
)
if (!isUpdate) {
val packageNameIsDefault by remember {
derivedStateOf {
packageName == PatchOptions.Default.packageName
}
}
TextPatchOption(
name = stringResource(R.string.patchopts_pkgname_title),
description = stringResource(R.string.patchopts_pkgname_desc),
value = packageName,
valueIsError = packageNameState == PackageNameState.Invalid,
valueIsDefault = packageNameIsDefault,
onValueChange = setPackageName,
onValueReset = { setPackageName(PatchOptions.Default.packageName) },
) {
PackageNameStateLabel(
state = packageNameState,
modifier = Modifier.padding(start = 4.dp),
)
}
}
if (isDevMode) {
TextDivider(
text = stringResource(R.string.patchopts_divider_advanced),
modifier = Modifier.padding(top = 12.dp),
)
SwitchPatchOption(
icon = painterResource(R.drawable.ic_bug),
name = stringResource(R.string.patchopts_debuggable_title),
description = stringResource(R.string.patchopts_debuggable_desc),
value = debuggable,
onValueChange = setDebuggable,
)
IconPatchOption(
icon = painterResource(R.drawable.ic_extension),
name = stringResource(R.string.patchopts_custom_injector_title),
description = stringResource(R.string.patchopts_custom_injector_desc),
modifier = Modifier.clickable(onClick = onSelectCustomInjector),
) {
FilledTonalButton(onClick = onSelectCustomInjector) {
Text(
text = customInjector?.version?.toString()
?: stringResource(R.string.componentopts_selected_none)
)
}
}
IconPatchOption(
icon = painterResource(R.drawable.ic_extension),
name = stringResource(R.string.patchopts_custom_patches_title),
description = stringResource(R.string.patchopts_custom_patches_desc),
modifier = Modifier.clickable(onClick = onSelectCustomPatches),
) {
FilledTonalButton(onClick = onSelectCustomPatches) {
Text(
text = customPatches?.version?.toString()
?: stringResource(R.string.componentopts_selected_none)
)
}
}
}
Spacer(Modifier.weight(1f))
FilledTonalButton(
enabled = isConfigValid,
onClick = onInstall,
colors = ButtonDefaults.filledTonalButtonColors(
contentColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier
.padding(bottom = 10.dp)
.align(Alignment.End),
) {
Text(stringResource(R.string.action_install))
}
}
}
}
@@ -0,0 +1,65 @@
package com.meowarex.rlmobile.ui.screens.patchopts.components
import androidx.compose.animation.Crossfade
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.graphics.Color
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.screens.patchopts.PackageNameState
@Composable
fun PackageNameStateLabel(
state: PackageNameState,
modifier: Modifier = Modifier,
) {
Crossfade(
targetState = state,
label = "PackageNameStateLabel CrossFade"
) { animatedState ->
val (label, icon, tint) = when (animatedState) {
PackageNameState.Invalid -> Triple(
R.string.patchopts_pkgname_invalid,
R.drawable.ic_canceled,
MaterialTheme.colorScheme.error,
)
PackageNameState.Taken -> Triple(
R.string.patchopts_pkgname_taken,
R.drawable.ic_warning,
Color(0xFFFFCC00),
)
PackageNameState.Ok -> Triple(
R.string.patchopts_pkgname_ok,
R.drawable.ic_check_circle,
Color(0xFF59B463),
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Icon(
painter = painterResource(icon),
contentDescription = null,
tint = tint,
modifier = Modifier.size(20.dp),
)
Text(
text = stringResource(label),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(.7f),
)
}
}
}
@@ -0,0 +1,30 @@
package com.meowarex.rlmobile.ui.screens.patchopts.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.components.BackButton
import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
@Composable
fun PatchOptionsAppBar(
isUpdate: Boolean = false,
) {
TopAppBar(
navigationIcon = { BackButton() },
title = { Text(stringResource(if (!isUpdate) R.string.action_add_install else R.string.action_update_install)) },
actions = {
val navigator = LocalNavigator.current
IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
Icon(
painter = painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings)
)
}
}
)
}
@@ -0,0 +1,50 @@
package com.meowarex.rlmobile.ui.screens.patchopts.components.options
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.graphics.painter.Painter
import androidx.compose.ui.unit.dp
@Composable
fun IconPatchOption(
icon: Painter,
name: String,
description: String,
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(26.dp),
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.weight(1f)
) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.alpha(.7f)
)
}
content()
}
}
@@ -0,0 +1,50 @@
package com.meowarex.rlmobile.ui.screens.patchopts.components.options
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
@Composable
fun SwitchPatchOption(
icon: Painter,
name: String,
description: String,
value: Boolean,
onValueChange: (Boolean) -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
val interactionSource = remember(::MutableInteractionSource)
val onClick = remember(value) { { onValueChange(!value) } }
IconPatchOption(
icon = icon,
name = name,
description = description,
modifier = modifier
.fillMaxWidth()
.clickable(
onClick = onClick,
enabled = enabled,
interactionSource = interactionSource,
indication = null,
role = Role.Switch,
),
) {
Switch(
checked = value,
enabled = enabled,
onCheckedChange = onValueChange,
interactionSource = interactionSource,
modifier = Modifier.padding(start = 6.dp),
)
}
}
@@ -0,0 +1,51 @@
package com.meowarex.rlmobile.ui.screens.patchopts.components.options
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.ui.components.Label
import com.meowarex.rlmobile.ui.components.ResetToDefaultButton
@Composable
fun TextPatchOption(
name: String,
description: String,
value: String,
valueIsError: Boolean,
valueIsDefault: Boolean,
onValueChange: (String) -> Unit,
onValueReset: () -> Unit,
modifier: Modifier = Modifier,
extra: (@Composable ColumnScope.() -> Unit)? = null,
) {
Label(
name = name,
description = description,
modifier = modifier,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
isError = valueIsError,
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
errorContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
),
trailingIcon = {
ResetToDefaultButton(
enabled = !valueIsDefault,
onClick = onValueReset,
)
},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
)
extra?.invoke(this)
}
}
@@ -0,0 +1,139 @@
package com.meowarex.rlmobile.ui.screens.permissions
import android.Manifest
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.runtime.*
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.di.ActivityProvider
import com.meowarex.rlmobile.manager.InstallerSetting
import com.meowarex.rlmobile.manager.PreferencesManager
import com.meowarex.rlmobile.util.*
import java.util.UUID
class PermissionsModel(
private val application: Application,
private val activities: ActivityProvider,
private val preferences: PreferencesManager,
) : ViewModel() {
private var timesRequestedNotificationsPerms = 0
var showInstallersDialog by mutableStateOf(false)
private set
val installer: InstallerSetting
get() = preferences.installer
var storagePermsGranted by mutableStateOf(false)
private set
var unknownSourcesPermsGranted by mutableStateOf(Build.VERSION.SDK_INT < 26)
private set
var notificationsPermsGranted by mutableStateOf(Build.VERSION.SDK_INT < 33)
private set
var batteryPermsGranted by mutableStateOf(Build.VERSION.SDK_INT < 24)
private set
val requiredPermsGranted by derivedStateOf {
// Unknown Sources permission is only required when the installer is PM
if (preferences.installer == InstallerSetting.PackageInstaller && !unknownSourcesPermsGranted)
return@derivedStateOf false
storagePermsGranted
}
val allPermsGranted by derivedStateOf {
requiredPermsGranted && notificationsPermsGranted && batteryPermsGranted
}
fun showInstallersDialog() {
showInstallersDialog = true
}
fun hideInstallersDialog() {
showInstallersDialog = false
}
fun setInstaller(installer: InstallerSetting) {
preferences.installer = installer
}
fun requestUnknownSourcesPerms() {
if (Build.VERSION.SDK_INT < 26) return
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
.setData("package:${BuildConfig.APPLICATION_ID}".toUri())
.let(activities.get<Activity>()::startActivity)
}
fun requestStoragePerms() = permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
@RequiresApi(Build.VERSION_CODES.R)
fun requestManageStoragePerms() {
Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
.setData("package:${BuildConfig.APPLICATION_ID}".toUri())
.let(activities.get<Activity>()::startActivity)
}
fun requestNotificationsPerms() {
if (Build.VERSION.SDK_INT < 33) return
// If the user denies the permission twice (not dismiss), then the dialog will no longer show,
// and the user will have to manually enable it from system settings.
if (++timesRequestedNotificationsPerms <= 2) {
permissionRequestLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
.let(activities.get<Activity>()::startActivity)
}
}
fun grantBatteryPerms() {
if (Build.VERSION.SDK_INT < 23) return
activities.get<Activity>().requestNoBatteryOptimizations()
}
fun refresh() = viewModelScope.launchBlock {
storagePermsGranted = if (Build.VERSION.SDK_INT >= 30) {
Environment.isExternalStorageManager()
} else {
application.selfHasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
unknownSourcesPermsGranted = Build.VERSION.SDK_INT < 26 || application.packageManager.canRequestPackageInstalls()
notificationsPermsGranted = Build.VERSION.SDK_INT < 33 || application.selfHasPermission(Manifest.permission.POST_NOTIFICATIONS)
batteryPermsGranted = Build.VERSION.SDK_INT < 24 || application.isIgnoringBatteryOptimizations()
}
init {
refresh()
}
override fun onCleared() {
permissionRequestLauncher.unregister()
}
/**
* Used for requesting permissions that launch a popup to the user.
* Refreshes the permissions state once returned to the app.
*/
private val permissionRequestLauncher = run {
val activity = activities.get<ComponentActivity>()
activity.activityResultRegistry.register(
key = UUID.randomUUID().toString(),
contract = ActivityResultContracts.RequestPermission(),
callback = { refresh() },
)
}
}
@@ -0,0 +1,222 @@
package com.meowarex.rlmobile.ui.screens.permissions
import android.os.Build
import android.os.Parcelable
import androidx.compose.animation.EnterTransition
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.transitions.ScreenTransition
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.InstallerSetting
import com.meowarex.rlmobile.ui.components.TextDivider
import com.meowarex.rlmobile.ui.components.settings.SettingsItem
import com.meowarex.rlmobile.ui.screens.home.HomeScreen
import com.meowarex.rlmobile.ui.screens.permissions.components.PermissionButton
import com.meowarex.rlmobile.ui.screens.permissions.components.PermissionsAppBar
import com.meowarex.rlmobile.ui.screens.settings.components.InstallersDialog
import com.meowarex.rlmobile.ui.util.paddings.*
import com.meowarex.rlmobile.ui.util.spacedByLastAtBottom
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.compose.viewmodel.koinActivityViewModel
@Parcelize
@OptIn(ExperimentalVoyagerApi::class)
class PermissionsScreen : Screen, ScreenTransition, Parcelable {
@IgnoredOnParcel
override val key = "Permissions"
override fun enter(lastEvent: StackEvent): EnterTransition? = EnterTransition.None
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val model = koinActivityViewModel<PermissionsModel>()
// Go back (ex: HomeScreen) when all permissions have been granted
LaunchedEffect(model.allPermsGranted) {
if (model.allPermsGranted)
navigator.pop()
}
if (model.showInstallersDialog) {
InstallersDialog(
currentInstaller = model.installer,
onDismiss = model::hideInstallersDialog,
onConfirm = model::setInstaller,
)
}
PermissionsScreenContent(
installer = model.installer,
openInstallersDialog = model::showInstallersDialog,
storagePermsGranted = model.storagePermsGranted,
onGrantStoragePerms = if (Build.VERSION.SDK_INT >= 30) {
model::requestManageStoragePerms
} else {
model::requestStoragePerms
},
unknownSourcesPermsGranted = model.unknownSourcesPermsGranted,
onGrantUnknownSourcesPerms = model::requestUnknownSourcesPerms,
notificationsPermsGranted = model.notificationsPermsGranted,
onGrantNotificationsPerms = model::requestNotificationsPerms,
batteryPermsGranted = model.batteryPermsGranted,
onGrantBatteryPerms = model::grantBatteryPerms,
canContinue = model.requiredPermsGranted,
onContinue = { navigator.replace(HomeScreen()) },
)
}
}
@Composable
fun PermissionsScreenContent(
installer: InstallerSetting,
openInstallersDialog: () -> Unit,
storagePermsGranted: Boolean,
onGrantStoragePerms: () -> Unit,
unknownSourcesPermsGranted: Boolean,
onGrantUnknownSourcesPerms: () -> Unit,
notificationsPermsGranted: Boolean,
onGrantNotificationsPerms: () -> Unit,
batteryPermsGranted: Boolean,
onGrantBatteryPerms: () -> Unit,
canContinue: Boolean,
onContinue: () -> Unit,
) {
Scaffold(
topBar = { PermissionsAppBar() },
) { padding ->
LazyColumn(
verticalArrangement = Arrangement.spacedByLastAtBottom(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = padding
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
.add(PaddingValues(bottom = 12.dp, top = 24.dp)),
modifier = Modifier
.fillMaxSize()
.padding(padding.exclude(PaddingValuesSides.Bottom))
) {
item(key = "DIVIDER_OPTIONS", contentType = "DIVIDER") {
TextDivider(
text = stringResource(R.string.permissions_header_options),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
item(key = "INSTALLER") {
SettingsItem(
text = { Text(stringResource(R.string.setting_installer)) },
secondaryText = { Text(stringResource(R.string.setting_installer_desc)) },
icon = { Icon(painterResource(R.drawable.ic_apk_install), null) },
modifier = Modifier.clickable(onClick = openInstallersDialog),
) {
FilledTonalButton(onClick = openInstallersDialog) {
Icon(
painter = installer.icon(),
contentDescription = null,
modifier = Modifier
.size(20.dp)
.padding(end = 6.dp),
)
Text(installer.title())
}
}
}
item(key = "DIVIDER_PERMS", contentType = "DIVIDER") {
TextDivider(
text = stringResource(R.string.permissions_header_permissions),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (installer == InstallerSetting.PackageInstaller) {
item(key = "PERMS_UNKNOWN_SOURCES", contentType = "PERMISSION_BUTTON") {
PermissionButton(
name = stringResource(R.string.permissions_install_title),
description = stringResource(R.string.permissions_install_desc),
granted = unknownSourcesPermsGranted,
required = true,
icon = painterResource(R.drawable.ic_alt_route),
onClick = onGrantUnknownSourcesPerms,
modifier = Modifier.animateItem(),
)
}
}
item(key = "PERMS_STORAGE", contentType = "PERMISSION_BUTTON") {
PermissionButton(
name = stringResource(R.string.permissions_storage_title),
description = stringResource(R.string.permissions_storage_desc),
granted = storagePermsGranted,
required = true,
icon = painterResource(R.drawable.ic_save),
onClick = onGrantStoragePerms,
)
}
item(key = "PERMS_NOTIFICATIONS", contentType = "PERMISSION_BUTTON") {
PermissionButton(
name = stringResource(R.string.permissions_notifs_title),
description = stringResource(R.string.permissions_notifs_desc),
granted = notificationsPermsGranted,
required = false,
icon = painterResource(R.drawable.ic_bell),
onClick = onGrantNotificationsPerms,
)
}
item(key = "PERMS_BATTERY", contentType = "PERMISSION_BUTTON") {
PermissionButton(
name = stringResource(R.string.permissions_battery_title),
description = stringResource(R.string.permissions_battery_desc),
granted = batteryPermsGranted,
required = false,
icon = painterResource(R.drawable.ic_battery_settings),
onClick = onGrantBatteryPerms,
)
}
item(key = "LEGEND") {
Text(
text = stringResource(R.string.permissions_legend, ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.padding(horizontal = 32.dp, vertical = 12.dp)
.fillMaxWidth(),
)
}
item(key = "CONTINUE") {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, end = 32.dp),
) {
FilledTonalButton(
onClick = onContinue,
enabled = canContinue,
) {
Text(stringResource(R.string.action_continue))
}
}
}
}
}
}
@@ -0,0 +1,77 @@
package com.meowarex.rlmobile.ui.screens.permissions.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.graphics.painter.Painter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.meowarex.rlmobile.R
@Composable
fun PermissionButton(
name: String,
description: String,
granted: Boolean,
required: Boolean,
icon: Painter,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier
.heightIn(min = 64.dp)
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 8.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Companion.CenterVertically,
) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
ProvideTextStyle(MaterialTheme.typography.titleSmall) {
Text(name)
if (required) {
Text(
text = "",
color = MaterialTheme.colorScheme.error,
fontSize = 10.sp,
)
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Companion.CenterVertically,
) {
ProvideTextStyle(
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface.copy(0.6f),
),
) {
Text(
text = description,
modifier = Modifier.weight(1f),
)
}
OutlinedButton(
onClick = onClick,
enabled = !granted,
) {
Text(stringResource(if (granted) R.string.permissions_granted else R.string.permissions_grant))
}
}
}
}
@@ -0,0 +1,45 @@
package com.meowarex.rlmobile.ui.screens.permissions.components
import androidx.compose.foundation.layout.*
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.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
@Composable
fun PermissionsAppBar() {
LargeTopAppBar(
title = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 12.dp),
) {
Text(
text = stringResource(R.string.permissions_title),
style = MaterialTheme.typography.displaySmall,
)
Text(
text = stringResource(R.string.permissions_subtitle),
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface.copy(.6f),
),
)
}
},
actions = {
val navigator = LocalNavigator.current
IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
Icon(
painter = painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings)
)
}
}
)
}
@@ -0,0 +1,279 @@
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()
}
}
@@ -0,0 +1,152 @@
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())
}
}
}
}
}
}
}
@@ -0,0 +1,154 @@
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))
}
}
)
}
@@ -0,0 +1,150 @@
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
)
}
}
}
}
}
@@ -0,0 +1,46 @@
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,
)
}
@@ -0,0 +1,35 @@
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,
)
}
}
}
@@ -0,0 +1,31 @@
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))
}
}
}
@@ -0,0 +1,56 @@
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))
}
}
)
}
@@ -0,0 +1,12 @@
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)
}
@@ -0,0 +1,36 @@
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"
}
}
@@ -0,0 +1,115 @@
package com.meowarex.rlmobile.ui.screens.settings
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.util.Log
import androidx.compose.runtime.*
import androidx.core.content.FileProvider
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.di.ActivityProvider
import com.meowarex.rlmobile.manager.*
import com.meowarex.rlmobile.ui.theme.Theme
import com.meowarex.rlmobile.util.*
class SettingsModel(
private val application: Application,
private val activities: ActivityProvider,
private val paths: PathManager,
val preferences: PreferencesManager,
) : ScreenModel {
val installInfo = InstallInfo
var patchedApkExists by mutableStateOf(paths.patchedApk.exists())
private set
var showThemeDialog by mutableStateOf(false)
private set
var showInstallersDialog by mutableStateOf(false)
private set
fun showThemeDialog() {
showThemeDialog = true
}
fun hideThemeDialog() {
showThemeDialog = false
}
fun showInstallersDialog() {
showInstallersDialog = true
}
fun hideInstallersDialog() {
showInstallersDialog = false
}
fun setTheme(theme: Theme) {
preferences.theme = theme
}
fun setInstaller(installer: InstallerSetting) {
preferences.installer = installer
}
fun setKeepPatchedApks(value: Boolean) {
preferences.keepPatchedApks = value
}
fun clearCache() = screenModelScope.launchIO {
paths.clearCache()
mainThread {
patchedApkExists = false
application.showToast(R.string.action_cleared_cache)
}
}
fun copyInstallInfo() {
application.copyToClipboard(installInfo)
application.showToast(R.string.action_copied)
}
fun shareApk() {
val file = paths.patchingWorkingDir.resolve("patched.apk")
val fileUri = FileProvider.getUriForFile(
/* context = */ application,
/* authority = */ "${BuildConfig.APPLICATION_ID}.provider",
/* file = */ file,
/* displayName = */ "RadiantLyrics.apk",
)
val intent = Intent(Intent.ACTION_SEND)
.setType("application/vnd.android.package-archive")
.putExtra(Intent.EXTRA_STREAM, fileUri)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.let {
Intent.createChooser(
/* target = */ it,
/* title = */ application.getString(R.string.log_action_export_apk),
)
}
try {
activities.get<Activity>().startActivity(intent)
} catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to share APK", t)
application.showToast(R.string.status_failed)
}
}
companion object {
@Suppress("KotlinConstantConditions")
private val InstallInfo: String = """
Radiant Lyrics Manager
Version: ${BuildConfig.VERSION_NAME}
Version Code: ${BuildConfig.VERSION_CODE}
Release: ${if (BuildConfig.RELEASE) "Yes" else "No"}
Git Branch: ${BuildConfig.GIT_BRANCH}
Git Commit: ${BuildConfig.GIT_COMMIT}
Git Changes: ${if (BuildConfig.GIT_LOCAL_CHANGES) "Yes" else "No"}
""".trimIndent()
}
}

Some files were not shown because too many files have changed in this diff Show More