Anywhere dropdown menu

This commit is contained in:
Nullptr 2022-07-11 18:13:02 +08:00
parent 8498da904c
commit 75e004e4c7
No known key found for this signature in database
GPG Key ID: 0B9D02052FF536BD
12 changed files with 366 additions and 255 deletions

View File

@ -0,0 +1,64 @@
package org.lsposed.lspatch.ui.component
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.DropdownMenu
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnywhereDropdown(
modifier: Modifier = Modifier,
enabled: Boolean = true,
expanded: Boolean,
onDismissRequest: () -> Unit,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
surface: @Composable () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
val indication = LocalIndication.current
val interactionSource = remember { MutableInteractionSource() }
val state by interactionSource.interactions.collectAsState(null)
var offset by remember { mutableStateOf(Offset.Zero) }
val dpOffset = with(LocalDensity.current) {
DpOffset(offset.x.toDp(), offset.y.toDp())
}
LaunchedEffect(state) {
if (state is PressInteraction.Release) {
val i = state as PressInteraction.Release
offset = i.press.pressPosition
}
}
Box(
modifier = modifier
.combinedClickable(
interactionSource = interactionSource,
indication = indication,
enabled = enabled,
onClick = onClick,
onLongClick = onLongClick
)
) {
surface()
Box {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
offset = dpOffset,
content = content
)
}
}
}

View File

