FeaturesRootSection.kt (29935B) - raw
1 package me.rhunk.snapenhance.ui.manager.pages.features 2 3 import android.content.Intent 4 import android.net.Uri 5 import androidx.compose.animation.AnimatedContentTransitionScope 6 import androidx.compose.animation.core.tween 7 import androidx.compose.foundation.background 8 import androidx.compose.foundation.clickable 9 import androidx.compose.foundation.layout.* 10 import androidx.compose.foundation.lazy.LazyColumn 11 import androidx.compose.foundation.lazy.items 12 import androidx.compose.foundation.shape.RoundedCornerShape 13 import androidx.compose.foundation.text.KeyboardActions 14 import androidx.compose.material.icons.Icons 15 import androidx.compose.material.icons.automirrored.filled.OpenInNew 16 import androidx.compose.material.icons.filled.* 17 import androidx.compose.material3.* 18 import androidx.compose.runtime.* 19 import androidx.compose.ui.Alignment 20 import androidx.compose.ui.Modifier 21 import androidx.compose.ui.focus.FocusRequester 22 import androidx.compose.ui.focus.focusRequester 23 import androidx.compose.ui.graphics.Color 24 import androidx.compose.ui.graphics.graphicsLayer 25 import androidx.compose.ui.text.font.FontWeight 26 import androidx.compose.ui.text.style.TextOverflow 27 import androidx.compose.ui.unit.dp 28 import androidx.compose.ui.unit.sp 29 import androidx.lifecycle.Lifecycle 30 import androidx.navigation.NavBackStackEntry 31 import androidx.navigation.NavGraph.Companion.findStartDestination 32 import androidx.navigation.NavGraphBuilder 33 import androidx.navigation.NavOptions 34 import androidx.navigation.compose.composable 35 import kotlinx.coroutines.Dispatchers 36 import kotlinx.coroutines.Job 37 import kotlinx.coroutines.delay 38 import kotlinx.coroutines.launch 39 import me.rhunk.snapenhance.common.config.* 40 import me.rhunk.snapenhance.common.ui.TopBarActionButton 41 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList 42 import me.rhunk.snapenhance.common.ui.transparentTextFieldColors 43 import me.rhunk.snapenhance.ui.manager.MainActivity 44 import me.rhunk.snapenhance.ui.manager.Routes 45 import me.rhunk.snapenhance.ui.util.* 46 47 class FeaturesRootSection : Routes.Route() { 48 private val alertDialogs by lazy { AlertDialogs(context.translation) } 49 50 companion object { 51 const val FEATURE_CONTAINER_ROUTE = "feature_container/{name}" 52 const val SEARCH_FEATURE_ROUTE = "search_feature/{keyword}" 53 } 54 55 private var activityLauncherHelper: ActivityLauncherHelper? = null 56 57 private val allContainers by lazy { 58 val containers = mutableMapOf<String, PropertyPair<*>>() 59 fun queryContainerRecursive(container: ConfigContainer) { 60 container.properties.forEach { 61 if (it.key.dataType.type == DataProcessors.Type.CONTAINER) { 62 containers[it.key.name] = PropertyPair(it.key, it.value) 63 queryContainerRecursive(it.value.get() as ConfigContainer) 64 } 65 } 66 } 67 queryContainerRecursive(context.config.root) 68 containers 69 } 70 71 private val allProperties by lazy { 72 val properties = mutableMapOf<PropertyKey<*>, PropertyValue<*>>() 73 allContainers.values.forEach { 74 val container = it.value.get() as ConfigContainer 75 container.properties.forEach { property -> 76 properties[property.key] = property.value 77 } 78 } 79 properties 80 } 81 82 private fun navigateToMainRoot() { 83 routes.navController.navigate(routeInfo.id, NavOptions.Builder() 84 .setPopUpTo(routes.navController.graph.findStartDestination().id, false) 85 .setLaunchSingleTop(true) 86 .build() 87 ) 88 } 89 90 override val init: () -> Unit = { 91 activityLauncherHelper = ActivityLauncherHelper(context.activity!!) 92 } 93 94 private fun activityLauncher(block: ActivityLauncherHelper.() -> Unit) { 95 activityLauncherHelper?.let(block) ?: run { 96 //open manager if activity launcher is null 97 val intent = Intent(context.androidContext, MainActivity::class.java) 98 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 99 intent.putExtra("route", routeInfo.id) 100 context.androidContext.startActivity(intent) 101 } 102 } 103 104 override val content: @Composable (NavBackStackEntry) -> Unit = { 105 Container(context.config.root) 106 } 107 108 override val customComposables: NavGraphBuilder.() -> Unit = { 109 routeInfo.childIds.addAll(listOf(FEATURE_CONTAINER_ROUTE, SEARCH_FEATURE_ROUTE)) 110 111 composable(FEATURE_CONTAINER_ROUTE, enterTransition = { 112 slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(100)) 113 }, exitTransition = { 114 slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) 115 }) { backStackEntry -> 116 backStackEntry.arguments?.getString("name")?.let { containerName -> 117 allContainers[containerName]?.let { 118 Container(it.value.get() as ConfigContainer) 119 } 120 } 121 } 122 123 composable(SEARCH_FEATURE_ROUTE) { backStackEntry -> 124 backStackEntry.arguments?.getString("keyword")?.let { keyword -> 125 val properties = allProperties.filter { 126 it.key.name.contains(keyword, ignoreCase = true) || 127 context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) || 128 context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true) 129 }.map { PropertyPair(it.key, it.value) } 130 131 PropertiesView(properties) 132 } 133 } 134 } 135 136 @Composable 137 private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) { 138 var showDialog by remember { mutableStateOf(false) } 139 var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) } 140 141 fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true } 142 143 if (showDialog) { 144 Dialog( 145 properties = DialogProperties( 146 usePlatformDefaultWidth = false 147 ), 148 onDismissRequest = { showDialog = false }, 149 ) { 150 dialogComposable() 151 } 152 } 153 154 val propertyValue = property.value 155 156 if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) { 157 registerDialogOnClickCallback() 158 dialogComposable = { 159 var isEmpty by remember { mutableStateOf(false) } 160 val files = rememberAsyncMutableStateList(defaultValue = listOf()) { 161 context.fileHandleManager.getStoredFiles { 162 property.key.params.filenameFilter?.invoke(it.name) == true 163 }.also { 164 isEmpty = it.isEmpty() 165 if (isEmpty) { 166 propertyValue.setAny(null) 167 } 168 } 169 } 170 var selectedFile by remember(files.size) { mutableStateOf(files.firstOrNull { it.name == propertyValue.getNullable() }.also { 171 if (files.isNotEmpty() && it == null) propertyValue.setAny(null) 172 }?.name) } 173 174 Card( 175 shape = MaterialTheme.shapes.large, 176 modifier = Modifier 177 .fillMaxWidth(), 178 ) { 179 LazyColumn( 180 modifier = Modifier 181 .fillMaxWidth() 182 .padding(4.dp), 183 ) { 184 item { 185 Column( 186 modifier = Modifier 187 .fillMaxWidth() 188 .padding(16.dp), 189 horizontalAlignment = Alignment.CenterHorizontally 190 ) { 191 Text( 192 text = context.translation["manager.dialogs.file_imports.settings_select_file_hint"], 193 fontSize = 18.sp, 194 fontWeight = FontWeight.Bold, 195 ) 196 if (isEmpty) { 197 Text( 198 text = context.translation["manager.dialogs.file_imports.no_files_settings_hint"], 199 fontSize = 16.sp, 200 modifier = Modifier.padding(top = 10.dp), 201 ) 202 } 203 } 204 } 205 items(files, key = { it.name }) { file -> 206 Row( 207 modifier = Modifier 208 .clickable { 209 selectedFile = 210 if (selectedFile == file.name) null else file.name 211 propertyValue.setAny(selectedFile) 212 } 213 .padding(5.dp), 214 verticalAlignment = Alignment.CenterVertically 215 ) { 216 Icon(Icons.Filled.AttachFile, contentDescription = null, modifier = Modifier.padding(5.dp)) 217 Text( 218 text = file.name, 219 modifier = Modifier 220 .padding(3.dp) 221 .weight(1f), 222 fontSize = 14.sp, 223 lineHeight = 16.sp 224 ) 225 if (selectedFile == file.name) { 226 Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.padding(5.dp)) 227 } 228 } 229 } 230 } 231 } 232 } 233 234 Icon(Icons.Filled.AttachFile, contentDescription = null) 235 return 236 } 237 238 if (property.key.params.flags.contains(ConfigFlag.FOLDER)) { 239 IconButton(onClick = registerClickCallback { 240 activityLauncher { 241 chooseFolder { uri -> 242 propertyValue.setAny(uri) 243 } 244 } 245 }.let { { it.invoke(true) } }) { 246 Icon(Icons.Filled.FolderOpen, contentDescription = null) 247 } 248 return 249 } 250 251 when (val dataType = remember { property.key.dataType.type }) { 252 DataProcessors.Type.BOOLEAN -> { 253 var state by remember { mutableStateOf(propertyValue.get() as Boolean) } 254 Switch( 255 checked = state, 256 onCheckedChange = registerClickCallback { 257 state = state.not() 258 propertyValue.setAny(state) 259 } 260 ) 261 } 262 263 DataProcessors.Type.MAP_COORDINATES -> { 264 registerDialogOnClickCallback() 265 dialogComposable = { 266 alertDialogs.ChooseLocationDialog(property) { 267 showDialog = false 268 } 269 } 270 271 Text( 272 overflow = TextOverflow.Ellipsis, 273 maxLines = 1, 274 modifier = Modifier.widthIn(0.dp, 120.dp), 275 text = (propertyValue.get() as Pair<*, *>).let { 276 "${it.first.toString().toFloatOrNull() ?: 0F}, ${it.second.toString().toFloatOrNull() ?: 0F}" 277 } 278 ) 279 } 280 281 DataProcessors.Type.STRING_UNIQUE_SELECTION -> { 282 registerDialogOnClickCallback() 283 284 dialogComposable = { 285 alertDialogs.UniqueSelectionDialog(property) 286 } 287 288 Text( 289 overflow = TextOverflow.Ellipsis, 290 maxLines = 1, 291 modifier = Modifier.widthIn(0.dp, 120.dp), 292 text = (propertyValue.getNullable() as? String ?: "null").let { 293 property.key.propertyOption(context.translation, it) 294 } 295 ) 296 } 297 298 DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { 299 dialogComposable = { 300 when (dataType) { 301 DataProcessors.Type.STRING_MULTIPLE_SELECTION -> { 302 alertDialogs.MultipleSelectionDialog(property) 303 } 304 DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { 305 alertDialogs.KeyboardInputDialog(property) { showDialog = false } 306 } 307 else -> {} 308 } 309 } 310 311 registerDialogOnClickCallback().let { { it.invoke(true) } }.also { 312 if (dataType == DataProcessors.Type.INTEGER || 313 dataType == DataProcessors.Type.FLOAT) { 314 FilledIconButton(onClick = it) { 315 Text( 316 text = propertyValue.get().toString(), 317 modifier = Modifier.wrapContentWidth(), 318 overflow = TextOverflow.Ellipsis 319 ) 320 } 321 } else { 322 IconButton(onClick = it) { 323 Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) 324 } 325 } 326 } 327 } 328 329 DataProcessors.Type.INT_COLOR -> { 330 dialogComposable = { 331 alertDialogs.ColorPickerPropertyDialog(property) { 332 showDialog = false 333 } 334 } 335 336 registerDialogOnClickCallback().let { { it.invoke(true) } }.also { 337 CircularAlphaTile(selectedColor = (propertyValue.getNullable() as? Int)?.let { Color(it) }) 338 } 339 } 340 341 DataProcessors.Type.CONTAINER -> { 342 val container = propertyValue.get() as ConfigContainer 343 344 registerClickCallback { 345 routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name)) 346 } 347 348 if (!container.hasGlobalState) return 349 350 var state by remember { mutableStateOf(container.globalState ?: false) } 351 352 Box( 353 modifier = Modifier 354 .padding(end = 15.dp), 355 ) { 356 357 Box(modifier = Modifier 358 .height(50.dp) 359 .width(1.dp) 360 .background( 361 color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), 362 shape = RoundedCornerShape(5.dp) 363 )) 364 } 365 366 Switch( 367 checked = state, 368 onCheckedChange = { 369 state = state.not() 370 container.globalState = state 371 } 372 ) 373 } 374 } 375 376 } 377 378 @Composable 379 private fun PropertyCard(property: PropertyPair<*>) { 380 var clickCallback by remember { mutableStateOf<ClickCallback?>(null) } 381 val noticeColorMap = mapOf( 382 FeatureNotice.UNSTABLE.key to Color(0xFFFFFB87), 383 FeatureNotice.BAN_RISK.key to Color(0xFFFF8585), 384 FeatureNotice.INTERNAL_BEHAVIOR.key to Color(0xFFFFFB87), 385 ) 386 387 val versionCheck = remember { property.key.params.versionCheck } 388 val versionCheckPair = remember(property) { versionCheck?.checkVersion(context.installationSummary.snapchatInfo?.versionCode ?: return@remember null)} 389 val isComponentDisabled = remember { versionCheckPair != null && versionCheck?.isDisabled == true } 390 391 ElevatedCard( 392 modifier = Modifier 393 .fillMaxWidth() 394 .then( 395 if (isComponentDisabled) Modifier.graphicsLayer(alpha = 0.5f) 396 else Modifier 397 ) 398 .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp) 399 ) { 400 Row( 401 modifier = Modifier 402 .fillMaxSize() 403 .clickable { 404 clickCallback?.invoke(true) 405 } 406 .padding(all = 4.dp), 407 horizontalArrangement = Arrangement.SpaceBetween 408 ) { 409 property.key.params.icon?.let { icon -> 410 Icon( 411 imageVector = icon, 412 contentDescription = null, 413 modifier = Modifier 414 .align(Alignment.CenterVertically) 415 .padding(start = 10.dp) 416 ) 417 } 418 419 Column( 420 modifier = Modifier 421 .align(Alignment.CenterVertically) 422 .weight(1f, fill = true) 423 .padding(all = 10.dp) 424 ) { 425 Text( 426 text = context.translation[property.key.propertyName()], 427 fontSize = 16.sp, 428 lineHeight = 16.sp, 429 fontWeight = FontWeight.Bold 430 ) 431 Text( 432 text = context.translation[property.key.propertyDescription()], 433 fontSize = 12.sp, 434 lineHeight = 15.sp 435 ) 436 property.key.params.notices.also { 437 if (it.isNotEmpty()) Spacer(modifier = Modifier.height(5.dp)) 438 }.forEach { 439 Text( 440 text = context.translation["features.notices.${it.key}"], 441 color = noticeColorMap[it.key] ?: Color(0xFFFFFB87), 442 fontSize = 12.sp, 443 lineHeight = 15.sp 444 ) 445 } 446 447 if (versionCheckPair != null) { 448 Spacer(modifier = Modifier.height(2.dp)) 449 Text( 450 text = context.translation.format( 451 "manager.sections.features.${versionCheckPair.second.key}", 452 "version" to versionCheckPair.first.first 453 ), 454 color = Color(0xFFFF8585), 455 fontSize = 12.sp, 456 lineHeight = 15.sp 457 ) 458 } 459 } 460 461 Row( 462 modifier = Modifier 463 .align(Alignment.CenterVertically) 464 .padding(all = 10.dp), 465 verticalAlignment = Alignment.CenterVertically 466 ) { 467 PropertyAction(property, registerClickCallback = { callback -> 468 if (property.key.propertyTranslationPath().startsWith("rules.properties")) { 469 clickCallback = { 470 routes.manageRuleFeature.navigate { 471 put("rule_type", property.key.name) 472 } 473 } 474 return@PropertyAction clickCallback!! 475 } 476 clickCallback = callback 477 callback 478 }) 479 } 480 } 481 } 482 } 483 484 @Composable 485 private fun FeatureSearchBar(rowScope: RowScope, focusRequester: FocusRequester) { 486 var searchValue by remember { mutableStateOf("") } 487 val scope = rememberCoroutineScope() 488 var currentSearchJob by remember { mutableStateOf<Job?>(null) } 489 490 rowScope.apply { 491 TextField( 492 value = searchValue, 493 onValueChange = { keyword -> 494 searchValue = keyword 495 if (keyword.isEmpty()) { 496 navigateToMainRoot() 497 return@TextField 498 } 499 currentSearchJob?.cancel() 500 scope.launch { 501 delay(300) 502 routes.navController.navigate(SEARCH_FEATURE_ROUTE.replace("{keyword}", keyword), NavOptions.Builder() 503 .setLaunchSingleTop(true) 504 .setPopUpTo(routeInfo.id, false) 505 .build() 506 ) 507 }.also { currentSearchJob = it } 508 }, 509 510 keyboardActions = KeyboardActions(onDone = { 511 focusRequester.freeFocus() 512 }), 513 modifier = Modifier 514 .focusRequester(focusRequester) 515 .weight(1f, fill = true) 516 .padding(end = 10.dp) 517 .height(70.dp), 518 singleLine = true, 519 colors = transparentTextFieldColors() 520 ) 521 } 522 } 523 524 override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{ 525 var showSearchBar by remember { mutableStateOf(false) } 526 val focusRequester = remember { FocusRequester() } 527 528 if (showSearchBar) { 529 FeatureSearchBar(this, focusRequester) 530 LaunchedEffect(true) { 531 focusRequester.requestFocus() 532 } 533 } 534 535 536 if (showSearchBar) { 537 IconButton(onClick = { 538 showSearchBar = false 539 if (routes.currentDestination == SEARCH_FEATURE_ROUTE) { 540 navigateToMainRoot() 541 } 542 }) { 543 Icon( 544 imageVector = Icons.Filled.Close, 545 contentDescription = null 546 ) 547 } 548 } else { 549 TopBarActionButton( 550 onClick = { 551 showSearchBar = true 552 }, 553 icon = Icons.Filled.Search, 554 text = translation["search_button"] 555 ) 556 } 557 558 if (showSearchBar) return@topBarActions 559 560 var showExportDropdownMenu by remember { mutableStateOf(false) } 561 var showResetConfirmationDialog by remember { mutableStateOf(false) } 562 var showExportDialog by remember { mutableStateOf(false) } 563 564 if (showResetConfirmationDialog) { 565 AlertDialog( 566 title = { Text(text = context.translation["manager.dialogs.reset_config.title"]) }, 567 text = { Text(text = context.translation["manager.dialogs.reset_config.content"]) }, 568 onDismissRequest = { showResetConfirmationDialog = false }, 569 confirmButton = { 570 Button( 571 onClick = { 572 context.config.reset() 573 context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"]) 574 showResetConfirmationDialog = false 575 } 576 ) { 577 Text(text = context.translation["button.positive"]) 578 } 579 }, 580 dismissButton = { 581 Button( 582 onClick = { 583 showResetConfirmationDialog = false 584 } 585 ) { 586 Text(text = context.translation["button.negative"]) 587 } 588 } 589 ) 590 } 591 592 if (showExportDialog) { 593 fun exportConfig( 594 exportSensitiveData: Boolean 595 ) { 596 showExportDialog = false 597 activityLauncher { 598 saveFile("config.json", "application/json") { uri -> 599 runCatching { 600 context.androidContext.contentResolver.openOutputStream(Uri.parse(uri))?.use { 601 context.config.writeConfig() 602 context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it) 603 context.shortToast(translation["config_export_success_toast"]) 604 } 605 }.onFailure { 606 context.longToast(translation.format("config_export_failure_toast", "error" to it.message.toString())) 607 } 608 } 609 } 610 } 611 612 AlertDialog( 613 title = { Text(text = context.translation["manager.dialogs.export_config.title"]) }, 614 text = { Text(text = context.translation["manager.dialogs.export_config.content"]) }, 615 onDismissRequest = { showExportDialog = false }, 616 confirmButton = { 617 Button( 618 onClick = { exportConfig(true) } 619 ) { 620 Text(text = context.translation["button.positive"]) 621 } 622 }, 623 dismissButton = { 624 Button( 625 onClick = { exportConfig(false) } 626 ) { 627 Text(text = context.translation["button.negative"]) 628 } 629 } 630 ) 631 } 632 633 val actions = remember { 634 mapOf( 635 translation["export_option"] to { showExportDialog = true }, 636 translation["import_option"] to { 637 activityLauncher { 638 openFile("application/json") { uri -> 639 context.androidContext.contentResolver.openInputStream(Uri.parse(uri))?.use { 640 runCatching { 641 context.config.loadFromString(it.readBytes().toString(Charsets.UTF_8)) 642 }.onFailure { 643 context.longToast(translation.format("config_import_failure_toast", "error" to it.message.toString())) 644 return@use 645 } 646 context.shortToast(translation["config_import_success_toast"]) 647 context.coroutineScope.launch(Dispatchers.Main) { 648 navigateReload() 649 } 650 } 651 } 652 } 653 }, 654 translation["reset_option"] to { showResetConfirmationDialog = true } 655 ) 656 } 657 658 if (context.activity != null) { 659 IconButton(onClick = { showExportDropdownMenu = !showExportDropdownMenu}) { 660 Icon( 661 imageVector = Icons.Filled.MoreVert, 662 contentDescription = null 663 ) 664 } 665 } 666 667 if (showExportDropdownMenu) { 668 DropdownMenu(expanded = true, onDismissRequest = { showExportDropdownMenu = false }) { 669 actions.forEach { (name, action) -> 670 DropdownMenuItem( 671 text = { 672 Text(text = name) 673 }, 674 onClick = { 675 action() 676 showExportDropdownMenu = false 677 } 678 ) 679 } 680 } 681 } 682 } 683 684 @Composable 685 private fun PropertiesView( 686 properties: List<PropertyPair<*>> 687 ) { 688 Scaffold( 689 modifier = Modifier.fillMaxSize(), 690 content = { innerPadding -> 691 LazyColumn( 692 modifier = Modifier 693 .fillMaxHeight() 694 .padding(innerPadding), 695 //save button space 696 contentPadding = PaddingValues(top = 10.dp, bottom = 110.dp), 697 verticalArrangement = Arrangement.Top 698 ) { 699 items(properties, key = { it.key.propertyName() }) { 700 PropertyCard(it) 701 } 702 } 703 } 704 ) 705 } 706 707 override val floatingActionButton: @Composable () -> Unit = { 708 fun saveConfig() { 709 context.coroutineScope.launch(Dispatchers.IO) { 710 context.config.writeConfig() 711 context.log.verbose("saved config!") 712 } 713 } 714 715 OnLifecycleEvent { _, event -> 716 if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { 717 saveConfig() 718 } 719 } 720 721 DisposableEffect(Unit) { 722 onDispose { 723 saveConfig() 724 } 725 } 726 } 727 728 729 @Composable 730 private fun Container( 731 configContainer: ConfigContainer 732 ) { 733 PropertiesView(remember { 734 configContainer.properties.map { PropertyPair(it.key, it.value) } 735 }) 736 } 737 }