@ -2,8 +2,6 @@ package org.lsposed.lspatch.ui.component
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForwardIos
@ -19,15 +17,13 @@ import androidx.compose.ui.unit.dp
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import org.lsposed.lspatch.ui.theme.LSPTheme
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppItem(
modifier: Modifier = Modifier,
icon: Drawable,
label: String,
packageName: String,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
checked: Boolean? = null,
rightIcon: (@Composable () -> Unit)? = null,
additionalContent: (@Composable ColumnScope.() -> Unit)? = null,
@ -37,10 +33,6 @@ fun AppItem(
Column(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
.padding(20.dp)
) {
Row(
@ -68,7 +60,7 @@ fun AppItem(
if (checked != null) {
Checkbox(
checked = checked,
onCheckedChange = { onClick() },
onCheckedChange = null,
modifier = Modifier.padding(start = 20.dp)
)
}
@ -90,7 +82,6 @@ private fun AppItemPreview() {
icon = shape,
label = "Sample App",
packageName = "org.lsposed.sample",
onClick = {},
rightIcon = { Icon(Icons.Filled.ArrowForwardIos, null) }
)
}

View File

@ -1,5 +1,6 @@
package org.lsposed.lspatch.ui.component.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material.icons.Icons
@ -17,14 +18,13 @@ fun SettingsCheckBox(
modifier: Modifier = Modifier,
checked: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
icon: ImageVector? = null,
title: String,
desc: String? = null,
extraContent: (@Composable ColumnScope.() -> Unit)? = null
) {
SettingsSlot(modifier, enabled, onClick, icon, title, desc, extraContent) {
Checkbox(checked = checked, onCheckedChange = { onClick() })
SettingsSlot(modifier, enabled, icon, title, desc, extraContent) {
Checkbox(checked = checked, onCheckedChange = null)
}
}
@ -35,14 +35,14 @@ private fun SettingsCheckBoxPreview() {
var checked2 by remember { mutableStateOf(false) }
Column {
SettingsCheckBox(
modifier = Modifier.clickable { checked1 = !checked1 },
checked = checked1,
onClick = { checked1 = !checked1 },
title = "Title",
desc = "Description"
)
SettingsCheckBox(
modifier = Modifier.clickable { checked2 = !checked2 },
checked = checked2,
onClick = { checked2 = !checked2 },
icon = Icons.Outlined.Api,
title = "Title",
desc = "Description"

View File

@ -1,6 +1,5 @@
package org.lsposed.lspatch.ui.component.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -16,22 +15,16 @@ import androidx.compose.ui.unit.dp
fun SettingsSlot(
modifier: Modifier,
enabled: Boolean,
onClick: () -> Unit,
icon: ImageVector? = null,
title: String,
desc: String?,
extraContent: (@Composable ColumnScope.() -> Unit)? = null,
action: (@Composable RowScope.() -> Unit)?,
) {
val enabledModifier =
if (enabled) Modifier
.alpha(1f)
.clickable(onClick = onClick)
else Modifier.alpha(0.5f)
Row(
modifier = modifier
.fillMaxWidth()
.then(enabledModifier)
.alpha(if (enabled) 1f else 0.5f)
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
@ -71,9 +64,8 @@ fun SettingsSlot(
fun SettingsItem(
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
icon: ImageVector? = null,
title: String,
desc: String? = null,
extraContent: (@Composable ColumnScope.() -> Unit)? = null
) = SettingsSlot(modifier, enabled, onClick, icon, title, desc, extraContent, null)
) = SettingsSlot(modifier, enabled, icon, title, desc, extraContent, null)

View File

@ -16,14 +16,13 @@ fun SettingsSwitch(
modifier: Modifier = Modifier,
checked: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
icon: ImageVector? = null,
title: String,
desc: String? = null,
extraContent: (@Composable ColumnScope.() -> Unit)? = null
) {
SettingsSlot(modifier.clickable(onClick = onClick), enabled, onClick, icon, title, desc, extraContent) {
Switch(checked = checked, onCheckedChange = { onClick() })
SettingsSlot(modifier, enabled, icon, title, desc, extraContent) {
Switch(checked = checked, onCheckedChange = null)
}
}
@ -34,14 +33,14 @@ private fun SettingsCheckBoxPreview() {
var checked2 by remember { mutableStateOf(false) }
Column {
SettingsSwitch(
modifier = Modifier.clickable { checked1 = !checked1 },
checked = checked1,
onClick = { checked1 = !checked1 },
title = "Title",
desc = "Description"
)
SettingsSwitch(
modifier = Modifier.clickable { checked2 = !checked2 },
checked = checked2,
onClick = { checked2 = !checked2 },
icon = Icons.Outlined.Api,
title = "Title",
desc = "Description"

View File

@ -12,6 +12,7 @@ import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -39,6 +40,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.lsposed.lspatch.R
import org.lsposed.lspatch.lspApp
import org.lsposed.lspatch.ui.component.AnywhereDropdown
import org.lsposed.lspatch.ui.component.SelectionColumn
import org.lsposed.lspatch.ui.component.ShimmerAnimation
import org.lsposed.lspatch.ui.component.settings.SettingsCheckBox
@ -219,64 +221,74 @@ private fun PatchOptionsBody(modifier: Modifier, onAddEmbed: () -> Unit) {
)
}
SettingsCheckBox(
modifier = Modifier.padding(top = 6.dp),
modifier = Modifier
.padding(top = 6.dp)
.clickable { viewModel.debuggable = !viewModel.debuggable },
checked = viewModel.debuggable,
onClick = { viewModel.debuggable = !viewModel.debuggable },
icon = Icons.Outlined.BugReport,
title = stringResource(R.string.patch_debuggable)
)
SettingsCheckBox(
modifier = Modifier.clickable { viewModel.overrideVersionCode = !viewModel.overrideVersionCode },
checked = viewModel.overrideVersionCode,
onClick = { viewModel.overrideVersionCode = !viewModel.overrideVersionCode },
icon = Icons.Outlined.Layers,
title = stringResource(R.string.patch_override_version_code),
desc = stringResource(R.string.patch_override_version_code_desc)
)
Box {
var expanded by remember { mutableStateOf(false) }
SettingsItem(
onClick = { expanded = true },
icon = Icons.Outlined.Edit,
title = stringResource(R.string.patch_sign),
desc = viewModel.sign.mapIndexedNotNull { index, on -> if (on) "V" + (index + 1) else null }.joinToString(" + ").ifEmpty { "None" }
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
repeat(2) { index ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = viewModel.sign[index], onCheckedChange = { viewModel.sign[index] = !viewModel.sign[index] })
Text("V" + (index + 1))
}
},
onClick = { viewModel.sign[index] = !viewModel.sign[index] }
)
}
var signExpanded by remember { mutableStateOf(false) }
AnywhereDropdown(
expanded = signExpanded,
onDismissRequest = { signExpanded = false },
onClick = { signExpanded = true },
surface = {
SettingsItem(
icon = Icons.Outlined.Edit,
title = stringResource(R.string.patch_sign),
desc = viewModel.sign
.mapIndexedNotNull { index, on -> if (on) "V" + (index + 1) else null }
.joinToString(" + ")
.ifEmpty { "None" }
)
}
) {
repeat(2) { index ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = viewModel.sign[index], onCheckedChange = { viewModel.sign[index] = !viewModel.sign[index] })
Text("V" + (index + 1))
}
},
onClick = { viewModel.sign[index] = !viewModel.sign[index] }
)
}
}
Box {
var expanded by remember { mutableStateOf(false) }
SettingsItem(
onClick = { expanded = true },
icon = Icons.Outlined.RemoveModerator,
title = stringResource(R.string.patch_sigbypass),
desc = sigBypassLvStr(viewModel.sigBypassLevel)
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
repeat(3) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = viewModel.sigBypassLevel == it, onClick = { viewModel.sigBypassLevel = it })
Text(sigBypassLvStr(it))
}
},
onClick = {
viewModel.sigBypassLevel = it
expanded = false
var bypassExpanded by remember { mutableStateOf(false) }
AnywhereDropdown(
expanded = bypassExpanded,
onDismissRequest = { bypassExpanded = false },
onClick = { bypassExpanded = true },
surface = {
SettingsItem(
icon = Icons.Outlined.RemoveModerator,
title = stringResource(R.string.patch_sigbypass),
desc = sigBypassLvStr(viewModel.sigBypassLevel)
)
}
) {
repeat(3) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = viewModel.sigBypassLevel == it, onClick = { viewModel.sigBypassLevel = it })
Text(sigBypassLvStr(it))
}
)
}
},
onClick = {
viewModel.sigBypassLevel = it
bypassExpanded = false
}
)
}
}
}

View File

@ -6,6 +6,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@ -124,11 +125,12 @@ private fun SingleSelect(onSelect: (AppInfo) -> Unit) {
key = { it.app.packageName }
) {
AppItem(
modifier = Modifier.animateItemPlacement(spring(stiffness = Spring.StiffnessLow)),
modifier = Modifier
.animateItemPlacement(spring(stiffness = Spring.StiffnessLow))
.clickable { onSelect(it) },
icon = LSPPackageManager.getIcon(it),
label = it.label,
packageName = it.app.packageName,
onClick = { onSelect(it) }
packageName = it.app.packageName
)
}
}
@ -145,14 +147,15 @@ private fun MultiSelect() {
) {
val checked = viewModel.multiSelected.contains(it)
AppItem(
modifier = Modifier.animateItemPlacement(spring(stiffness = Spring.StiffnessLow)),
modifier = Modifier
.animateItemPlacement(spring(stiffness = Spring.StiffnessLow))
.clickable {
if (checked) viewModel.multiSelected.remove(it)
else viewModel.multiSelected.add(it)
},
icon = LSPPackageManager.getIcon(it),
label = it.label,
packageName = it.app.packageName,
onClick = {
if (checked) viewModel.multiSelected.remove(it)
else viewModel.multiSelected.add(it)
},
checked = checked
)
}

View File

@ -2,9 +2,9 @@ package org.lsposed.lspatch.ui.page
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
import org.lsposed.lspatch.R
import org.lsposed.lspatch.config.Configs
import org.lsposed.lspatch.config.MyKeyStore
import org.lsposed.lspatch.ui.component.AnywhereDropdown
import org.lsposed.lspatch.ui.component.CenterTopBar
import org.lsposed.lspatch.ui.component.settings.SettingsItem
import org.lsposed.lspatch.ui.component.settings.SettingsSwitch
@ -56,32 +57,35 @@ fun SettingsScreen() {
private fun KeyStore() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var dropdownExpanded by remember { mutableStateOf(false) }
var expanded by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
Box {
SettingsItem(
onClick = { dropdownExpanded = !dropdownExpanded },
icon = Icons.Outlined.Ballot,
title = stringResource(R.string.settings_keystore),
desc = stringResource(if (MyKeyStore.useDefault) R.string.settings_keystore_default else R.string.settings_keystore_custom)
)
DropdownMenu(expanded = dropdownExpanded, onDismissRequest = { dropdownExpanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.settings_keystore_default)) },
onClick = {
scope.launch { MyKeyStore.reset() }
dropdownExpanded = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.settings_keystore_custom)) },
onClick = {
dropdownExpanded = false
showDialog = true
}
AnywhereDropdown(
expanded = expanded,
onDismissRequest = { expanded = false },
onClick = { expanded = true },
surface = {
SettingsItem(
icon = Icons.Outlined.Ballot,
title = stringResource(R.string.settings_keystore),
desc = stringResource(if (MyKeyStore.useDefault) R.string.settings_keystore_default else R.string.settings_keystore_custom)
)
}
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.settings_keystore_default)) },
onClick = {
scope.launch { MyKeyStore.reset() }
expanded = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.settings_keystore_custom)) },
onClick = {
expanded = false
showDialog = true
}
)
}
if (showDialog) {
@ -106,7 +110,7 @@ private fun KeyStore() {
}
AlertDialog(
onDismissRequest = { dropdownExpanded = false; showDialog = false },
onDismissRequest = { expanded = false; showDialog = false },
confirmButton = {
TextButton(
content = { Text(stringResource(android.R.string.ok)) },
@ -144,14 +148,14 @@ private fun KeyStore() {
}
scope.launch { MyKeyStore.setCustom(password, alias, aliasPassword) }
dropdownExpanded = false
expanded = false
showDialog = false
})
},
dismissButton = {
TextButton(
content = { Text(stringResource(android.R.string.cancel)) },
onClick = { dropdownExpanded = false; showDialog = false }
onClick = { expanded = false; showDialog = false }
)
},
title = {
@ -228,8 +232,8 @@ private fun KeyStore() {
@Composable
private fun DetailPatchLogs() {
SettingsSwitch(
modifier = Modifier.clickable { Configs.detailPatchLogs = !Configs.detailPatchLogs },
checked = Configs.detailPatchLogs,
onClick = { Configs.detailPatchLogs = !Configs.detailPatchLogs },
title = stringResource(R.string.settings_detail_patch_logs)
)
}

View File

@ -42,6 +42,7 @@ import org.lsposed.lspatch.config.Configs
import org.lsposed.lspatch.database.entity.Module
import org.lsposed.lspatch.lspApp
import org.lsposed.lspatch.share.LSPConfig
import org.lsposed.lspatch.ui.component.AnywhereDropdown
import org.lsposed.lspatch.ui.component.AppItem
import org.lsposed.lspatch.ui.component.LoadingDialog
import org.lsposed.lspatch.ui.page.SelectAppsResult
@ -49,6 +50,7 @@ import org.lsposed.lspatch.ui.page.destinations.NewPatchScreenDestination
import org.lsposed.lspatch.ui.page.destinations.SelectAppsScreenDestination
import org.lsposed.lspatch.ui.util.LocalSnackbarHost
import org.lsposed.lspatch.ui.viewmodel.manage.AppManageViewModel
import org.lsposed.lspatch.ui.viewstate.ProcessingState
import org.lsposed.lspatch.util.LSPPackageManager
import org.lsposed.lspatch.util.ShizukuApi
import java.io.IOException
@ -93,22 +95,39 @@ fun AppManageBody(
}
}
if (viewModel.processingUpdate) LoadingDialog()
viewModel.updateLoaderResult?.let {
val updateSuccessfully = stringResource(R.string.manage_update_loader_successfully)
val updateFailed = stringResource(R.string.manage_update_loader_failed)
val copyError = stringResource(R.string.copy_error)
LaunchedEffect(Unit) {
it.onSuccess {
snackbarHost.showSnackbar(updateSuccessfully)
}.onFailure {
val result = snackbarHost.showSnackbar(updateFailed, copyError)
if (result == SnackbarResult.ActionPerformed) {
val cm = lspApp.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("LSPatch", it.toString()))
when (viewModel.updateLoaderState) {
is ProcessingState.Idle -> Unit
is ProcessingState.Processing -> LoadingDialog()
is ProcessingState.Done -> {
val it = viewModel.updateLoaderState as ProcessingState.Done
val updateSuccessfully = stringResource(R.string.manage_update_loader_successfully)
val updateFailed = stringResource(R.string.manage_update_loader_failed)
val copyError = stringResource(R.string.copy_error)
LaunchedEffect(Unit) {
it.result.onSuccess {
snackbarHost.showSnackbar(updateSuccessfully)
}.onFailure {
val result = snackbarHost.showSnackbar(updateFailed, copyError)
if (result == SnackbarResult.ActionPerformed) {
val cm = lspApp.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("LSPatch", it.toString()))
}
}
viewModel.dispatch(AppManageViewModel.ViewAction.ClearUpdateLoaderResult)
}
}
}
when (viewModel.optimizeState) {
is ProcessingState.Idle -> Unit
is ProcessingState.Processing -> LoadingDialog()
is ProcessingState.Done -> {
val it = viewModel.optimizeState as ProcessingState.Done
val optimizeSucceed = stringResource(R.string.manage_optimize_successfully)
val optimizeFailed = stringResource(R.string.manage_optimize_failed)
LaunchedEffect(Unit) {
snackbarHost.showSnackbar(if (it.result) optimizeSucceed else optimizeFailed)
viewModel.dispatch(AppManageViewModel.ViewAction.ClearOptimizeResult)
}
viewModel.dispatch(AppManageViewModel.ViewAction.ClearUpdateLoaderResult)
}
}
@ -118,98 +137,97 @@ fun AppManageBody(
key = { it.first.app.packageName }
) {
var expanded by remember { mutableStateOf(false) }
Box {
AppItem(
icon = LSPPackageManager.getIcon(it.first),
label = it.first.label,
packageName = it.first.app.packageName,
onClick = { expanded = true },
onLongClick = { expanded = true },
additionalContent = {
Text(
text = buildAnnotatedString {
val (text, color) =
if (it.second.useManager) stringResource(R.string.patch_local) to MaterialTheme.colorScheme.secondary
else stringResource(R.string.patch_portable) to MaterialTheme.colorScheme.tertiary
append(AnnotatedString(text, SpanStyle(color = color)))
append(" ")
append(it.second.lspConfig.VERSION_CODE.toString())
},
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Serif,
style = MaterialTheme.typography.bodySmall
)
}
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
val shizukuUnavailable = stringResource(R.string.shizuku_unavailable)
if (it.second.lspConfig.VERSION_CODE < LSPConfig.instance.VERSION_CODE || BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_update_loader)) },
onClick = {
expanded = false
scope.launch {
if (!ShizukuApi.isPermissionGranted) {
snackbarHost.showSnackbar(shizukuUnavailable)
} else {
viewModel.dispatch(AppManageViewModel.ViewAction.UpdateLoader(it.first, it.second))
}
}
}
)
}
if (it.second.useManager) {
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_module_scope)) },
onClick = {
expanded = false
scope.launch {
scopeApp = it.first.app.packageName
val activated = ConfigManager.getModulesForApp(scopeApp).map { it.pkgName }.toSet()
val initialSelected = LSPPackageManager.appList.mapNotNullTo(ArrayList()) {
if (activated.contains(it.app.packageName)) it.app.packageName else null
}
navigator.navigate(SelectAppsScreenDestination(true, initialSelected))
}
}
)
}
val optimizeSucceed = stringResource(R.string.manage_optimize_successfully)
val optimizeFailed = stringResource(R.string.manage_optimize_failed)
AnywhereDropdown(
expanded = expanded,
onDismissRequest = { expanded = false },
onClick = { expanded = true },
surface = {
AppItem(
icon = LSPPackageManager.getIcon(it.first),
label = it.first.label,
packageName = it.first.app.packageName,
additionalContent = {
Text(
text = buildAnnotatedString {
val (text, color) =
if (it.second.useManager) stringResource(R.string.patch_local) to MaterialTheme.colorScheme.secondary
else stringResource(R.string.patch_portable) to MaterialTheme.colorScheme.tertiary
append(AnnotatedString(text, SpanStyle(color = color)))
append(" ")
append(it.second.lspConfig.VERSION_CODE.toString())
},
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Serif,
style = MaterialTheme.typography.bodySmall
)
}
)
}
) {
val shizukuUnavailable = stringResource(R.string.shizuku_unavailable)
if (it.second.lspConfig.VERSION_CODE < LSPConfig.instance.VERSION_CODE || BuildConfig.DEBUG) {
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_optimize)) },
text = { Text(stringResource(R.string.manage_update_loader)) },
onClick = {
expanded = false
scope.launch {
if (!ShizukuApi.isPermissionGranted) {
snackbarHost.showSnackbar(shizukuUnavailable)
} else {
val result = ShizukuApi.performDexOptMode(it.first.app.packageName)
snackbarHost.showSnackbar(if (result) optimizeSucceed else optimizeFailed)
viewModel.dispatch(AppManageViewModel.ViewAction.UpdateLoader(it.first, it.second))
}
}
}
)
val uninstallSuccessfully = stringResource(R.string.manage_uninstall_successfully)
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
scope.launch {
snackbarHost.showSnackbar(uninstallSuccessfully)
}
}
}
}
if (it.second.useManager) {
DropdownMenuItem(
text = { Text(stringResource(R.string.uninstall)) },
text = { Text(stringResource(R.string.manage_module_scope)) },
onClick = {
expanded = false
val intent = Intent(Intent.ACTION_DELETE).apply {
data = Uri.parse("package:${it.first.app.packageName}")
putExtra(Intent.EXTRA_RETURN_RESULT, true)
scope.launch {
scopeApp = it.first.app.packageName
val activated = ConfigManager.getModulesForApp(scopeApp).map { it.pkgName }.toSet()
val initialSelected = LSPPackageManager.appList.mapNotNullTo(ArrayList()) {
if (activated.contains(it.app.packageName)) it.app.packageName else null
}
navigator.navigate(SelectAppsScreenDestination(true, initialSelected))
}
launcher.launch(intent)
}
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_optimize)) },
onClick = {
expanded = false
scope.launch {
if (!ShizukuApi.isPermissionGranted) {
snackbarHost.showSnackbar(shizukuUnavailable)
} else {
viewModel.dispatch(AppManageViewModel.ViewAction.PerformOptimize(it.first))
}
}
}
)
val uninstallSuccessfully = stringResource(R.string.manage_uninstall_successfully)
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
scope.launch {
snackbarHost.showSnackbar(uninstallSuccessfully)
}
}
}
DropdownMenuItem(
text = { Text(stringResource(R.string.uninstall)) },
onClick = {
expanded = false
val intent = Intent(Intent.ACTION_DELETE).apply {
data = Uri.parse("package:${it.first.app.packageName}")
putExtra(Intent.EXTRA_RETURN_RESULT, true)
}
launcher.launch(intent)
}
)
}
}
}

View File

@ -18,6 +18,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel
import org.lsposed.lspatch.R
import org.lsposed.lspatch.ui.component.AnywhereDropdown
import org.lsposed.lspatch.ui.component.AppItem
import org.lsposed.lspatch.ui.viewmodel.manage.ModuleManageViewModel
import org.lsposed.lspatch.util.LSPPackageManager
@ -44,30 +45,36 @@ fun ModuleManageBody() {
key = { it.first.app.packageName }
) {
var expanded by remember { mutableStateOf(false) }
Box {
AppItem(
icon = LSPPackageManager.getIcon(it.first),
label = it.first.label,
packageName = it.first.app.packageName,
onClick = { /* TODO: startAndSendModuleBinder */ },
onLongClick = { expanded = true },
additionalContent = {
Text(
text = it.second.description,
style = MaterialTheme.typography.bodySmall
)
Text(
text = buildAnnotatedString {
append(AnnotatedString("API", SpanStyle(color = MaterialTheme.colorScheme.secondary)))
append(" ")
append(it.second.api.toString())
},
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Serif,
style = MaterialTheme.typography.bodySmall
)
}
)
AnywhereDropdown(
expanded = expanded,
onDismissRequest = { expanded = false },
onClick = { /* TODO: Start module */ },
onLongClick = { expanded = true },
surface = {
AppItem(
icon = LSPPackageManager.getIcon(it.first),
label = it.first.label,
packageName = it.first.app.packageName,
additionalContent = {
Text(
text = it.second.description,
style = MaterialTheme.typography.bodySmall
)
Text(
text = buildAnnotatedString {
append(AnnotatedString("API", SpanStyle(color = MaterialTheme.colorScheme.secondary)))
append(" ")
append(it.second.api.toString())
},
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Serif,
style = MaterialTheme.typography.bodySmall
)
}
)
}
) {
// TODO: Implement
}
}
}

View File

@ -11,14 +11,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.lsposed.lspatch.Patcher
import org.lsposed.lspatch.lspApp
import org.lsposed.lspatch.share.Constants
import org.lsposed.lspatch.share.PatchConfig
import org.lsposed.lspatch.ui.viewstate.ProcessingState
import org.lsposed.lspatch.util.LSPPackageManager
import org.lsposed.lspatch.util.LSPPackageManager.AppInfo
import org.lsposed.lspatch.util.ShizukuApi
import org.lsposed.patch.util.Logger
import java.io.FileNotFoundException
import java.util.zip.ZipFile
@ -32,6 +33,8 @@ class AppManageViewModel : ViewModel() {
sealed class ViewAction {
data class UpdateLoader(val appInfo: AppInfo, val config: PatchConfig) : ViewAction()
object ClearUpdateLoaderResult : ViewAction()
data class PerformOptimize(val appInfo: AppInfo) : ViewAction()
object ClearOptimizeResult : ViewAction()
}
val appList: List<Pair<AppInfo, PatchConfig>> by derivedStateOf {
@ -46,9 +49,10 @@ class AppManageViewModel : ViewModel() {
}
}
var processingUpdate by mutableStateOf(false)
var updateLoaderState: ProcessingState<Result<Unit>> by mutableStateOf(ProcessingState.Idle)
private set
var updateLoaderResult: Result<Unit>? by mutableStateOf(null)
var optimizeState: ProcessingState<Boolean> by mutableStateOf(ProcessingState.Idle)
private set
private val logger = object : Logger() {
@ -65,44 +69,54 @@ class AppManageViewModel : ViewModel() {
}
}
fun dispatch(action: ViewAction) {
when (action) {
is ViewAction.UpdateLoader -> updateLoader(action.appInfo, action.config)
is ViewAction.ClearUpdateLoaderResult -> updateLoaderResult = null
suspend fun dispatch(action: ViewAction) {
withContext(viewModelScope.coroutineContext) {
when (action) {
is ViewAction.UpdateLoader -> updateLoader(action.appInfo, action.config)
is ViewAction.ClearUpdateLoaderResult -> updateLoaderState = ProcessingState.Idle
is ViewAction.PerformOptimize -> performOptimize(action.appInfo)
is ViewAction.ClearOptimizeResult -> optimizeState = ProcessingState.Idle
}
}
}
private fun updateLoader(appInfo: AppInfo, config: PatchConfig) {
private suspend fun updateLoader(appInfo: AppInfo, config: PatchConfig) {
Log.i(TAG, "Update loader for ${appInfo.app.packageName}")
viewModelScope.launch {
processingUpdate = true
val result = runCatching {
withContext(Dispatchers.IO) {
LSPPackageManager.cleanTmpApkDir()
val apkPaths = listOf(appInfo.app.sourceDir) + (appInfo.app.splitSourceDirs ?: emptyArray())
val patchPaths = mutableListOf<String>()
val embeddedModulePaths = if (config.useManager) emptyList<String>() else null
for (apk in apkPaths) {
ZipFile(apk).use { zip ->
var entry = zip.getEntry(Constants.ORIGINAL_APK_ASSET_PATH)
if (entry == null) entry = zip.getEntry("assets/lspatch/origin_apk.bin")
if (entry == null) throw FileNotFoundException("Original apk entry not found for $apk")
zip.getInputStream(entry).use { input ->
val dst = lspApp.tmpApkDir.resolve(apk.substringAfterLast('/'))
patchPaths.add(dst.absolutePath)
dst.outputStream().use { output ->
input.copyTo(output)
}
updateLoaderState = ProcessingState.Processing
val result = runCatching {
withContext(Dispatchers.IO) {
LSPPackageManager.cleanTmpApkDir()
val apkPaths = listOf(appInfo.app.sourceDir) + (appInfo.app.splitSourceDirs ?: emptyArray())
val patchPaths = mutableListOf<String>()
val embeddedModulePaths = if (config.useManager) emptyList<String>() else null
for (apk in apkPaths) {
ZipFile(apk).use { zip ->
var entry = zip.getEntry(Constants.ORIGINAL_APK_ASSET_PATH)
if (entry == null) entry = zip.getEntry("assets/lspatch/origin_apk.bin")
if (entry == null) throw FileNotFoundException("Original apk entry not found for $apk")
zip.getInputStream(entry).use { input ->
val dst = lspApp.tmpApkDir.resolve(apk.substringAfterLast('/'))
patchPaths.add(dst.absolutePath)
dst.outputStream().use { output ->
input.copyTo(output)
}
}
}
Patcher.patch(logger, Patcher.Options(config, patchPaths, embeddedModulePaths))
val (status, message) = LSPPackageManager.install()
if (status != PackageInstaller.STATUS_SUCCESS) throw RuntimeException(message)
}
Patcher.patch(logger, Patcher.Options(config, patchPaths, embeddedModulePaths))
val (status, message) = LSPPackageManager.install()
if (status != PackageInstaller.STATUS_SUCCESS) throw RuntimeException(message)
}
processingUpdate = false
updateLoaderResult = result
}
updateLoaderState = ProcessingState.Done(result)
}
private suspend fun performOptimize(appInfo: AppInfo) {
Log.i(TAG, "Perform optimize for ${appInfo.app.packageName}")
optimizeState = ProcessingState.Processing
val result = withContext(Dispatchers.IO) {
ShizukuApi.performDexOptMode(appInfo.app.packageName)
}
optimizeState = ProcessingState.Done(result)
}
}

View File

@ -0,0 +1,7 @@
package org.lsposed.lspatch.ui.viewstate
sealed class ProcessingState<out T> {
object Idle : ProcessingState<Nothing>()
object Processing : ProcessingState<Nothing>()
data class Done<T>(val result: T) : ProcessingState<T>()
}