Compare commits

...

224 Commits
v30.2 ... miuix

Author SHA1 Message Date
LoveSy
ebfadcc457 Fix module install showing script help due to unescaped single quotes
The command passed to busybox `script -c '...'` contained embedded
single quotes (from echo and file path), breaking the outer quoting.
Escape them with the standard POSIX `'\''` technique before wrapping.

Made-with: Cursor
2026-03-09 10:11:21 +08:00
LoveSy
93c6381b1d Add padding and center alignment to log empty state text
Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
2db016a000 Add windowSoftInputMode adjustResize to MainActivity
Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
e0c295afd6 Improve Core/App card alignment and exclude unused native lib
- Use Box with weight(1f) for card content so text gets full width
- Pin Install/Reinstall button to bottom with fixed height for alignment
- Exclude libandroidx.graphics.path.so from APK (Java fallback exists)

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
5fffab52dd Remove bottom action buttons from Log tabs, keep only TopAppBar actions
Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
9f89a0827c Use tristate checkbox for denylist package toggle
Bump miuix to 0.8.6 and migrate Checkbox to the new ToggleableState
API. The package-level checkbox now shows indeterminate state when
only some processes are selected.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
8b852eaeff Replace Termux terminal libraries with custom read-only Kotlin implementation
Fork and heavily simplify terminal-emulator/terminal-view from Termux into
a self-contained Kotlin terminal package. Remove all library-style abstractions
(TerminalOutput, TerminalSessionClient, Logger) and dead code (mouse events,
paste, key input) since the terminal is read-only. The emulator creates a PTY
via busybox script for proper escape sequence support. The UI is a pure Compose
Canvas with scroll support, replacing the old AndroidView-based approach.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
05a3aa9aa3 Fix blank flash screen and crash when flashing module zips
FlashScreen's useTerminal was a plain getter on flashAction, which was
only set in LaunchedEffect (after first composition). Since it wasn't
a Compose State, no recomposition occurred, leaving the screen stuck on
an empty LazyColumn. The unreachable TerminalComposeView meant
onEmulatorReady was never called, hanging the coroutine and eventually
crashing the process.

Pass the action from the route key directly to FlashScreen so it can
pick the correct UI path on the very first composition.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
72f235ddd4 Use MarkdownText for release notes in core install bottom sheet
Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
5d28d461cd Remove watermark icon and background color from CoreCard
Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
5ea6163708 Hide Modules and Superuser tabs when Magisk is not activated
Filter out disabled tabs from the pager and navigation bar instead
of showing them as greyed-out, preventing swipe access to unavailable
screens.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
0e958e77b3 Replace timeout dropdown with discrete slider in SU request dialog
Use miuix Slider with key points and haptic feedback for timeout
selection. Order: Once → 10/20/30/60 mins → Forever. Also reduce
app icon size from 48dp to 40dp.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
34147561d1 Redesign SU request dialog and add SharedUID badge to title row
Move SharedUID badge next to app title in superuser list, detail, and
SU request screens. Consolidate SU request dialog: remove top title,
combine warning text into single string, widen dialog, style buttons
with larger corner radius and height, and adjust spacing.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
aa125c4277 Replace SU log action icon with themed badge
Use "Approved"/"Rejected" text badges with primary/error theme colors
instead of the canvas-drawn tick/cross icons in the superuser log.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
9cda6a7789 Display SharedUID as badge in superuser screens
Replace the "[SharedUID]" text prefix on app names with a styled badge
shown next to the package name on both the superuser list and detail pages.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
86690d5c4c Simplify denylist isolated process handling with wildcard matching
Use a single prefix entry (e.g. "com.example:") to match all isolated
processes of a package instead of listing each service individually.
This avoids needing to reselect isolated processes after app updates.
Also simplify the package-level toggle to always select all processes.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
f7ce935004 Redirect stdin to PTY for terminal size queries
Use `<>$ptyPath >&0 2>&0` to connect all three fds (stdin/stdout/stderr)
to the PTY slave, so scripts can query terminal dimensions via stty size.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
53fe509f5d Remove redundant switch descriptions from superuser detail screen
The "Enabled"/"Disabled" summary text under each switch is redundant
since the toggle state is already visually conveyed by the switch itself.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
fe1840600c Add terminal view for module flash/action screens with TTY support
Replace plain text LazyColumn with com.termux:terminal-view for module
install (FlashZip) and module action screens, enabling real TTY control
(ANSI escape sequences, cursor movement, colors) for third-party module
scripts. Uses libsu's initialized shell environment with stdout/stderr
redirected to a PTY slave, preserving the full Magisk environment while
giving scripts a real terminal.

MagiskInstaller operations (Direct/Patch/Uninstall/SecondSlot) retain
the original LazyColumn plain-text display since they use our own
scripts that don't need TTY features.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
2233203ec4 Bump Kotlin to 2.3.10 to fix release build
Fixes produceReleaseComposeMapping failing with "Unsupported class file
major version 69" (KT-83266). The compose-group-mapping library in
Kotlin 2.3.0 used an ASM version that couldn't handle Java 25 class
files from dependencies.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
d99468d373 Redesign Home screen and improve SU request dialog
- Replace stacked Magisk/Manager cards with side-by-side Core/App cards
  featuring status-based colors, watermark icons, and update badges
- Add Status card showing Ramdisk, Zygisk, and DenyList states
- Restructure Support Us and Follow Us sections with SuperArrow and
  bottom sheets for donate links and developer social links
- Redesign NoticeCard with subtle tertiary container styling
- Move Hide/Restore app action from Settings to Home App card
- Use primary color for SU request Grant button
- Fix DevelopersCard crash caused by null selectedDev during sheet dismiss

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
443184676b Fix SU log and notification missing for first-time/denied requests
The native daemon skips app_log/app_notify callbacks when the stored
RootSettings defaults log=false and notify=false (first-time requests).
Handle logging and notification directly from SuRequestHandler.respond()
on the Java side so both grants and denials are always recorded.

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
ae6093a8c2 Add SU live-reload, status bar notifications, Monet theming, and UI improvements
- Add SuEvents shared flow for live-reloading Superuser and Log tabs
  when root decisions are made
- Add status bar notification option for SU grant/deny with auto-dismiss
- Add Monet/Material You theme support with instant theme switching
- Replace install page navigation with bottom sheet from Home screen
- Replace deprecated ProgressDialog with MiUIX loading dialog for
  app hide/restore
- Move save log and reboot to top bar action buttons in flash/action screens
- Fix dialog button layout to use evenly distributed buttons with
  primary color on positive actions

Made-with: Cursor
2026-03-08 00:53:30 +08:00
LoveSy
9347077214 Remove legacy UI infrastructure and migrate dialogs to Compose
Replace the imperative MagiskDialog/DialogBuilder system with
Compose-idiomatic dialogs using miuix SuperDialog. Remove ViewEvent,
UIActivity, ViewModelHolder, and all old dialog classes. Migrate
ViewModels to expose dialog state via StateFlow instead of holding
Activity references. Remove LiveData from FlashViewModel and
ActionViewModel. Delete orphaned resources (XML menus, drawables,
styles). Provide LocalContentColor in MagiskTheme to fix dark theme
text visibility across all miuix components.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
c672da6fe0 Add Compose reboot menu to Home and fix navigation icon padding
Replace the View-based RebootMenu with a native Compose
SuperListPopup in the Home top bar, with proper state tracking
for the safe mode toggle. Remove the now-unused RebootMenu.kt.

Also fix missing start padding on the SuperuserDetail back button
to match all other sub-screens.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
241db98068 Add MiuixPopupHost to SuRequestActivity for dropdown rendering
SuperDropdown requires MiuixPopupHost in the Compose tree to render
its popup list. Without it the timeout selector was hidden behind the
dialog card or invisible outside the app.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
f4afb0cbb2 Fix revoke dialog showing after navigating back from Superuser detail
The revoke click handler called onBack() immediately after triggering
the dialog event, causing the dialog to appear on the wrong screen.
Defer navigation to the onDeleted callback so the dialog stays on the
detail screen and back only happens after confirmation.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
32fa843a93 Redesign Modules tab and polish various screens
- Replace "Install from storage" button with a FAB matching KernelSU
- Redesign module cards with expandable description, divider, and
  icon-based action/update/remove buttons
- Reorder nav bar: Modules, Superuser, Home, Logs, Settings
- Make suRestrict reactive in Superuser detail screen
- Add horizontal padding to log tab row
- Add checkbox left padding in DenyList
- Rename Superuser detail title to "Superuser Setting"
- Simplify revoke row to a clickable card with error-colored text

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
44fa814c50 Remove rikka layoutinflater and insets dependencies
These View-system utilities are no longer needed after the Compose
migration. Compose handles insets natively via WindowInsets APIs.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
aebfdeff16 Add Superuser detail screen with policy row component
Redesign the Superuser list with a clickable card + vertical divider +
switch layout. Tapping the left area navigates to a new detail screen
showing app info, notification/logging switches, restricted root
capabilities toggle (when enabled globally), and a revoke action.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
7f1abe8c2c Redesign SU log cards and add save/clear actions to Log tab
- Show app icon, grant/deny canvas icon (bottom-right), timestamp,
  UID/PID info, and always-visible details in SU log cards
- Un-bold Magisk log tag text
- Add save (download) and clear (delete) action buttons to Log TopAppBar

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
59fc23607d Add sort/filter popup menus to DenyList screen
Replace inline filter chips with LSPosed-style TopAppBar action icons:
sort (by name, package name, install time, update time with reverse
toggle) and filter (show system/OS apps with linked state). Search bar
is always visible. Process rows now use Checkbox instead of Switch.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
99396e36eb Add padding between log tab row and list content
Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
4326a8c8ef Fix dialogs being overlapped by floating navigation bar
Move MiuixPopupHost to a top-level Box in MainActivity so dialogs
render above all content including the floating nav bar. Disable
individual popup hosts in all 9 screen Scaffolds (popupHost = {})
to prevent duplicate rendering at a lower z-level.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
b9d60d3de1 Rewrite Magisk log tab with itemized parsed entries
Parse logcat-format lines into structured MagiskLogEntry objects with
timestamp, level, tag, PID/TID, and message. Display each entry as a
card with a colored log level badge, tag, timestamp, and expandable
message text instead of dumping raw text.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
83b5f8757b Add collapsing TopAppBar on scroll and fix initial data loading
Connect nestedScroll to MiuixScrollBehavior in all screens so the
TopAppBar collapses when scrolling down, matching KernelSU/LSPosed.
Also call startLoading() via LaunchedEffect for AsyncLoadViewModels
so screens that depend on async data (Superuser, Modules, Log,
DenyList, Home) actually trigger their initial load.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
2b8eb54dd4 Implement floating pill-shaped navigation bar
Replace the standard miuix NavigationBar with a custom floating
pill-shaped bar built from scratch. The miuix NavigationBar draws its
own background internally which defeats external clipping, so the
floating bar uses a Row with custom FloatingNavItem composables instead.
The bar has RoundedCornerShape(28dp), shadow elevation, 24dp horizontal
margin, and sits above the system gesture bar. Add bottom content
padding to all 5 main tab screens to account for the overlay.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
69d8024ee7 Migrate MagiskDialog to Compose and remove kapt/data binding
Replace MagiskDialog from AppCompatDialog with data binding to a
Compose state holder rendered via miuix SuperDialog. Update
MarkDownDialog to use AndroidView-based Compose content. Remove kapt
plugin, data binding, ObservableHost, RvItemAdapter, DataBindingAdapters,
and all remaining XML layouts (dialog_magisk_base, item_list_single_line,
markdown_window_md2). Clean up dead version catalog entries and remove
UIActivity generic type parameter.

Made-with: Cursor
2026-03-04 16:34:16 +08:00
LoveSy
7c6c9ad591 Remove dead View-system code after Compose migration
Remove SafeArgs plugin, Fragment navigation dependencies, unused
View libraries (IndeterminateCheckBox, rikka.recyclerview,
SwipeRefreshLayout, Fragment-ktx). Delete dead classes:
MergeObservableList, DiffObservableList, ObservableRvItem, TextItem.
Delete unused resources: fragment animations, item_spinner.xml,
item_text.xml, empty navigation directory. Clean up dead binding
adapters and UIActivity.startAnimations().

Made-with: Cursor
2026-03-04 16:34:15 +08:00
LoveSy
4a6f2e8fb0 Migrate SuRequestActivity to Compose and clean up dead View code
Rewrite SuRequestActivity and SuRequestViewModel to use Compose state
and miuix components, preserving tapjack protection and security logic.
Extract rememberDrawablePainter to shared utility. Remove dead code:
BaseFragment, NavigationActivity, ShowUIEvent, FragmentExecutor, and
UIActivity.setContentView(). Fix bottom nav icons to use outlined
vector drawables instead of animated selectors.

Made-with: Cursor
2026-03-04 16:34:15 +08:00
LoveSy
79f378dadc Replace Fragment navigation with Navigation3 and Compose-based UI
- Add Navigation3, serialization, miuix-icons dependencies
- Create Routes.kt, Navigator.kt for type-safe navigation
- Add per-screen TopAppBar/SmallTopAppBar with miuix Scaffold
- Rewrite MainActivity: UIActivity + setContent + NavDisplay + HorizontalPager
- Create MainScreen with bottom NavigationBar and tab paging
- Delete all 9 Fragment files and XML navigation/layout/menu resources
- Remove NavigationEvent, replace with SharedFlow<Route> in BaseViewModel
- Update ViewModels to use navigateTo(Route) instead of NavDirections
- Extract FlashUtils.installIntent() for PendingIntent creation
- Add ObserveViewEvents/CollectNavEvents Compose helpers for event dispatch

Made-with: Cursor
2026-03-04 16:34:10 +08:00
LoveSy
1c67786f6c Remove Theme screen and related dead code
Theme selection is no longer needed with Compose theming. Remove
ThemeFragment, ThemeViewModel, DarkThemeDialog, TappableHeadlineItem,
and associated XML layouts. Remove theme navigation from Settings.

Made-with: Cursor
2026-03-03 12:57:47 +08:00
LoveSy
e1162e7fb7 Migrate Install screen to Jetpack Compose with miuix
Replace data binding and @Bindable properties with StateFlow<UiState>.
Implement InstallScreen composable with options card, method selection,
and release notes. Remove fragment_install_md2.xml layout.

Made-with: Cursor
2026-03-03 12:55:40 +08:00
LoveSy
9aa068e645 Migrate Action screen to Jetpack Compose with miuix
Replace Fragment data-binding UI with a Compose-based ActionScreen
sharing the same console output pattern as FlashScreen. ActionViewModel
now uses mutableStateListOf for console lines and StateFlow for state.
Remove old ConsoleItem (no longer used by either Flash or Action),
fragment_action_md2.xml, item_console_md2.xml, and stale nav reference.

Made-with: Cursor
2026-03-03 12:51:31 +08:00
LoveSy
d949a086f8 Migrate Flash screen to Jetpack Compose with miuix
Replace Fragment data-binding UI with a Compose-based FlashScreen
using LazyColumn for monospace console output with auto-scroll,
a save log TextButton, and miuix FloatingActionButton for reboot.
FlashViewModel now uses mutableStateListOf for console lines and
StateFlow for flash state. Back press blocking uses
OnBackPressedCallback instead of BaseFragment override.

Made-with: Cursor
2026-03-03 12:49:25 +08:00
LoveSy
eba4b0bc20 Migrate Modules screen to Jetpack Compose with miuix
Replace Fragment data-binding UI with a Compose-based ModuleScreen
using miuix Card, Switch, and TextButton components. ModuleViewModel
now uses StateFlow<UiState> and Compose-friendly ModuleItem with
mutableStateOf for enable/remove/update state. Each module card shows
name, version/author, description, enable toggle, and action/update/
remove buttons. Remove old ModuleRvItem, XML layouts, and stale
navigation reference.

Made-with: Cursor
2026-03-03 12:44:11 +08:00
LoveSy
69f148ae93 Migrate Superuser screen to Jetpack Compose with miuix
Replace Fragment data-binding UI with a Compose-based SuperuserScreen
using miuix Card, Switch, Slider, and TextButton components.
SuperuserViewModel now uses StateFlow<UiState> and Compose-friendly
PolicyItem with mutableStateOf for reactive updates.
Remove old PolicyRvItem, XML layouts, and stale navigation reference.

Made-with: Cursor
2026-03-03 12:37:47 +08:00
LoveSy
4ac623224f Migrate Log screen to Jetpack Compose with miuix
Replace the Fragment data-binding UI with a Compose-based LogScreen
using miuix TabRow for switching between SU Log and Magisk Log tabs.
LogViewModel now uses StateFlow instead of @Bindable properties.
Remove old XML layouts, LogRvItem, and SuLogRvItem. Also clean up
stale tools:layout references in navigation graph.

Made-with: Cursor
2026-03-03 12:21:36 +08:00
LoveSy
d100322e45 Migrate DenyList screen to Jetpack Compose with miuix
Replace the DenyList screen's data-binding XML layouts and RecyclerView
system with a Compose UI using LazyColumn, Card, Checkbox, and Switch.

DenyListViewModel now uses StateFlow with combine for reactive filtering
instead of the custom filterList/ObservableHost pattern. App and process
state is tracked via Compose mutableStateOf for efficient recomposition.

Remove DenyListRvItem.kt and all associated XML layouts (fragment_deny_md2,
item_hide_md2, item_hide_process_md2).

Made-with: Cursor
2026-03-03 12:15:10 +08:00
LoveSy
b107bf1727 Move Settings to bottom navigation bar
Add Settings as a fifth tab in the bottom nav bar instead of a toolbar
icon in the Home screen. Add a global nav action for the settings
destination and remove the old Home-to-Settings fragment action.

Made-with: Cursor
2026-03-03 12:00:41 +08:00
LoveSy
5e813a7785 Fix Settings crash when update channel is default (-1)
Config.Value.DEFAULT_CHANNEL is -1, which is out of bounds for the
SuperDropdown items list. Coerce to valid range on initialization.

Made-with: Cursor
2026-03-03 11:55:43 +08:00
LoveSy
618220b079 Migrate Settings screen to Jetpack Compose with miuix
Replace the Settings screen's data-binding XML layouts and RecyclerView
item system with a declarative Compose UI using miuix components:
SuperSwitch for toggles, SuperDropdown for selectors, SuperArrow for
navigation items, and SuperDialog for text input dialogs.

SettingsViewModel now exposes DenyList state via StateFlow and provides
action methods instead of the old Handler/BaseSettingsItem pattern.

Remove BaseSettingsItem.kt, SettingsItems.kt, and all associated XML
layouts (fragment_settings_md2, item_settings, item_settings_section,
dialog_settings_app_name, dialog_settings_download_path,
dialog_settings_update_channel).

Made-with: Cursor
2026-03-03 11:52:03 +08:00
LoveSy
a6255daab5 Fix HomeFragment to observe ViewModel events
Implement ViewModelHolder in HomeFragment so that ViewEvents
(navigation, permissions, snackbar) are properly dispatched.

Made-with: Cursor
2026-03-03 11:51:53 +08:00
LoveSy
a5d995f2fd Migrate Home screen to Jetpack Compose with miuix
Replace the Home screen's data-binding XML layouts with a Compose UI
using miuix components. HomeViewModel now uses StateFlow instead of
@Bindable/ObservableHost for UI state. HomeFragment hosts the new
HomeScreen composable via ComposeView.

Remove old XML layouts (fragment_home_md2, include_home_magisk,
include_home_manager, item_developer, item_icon_link) and the
DeveloperItem RvItem class that are no longer needed.

Made-with: Cursor
2026-03-03 11:30:01 +08:00
LoveSy
55da230190 Add MagiskTheme composable wrapping MiuixTheme
Create MagiskTheme that wraps MiuixTheme with light/dark color scheme
based on the existing Config.darkTheme preference setting.

Made-with: Cursor
2026-03-03 11:29:53 +08:00
LoveSy
ae567f1207 Add Compose and miuix dependencies
Add Jetpack Compose BOM, activity-compose, lifecycle-runtime-compose,
lifecycle-viewmodel-compose, and miuix library dependencies.
Enable the Compose compiler plugin alongside existing data binding.

Made-with: Cursor
2026-03-03 11:29:48 +08:00
topjohnwu
e8a58776f1 Release Magisk v30.7
[skip ci]
2026-02-23 00:02:33 -08:00
topjohnwu
07e92948c8 Add CI tests for Android 17 2026-02-22 23:26:10 -08:00
topjohnwu
74ead75ce8 Update cargo dependencies 2026-02-22 14:39:46 -08:00
topjohnwu
bb082e703c Update app dependencies 2026-02-22 14:22:54 -08:00
Wang Han
ee25db0627 Add jni hooks signature for nubia 2026-02-22 13:20:08 -08:00
topjohnwu
9ee6a6b3d7 Update to ONDK r29.5 2026-02-06 16:32:25 -08:00
Wang Han
e872fafd8b Update getModuleDir() API doc 2026-02-05 13:12:34 -08:00
topjohnwu
dd3798905f Update libsepol to upstream Android 16 QPR2 2026-02-05 12:51:21 -08:00
Roshabor
c07eac118c Update ota.md
Hi there, appreciate you and your work.

Proposing a small change to the A/B device update instructions, based on your changes within the Magisk app which made those instructions outdated/ invalid. 
Specifically, after installing Magisk to the new slot, apparently Magisk then instructs to go back to the system update and hit restart now, instead of the prior instructions which remain on this website of rebooting within the Magisk app. I just did testing, and rebooting within the Magisk app does not result in slot switch, which must be why you noe instruct to go back to system update for the restart now. 

Just thought I'd update the instructions for anyone who might be confused. 

Thx!
2026-02-05 11:13:49 -08:00
topjohnwu
4dd6e3c252 Minor changes for app 2026-02-05 00:56:10 -08:00
Ephemera42
0d39b10889 Optimize preinit finding and add klogdump partition
* Use major number check to filter out device-mapper devices while preserving virtio-blk compatibility.
* Introduce `klogdump` partition support for Smartisan devices as a valid preinit target.
2026-02-04 21:10:25 -08:00
vvb2060
628b4d4715 Allow reacquiring capabilities if not explicitly cleared
- Old behavior: Switching to a non-zero UID was implicitly interpreted as a request to drop capabilities, thereby preventing subsequent reacquisition  via `su`.
- New behavior: Switching to a non-zero UID now requires the `--drop-cap` argument to explicitly prevent the reacquisition of capabilities.
2026-02-04 11:35:24 -08:00
LoveSy
be5246aef5 Reorder positional arguments in Backup and Remove 2026-02-04 11:35:12 -08:00
LoveSy
b55f597ccf Skip loading magisk in charger mode 2026-02-03 15:55:53 -08:00
Wang Han
e729eec636 Remove unnecessary file system permissions
Removed permissions for mounting loop devices, mirrors, and tmpfs.
2026-02-02 11:15:33 -08:00
Wang Han
a657af5dc9 Refine useLocaleManager with intent check for locales
This fixes https://github.com/topjohnwu/Magisk/issues/9628.
2026-01-31 23:34:03 -08:00
Padraic Slattery
34137b112a chore: Update outdated GitHub Actions versions 2026-01-31 15:27:41 -08:00
LoveSy
5f13a8f8f7 support sony's init.real 2026-01-31 13:54:34 -08:00
osm0sis
8495a03947 Fix A-only addon.d retaining PREINITDEVICE on 30300+
- broken after 742913ebcb so use get_flags to be more futureproof
2026-01-31 13:52:44 -08:00
canyie
ac8d4200e1 Add nativeSpecializeAppProcess signature for XR devices running 14
Build fingerprint: google/gms_sdk_xr64_x86_64/emulator64_x86_64:14/UP1A.231005.007.A1/13953962:userdebug/test-keys
2026-01-31 13:51:41 -08:00
Wang Han
e590ffbdcb Fix formatting and update comments in bootimg.cpp 2026-01-31 13:50:36 -08:00
Wang Han
97138f1e91 magiskboot: Fix tail offset calculation 2026-01-31 13:50:36 -08:00
LoveSy
bea50fafba Fix magiskboot cpio ls -r 2026-01-31 13:50:13 -08:00
Pzqqt
0919db6b11 magiskboot: Avoid implicit type conversion
Fix #9607
2026-01-19 23:33:00 +08:00
topjohnwu
18c1347bd3 Update to AGP 9.0 2026-01-19 23:29:02 +08:00
topjohnwu
0bbc736051 Properly set abiList with config.prop for APK 2025-12-16 02:38:27 -08:00
topjohnwu
01cb75eaef Code cleanup 2025-12-13 22:12:30 -08:00
topjohnwu
33eaa7c5eb Update cargo dependencies 2025-12-12 11:42:45 -08:00
topjohnwu
8734423cb0 Update to ONDK r29.4 2025-12-12 11:42:45 -08:00
topjohnwu
da9e72b2a2 Update gradle dependencies 2025-12-12 11:42:45 -08:00
topjohnwu
ff4ca74cfe Ban all unwrap usage in codebase 2025-12-08 23:31:04 -08:00
topjohnwu
200665c48a Make jni_hooks.hpp a normal C++ header
[skip ci]
2025-12-07 02:44:19 -08:00
topjohnwu
dd42aa99ea Refactor gen_jni_hooks.py
[skip ci]
2025-12-07 02:25:50 -08:00
Wang Han
0936cdb192 Update nativeForkAndSpecialize signature for A16 QPR2
67a4b1b2fe
2025-12-07 00:18:50 -08:00
topjohnwu
871643dce2 Enable CI for API 36.1 2025-12-07 00:18:50 -08:00
topjohnwu
a510554b21 Disable Zygisk upon incomplete JNI hook
Do not enable Zygisk unless ALL replacements are hooked properly.
This allow Zygisk tests to fail when new signature is introduced.
2025-12-07 00:18:50 -08:00
Arbri çoçka
9cc830c565 Update strings.xml values_sq 2025-12-05 16:22:03 -08:00
hajs1664
ddbac50645 update korean translation 2025-12-05 11:15:21 -08:00
南宫雪珊
b5138a4af0 Update unpack boot image help message 2025-12-05 11:14:45 -08:00
topjohnwu
64752f38e8 Do not unwrap when getting decoder and encoder
Or else things will crash mysteriously when unexpected input occurs
2025-12-05 03:40:18 -08:00
topjohnwu
9ac4b5ce7d Add proper lzma format detection 2025-12-05 03:40:18 -08:00
topjohnwu
505053f9b4 Properly support AVD with minor SDK version 2025-12-04 20:55:46 -08:00
topjohnwu
ccb264f33a Release Magisk v30.6
[skip ci]
2025-12-01 15:46:33 -08:00
topjohnwu
84f7d75d30 Update release.sh
Strip out all canary build logic
2025-12-01 15:27:01 -08:00
南宫雪珊
9a776c22d9 Revert "Use rootfs for magisktmp if possible" 2025-12-01 11:45:34 -08:00
topjohnwu
363566d0d5 Release Magisk v30.5
[skip ci]
2025-12-01 01:52:46 -08:00
topjohnwu
d9dc459bf4 Update system_properties
Fix #9408
2025-12-01 00:25:39 -08:00
topjohnwu
5d6b703622 Move occupy and unoccupy out of base crate 2025-11-29 00:13:41 -08:00
topjohnwu
f7ce9c38e1 Run through clippy and rustfmt 2025-11-29 00:13:41 -08:00
LoveSy
bdbfb40383 Use rootfs for magisktmp if possible 2025-11-29 00:13:41 -08:00
topjohnwu
283fc0f46f Update cargo dependencies 2025-11-28 21:00:48 -08:00
topjohnwu
2c24a41bf2 Update gradle dependencies 2025-11-27 03:36:49 -08:00
topjohnwu
97c93a1f4d Smaller release binary size 2025-11-27 02:00:54 -08:00
topjohnwu
8d534e6de8 Update cxx-rs 2025-11-25 02:29:45 -08:00
topjohnwu
3a60ef2039 Update to ONDK r29.3 2025-11-21 13:28:46 -08:00
Wang Han
52d7eff03f Fix splice direction for ptmx out stream 2025-11-19 15:14:59 -08:00
topjohnwu
020e23ea13 Disable help triggers on subcommands 2025-11-03 16:16:49 -08:00
topjohnwu
1599bfc2c5 Update dependencies 2025-11-02 13:52:32 -08:00
Wang Han
c8d51b38ba Enhance fdt_header validation for empty dtb 2025-11-02 02:42:48 -08:00
Wang Han
f741a4aeb8 Free regex resources in plt_hook_commit
Free regex resources for registered and ignored hooks before clearing the lists.
2025-11-02 01:59:03 -08:00
topjohnwu
4ee2235961 Update dependencies 2025-10-20 10:30:09 -07:00
topjohnwu
536e50c6e0 Support optional trailing positional arguments 2025-10-19 17:15:30 -07:00
topjohnwu
57d9fc6099 Support short only options and switches 2025-10-19 17:15:30 -07:00
topjohnwu
52d8910bdd Cleanup code for EarlyExit during help triggers 2025-10-19 17:15:30 -07:00
topjohnwu
c94bd49a89 Update default help triggers 2025-10-19 17:15:30 -07:00
topjohnwu
b72ba6759e Vendor argh sources
Further customization will come in future commits
2025-10-19 17:15:30 -07:00
topjohnwu
5bcb55b7fc Format Rust imports with rustfmt 2025-10-19 17:15:30 -07:00
topjohnwu
0dc8231585 Make all dependencies workspace = true 2025-10-19 17:15:30 -07:00
Wang Han
470acc93c9 Remove clickable attribute from item_module_md2.xml 2025-10-19 14:02:02 -07:00
Wang Han
0edb80b10f Set module card as non clickable
It's so easy to mis-click card.
2025-10-19 14:02:02 -07:00
topjohnwu
bcc6296d94 Build debug without debug-info 2025-10-03 00:16:17 -07:00
topjohnwu
c3db2e368d Release Magisk v30.4
[skip ci]
2025-10-02 04:30:47 -07:00
topjohnwu
d37da5ca66 Cleanup code 2025-10-02 04:18:20 -07:00
topjohnwu
aac52176ed Support API level as floating point 2025-10-02 04:10:22 -07:00
topjohnwu
78e2fc37e5 Add easy knobs to disable security checks 2025-10-02 04:09:46 -07:00
Wang Han
ca2e40593f Make fetchUpdate safe 2025-10-02 04:03:44 -07:00
LoveSy
c07fdc87e3 Handle second splice() failure gracefully 2025-10-02 04:03:27 -07:00
topjohnwu
7270f5e413 Several minor fixes/improvements 2025-10-02 04:03:08 -07:00
topjohnwu
07cc85ccb1 Default initialize before swap in move constructor
Fix #9373, fix #9384, fix #9400, fix #9404
2025-10-02 04:03:08 -07:00
topjohnwu
d6f17c42d5 Fix logging implementation error 2025-10-02 04:03:08 -07:00
Wang Han
d60806f429 Only reset NB prop when zygisk is enabled 2025-10-02 03:19:32 -07:00
Mohammad Hasan Keramat J
8836a09c8c core: Update Persian translation 2025-09-30 00:21:44 -07:00
topjohnwu
f16e93c7db Release Magisk v30.3
[skip ci]
2025-09-29 21:35:45 -07:00
Thonsi
1b0ddec66e Remove unused code 2025-09-29 02:33:02 -07:00
topjohnwu
cd8820f563 Refactor code for more readability 2025-09-29 01:41:55 -07:00
topjohnwu
b70192ca3e Sync libsepol with upstream AOSP 2025-09-29 01:18:52 -07:00
LoveSy
d42ec5da9a Fix pattern matching for CANARY version 2025-09-29 01:18:52 -07:00
topjohnwu
742913ebcb Support installing Magisk on vendor_boot
Close #9238, fix #8835
2025-09-28 01:10:11 -07:00
topjohnwu
ed206c6480 Upgrade cargo dependencies 2025-09-26 23:37:45 -07:00
topjohnwu
f9a8052583 Improve build.py
Close #8988
2025-09-26 17:00:58 -07:00
topjohnwu
f4fdd516f9 Upgrade gradle dependencies 2025-09-24 03:18:35 -07:00
topjohnwu
5925a71f94 Upgrade cargo dependencies 2025-09-24 03:05:18 -07:00
topjohnwu
3cda9beb93 Cleanup unused bindings 2025-09-24 02:38:18 -07:00
topjohnwu
8b7d1ffcdd Migrate magisk_main to Rust 2025-09-18 03:22:44 -07:00
topjohnwu
8d02d0632e Fix comments 2025-09-18 03:22:44 -07:00
topjohnwu
dd743f6f7e Improve Encodable/Decodable impls 2025-09-18 01:17:28 -07:00
topjohnwu
cf483ad4d2 Migrate connect_daemon to Rust 2025-09-15 14:25:18 -07:00
topjohnwu
4aed644e08 Directly accept RequestCode for connect_daemon 2025-09-15 14:25:18 -07:00
topjohnwu
0acc39cec0 Use bitflags to implement BootState 2025-09-15 14:25:18 -07:00
topjohnwu
8b3a44344f Move bootstages into its own module 2025-09-15 14:25:18 -07:00
topjohnwu
8b49eda85a Migrate daemon_entry to Rust 2025-09-15 14:25:18 -07:00
topjohnwu
7057d4c7f1 Migrate setup_magisk_env to Rust 2025-09-15 14:25:18 -07:00
Radoš Milićev
aab8344058 Update Serbian 2025-09-14 18:42:22 -07:00
topjohnwu
7cccf83b37 Remove unused poll_ctrl implementation 2025-09-14 01:59:04 -07:00
topjohnwu
f10ad93c4e Move more code of daemon_entry into Rust 2025-09-13 01:21:33 -07:00
topjohnwu
f143b5df15 Do not mount directories as mirror
Mounting real directories into worker will cause init to start tracking
the mount point through dev.mnt. This causes issues, so we are forced
to recursively reconstruct the mirror directory structure from scratch.

Fix #9316
2025-09-12 22:01:08 -07:00
topjohnwu
71213cc6f4 Fix path tracking in module.rs 2025-09-12 22:01:08 -07:00
topjohnwu
e2a1774e5b Make logging.rs use nix 2025-09-11 01:17:34 -07:00
topjohnwu
0222527a1e Use bitflags macro 2025-09-11 01:17:34 -07:00
topjohnwu
312bfe1bab Do not leak base::ffi to external crates 2025-09-11 01:17:34 -07:00
topjohnwu
48c62a1dae Disable exit on error for cmdline_logging 2025-09-11 01:17:34 -07:00
rikka
cfc2bcb665 Fix zygisk native bridge library name concatenation order 2025-09-11 01:16:54 -07:00
topjohnwu
94b1ff674f Allow calling remove_all on non-existence file 2025-09-10 03:44:39 -07:00
topjohnwu
111136733a Migrate away from unsafe set_len of Utf8CStr 2025-09-09 22:19:05 -07:00
topjohnwu
c8caaa98f5 Enable mount for nix 2025-09-09 20:17:09 -07:00
topjohnwu
8d28f10a3f Enable zerocopy for nix 2025-09-09 12:04:46 -07:00
topjohnwu
177a456d8b Enable term for nix 2025-09-09 12:04:31 -07:00
topjohnwu
ef4e230258 Use nix for libc functions 2025-09-08 23:59:29 -07:00
topjohnwu
17082af438 Simplify OsError 2025-09-08 11:25:20 -07:00
topjohnwu
1df5b34175 Stop differentiate Error vs ErrorCxx 2025-09-08 11:25:18 -07:00
topjohnwu
ea5fe7525d Simplify LibcReturn 2025-09-08 10:55:57 -07:00
topjohnwu
a75c335261 Update cargo dependencies 2025-09-08 02:24:01 -07:00
topjohnwu
3903f42cf6 Support specify ABI for clippy 2025-09-08 02:23:49 -07:00
topjohnwu
fb0c4ea838 Fallback to userspace copy if splice failed
Fix #9032
2025-09-03 16:10:18 -07:00
topjohnwu
bc89c60977 Run cargo fmt 2025-09-02 22:06:08 -07:00
topjohnwu
bd657c354c Reduce FFI across C++/Rust 2025-09-02 22:06:08 -07:00
MONA
675b5f9565 feat(i18n): Add Hinglish translation 2025-09-02 01:27:58 -07:00
MONA
1b2c43268e feat(i18n): Add Hinglish translation 2025-09-02 01:27:58 -07:00
topjohnwu
653730d75e Make cxx binding generate less code 2025-08-29 01:44:06 -07:00
topjohnwu
d472e9c36e Update cargo dependencies 2025-08-28 22:01:35 -07:00
topjohnwu
484d53ef7e Update to ONDK r29.2 2025-08-28 16:15:59 -07:00
topjohnwu
c4e2985677 Migrate resetprop to Rust 2025-08-27 22:48:48 -07:00
topjohnwu
42d9f87bc9 Cleanup resetprop code 2025-08-27 22:48:48 -07:00
topjohnwu
2e4fa6864c Make Utf8CStr a first class citizen in C++ codebase
Utf8CStr is in many cases a better string view class than
std::string_view, because it provides "view" access to a string buffer
that is guaranteed to be null terminated. It also has the additional
benefit of being UTF-8 verified and can seemlessly cross FFI boundaries.

We would want to start use more Utf8CStr in our existing C++ codebase.
2025-08-27 22:48:48 -07:00
topjohnwu
e2abb648ac Update system_properties 2025-08-27 10:12:51 -07:00
topjohnwu
3599dcedfb Make argh directly parse into Utf8CString 2025-08-27 01:26:41 -07:00
topjohnwu
ea72666df8 Only specify ADB port for tests 2025-08-25 15:34:04 -07:00
topjohnwu
bd2a47ba18 Merge libbase cpp files 2025-08-25 01:31:47 -07:00
topjohnwu
b861671391 Cleanup libbase 2025-08-25 01:31:47 -07:00
topjohnwu
e91fc75d86 Consolidate for_each implementation into Rust 2025-08-25 01:31:47 -07:00
LoveSy
78f5cd55c7 Use lzma-rust2 for xz and lzma compression and decompression 2025-08-24 00:23:55 -07:00
topjohnwu
9787a69528 Make all decoders Read instead of Write
Most libraries only implement Read for decoders
2025-08-24 00:23:55 -07:00
topjohnwu
87b8fe374d Fix magiskboot cli parsing 2025-08-23 20:31:15 -07:00
topjohnwu
7b706bb0cb Cleanup and fix compress/decompress command 2025-08-23 20:31:15 -07:00
topjohnwu
c1491b8d2b Fix LoggedResult implementation error 2025-08-23 15:25:52 -07:00
LoveSy
5cbaf2ae11 Use super let to simplify code 2025-08-22 12:05:44 -07:00
topjohnwu
8ebc6207b4 Merge headers 2025-08-22 12:03:47 -07:00
topjohnwu
7848ee616b Cleanup magiskboot main function 2025-08-22 12:03:47 -07:00
topjohnwu
fd193c3cae Simplify ResultExt implementation
Also introduce OptionExt
2025-08-22 12:03:47 -07:00
topjohnwu
36d33c7a85 Make log_err directly return LoggedResult 2025-08-22 12:03:47 -07:00
topjohnwu
5caf28d27c Hide harmless error reporting 2025-08-22 12:03:47 -07:00
topjohnwu
2c39d0234d Fix compression format detection 2025-08-21 12:21:22 -07:00
topjohnwu
c313812129 Simplify magiskboot FFI 2025-08-21 12:21:22 -07:00
topjohnwu
af51880a81 Introduce CmdArgs for argument parsing in Rust 2025-08-21 12:21:22 -07:00
LoveSy
db8d832707 Move magiskboot cli to argh 2025-08-20 21:40:34 -07:00
Wang Han
8dc23d0ead Avoid triggering magisk --zygote-restart twice
We have already used on restart keyword to inject zygote restart, so
triggering it here on prop is not needed.
2025-08-20 12:34:39 -07:00
topjohnwu
b4287700d5 Increase timeout to 15 minutes 2025-08-20 11:23:18 -07:00
topjohnwu
8d10ab89f2 Set zygisk properties in Rust 2025-08-20 11:23:18 -07:00
topjohnwu
49fdc1addb Prevent setting zygisk prop twice 2025-08-20 11:23:18 -07:00
topjohnwu
1333d3b986 Fix canary emulator 2025-08-18 11:25:47 -07:00
残页
335146a6a2 Update supported API levels 2025-08-17 23:58:43 -07:00
topjohnwu
eaf9527971 Use AOSP ATD for API 36
[skip ci]
2025-08-15 17:25:41 -07:00
LoveSy
da937a88c8 if !restore { set_zygisk_prop(); } 2025-08-15 16:45:01 -07:00
topjohnwu
9476e7282d More borrowing, less copying 2025-08-08 21:06:41 -07:00
topjohnwu
251c3c3e0e Remove old ffi data structure 2025-08-08 21:06:41 -07:00
topjohnwu
cd0eca20b0 Migrate connect.cpp to Rust 2025-08-08 21:06:41 -07:00
topjohnwu
6839cb9ab2 Keep /system/xbin/su on emulators 2025-08-08 21:06:41 -07:00
topjohnwu
d11a3397d8 Reduce verbose logging in Zygisk 2025-08-08 21:06:41 -07:00
356 changed files with 21746 additions and 17509 deletions

5
.github/kvm.sh vendored Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger

View File

@@ -20,7 +20,7 @@ jobs:
fail-fast: false
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
submodules: "recursive"
@@ -39,14 +39,14 @@ jobs:
run: ./app/gradlew --stop
- name: Upload build artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ${{ github.sha }}
path: out
compression-level: 9
- name: Upload mapping and native debug symbols
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ${{ github.sha }}-symbols
path: app/apk/build/outputs
@@ -61,7 +61,7 @@ jobs:
os: [windows-2025, ubuntu-24.04]
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
submodules: "recursive"
@@ -82,37 +82,34 @@ jobs:
strategy:
fail-fast: false
matrix:
version: [23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, "36.0-CANARY"]
version: [23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 36.1, "CANARY"]
type: [""]
include:
- version: "36.0-CANARY"
- version: "CinnamonBun"
type: "google_apis_ps16k"
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download build artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: ${{ github.sha }}
path: out
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
run: .github/kvm.sh
- name: Run AVD test
timeout-minutes: 10
timeout-minutes: 15
env:
AVD_TEST_LOG: 1
run: scripts/avd.sh test ${{ matrix.version }} ${{ matrix.type }}
- name: Upload logs on error
if: ${{ failure() }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: "avd-logs-${{ matrix.version }}"
path: |
@@ -131,22 +128,19 @@ jobs:
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download build artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: ${{ github.sha }}
path: out
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
run: .github/kvm.sh
- name: Run AVD test
timeout-minutes: 10
timeout-minutes: 15
env:
FORCE_32_BIT: 1
AVD_TEST_LOG: 1
@@ -154,7 +148,7 @@ jobs:
- name: Upload logs on error
if: ${{ failure() }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: "avd32-logs-${{ matrix.version }}"
path: |
@@ -177,26 +171,29 @@ jobs:
steps:
- name: Check out
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download build artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: ${{ github.sha }}
path: out
- name: Enable KVM group perms
run: .github/kvm.sh
- name: Setup Cuttlefish environment
run: |
scripts/cuttlefish.sh setup
scripts/cuttlefish.sh download ${{ matrix.branch }} ${{ matrix.device }}
- name: Run Cuttlefish test
timeout-minutes: 10
timeout-minutes: 15
run: sudo -E -u $USER scripts/cuttlefish.sh test
- name: Upload logs on error
if: ${{ failure() }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: "cvd-logs-${{ matrix.device }}"
path: |

4
.gitignore vendored
View File

@@ -12,3 +12,7 @@ native/out
# Android Studio
*.iml
.idea
.cursor
ramdisk.img
app/core/src/debug
app/core/src/release

3
.gitmodules vendored
View File

@@ -4,9 +4,6 @@
[submodule "lz4"]
path = native/src/external/lz4
url = https://github.com/lz4/lz4.git
[submodule "xz"]
path = native/src/external/xz
url = https://github.com/xz-mirror/xz.git
[submodule "libcxx"]
path = native/src/external/libcxx
url = https://github.com/topjohnwu/libcxx.git

2
app/.gitignore vendored
View File

@@ -3,5 +3,5 @@
# Gradle
.gradle
.kotlin
build
/local.properties
/build

1
app/apk/.gitignore vendored
View File

@@ -1 +0,0 @@
/build

View File

@@ -1,31 +1,31 @@
plugins {
id("com.android.application")
kotlin("android")
kotlin("plugin.parcelize")
kotlin("kapt")
id("androidx.navigation.safeargs.kotlin")
kotlin("plugin.compose")
kotlin("plugin.serialization")
}
setupMainApk()
kapt {
correctErrorTypes = true
useBuildCache = true
mapDiagnosticLocations = true
javacOptions {
option("-Xmaxerrs", "1000")
}
}
android {
buildFeatures {
dataBinding = true
compose = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
}
packaging {
jniLibs {
excludes += "lib/*/libandroidx.graphics.path.so"
}
}
defaultConfig {
proguardFile("proguard-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = true
@@ -38,22 +38,24 @@ dependencies {
implementation(project(":core"))
coreLibraryDesugaring(libs.jdk.libs)
implementation(libs.indeterminate.checkbox)
implementation(libs.rikka.layoutinflater)
implementation(libs.rikka.insets)
implementation(libs.rikka.recyclerview)
implementation(libs.navigation.fragment.ktx)
implementation(libs.navigation.ui.ktx)
implementation(libs.constraintlayout)
implementation(libs.swiperefreshlayout)
implementation(libs.recyclerview)
implementation(libs.transition)
implementation(libs.fragment.ktx)
implementation(libs.appcompat)
implementation(libs.material)
// Make sure kapt runs with a proper kotlin-stdlib
kapt(kotlin("stdlib"))
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.activity.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.miuix)
implementation(libs.miuix.icons)
implementation(libs.miuix.navigation3.ui)
// Navigation3
implementation(libs.navigation3.runtime)
implementation(libs.navigationevent.compose)
implementation(libs.lifecycle.viewmodel.navigation3)
}

3
app/apk/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,3 @@
# Excessive obfuscation
-flattenpackagehierarchy
-allowaccessmodification

View File

@@ -6,6 +6,7 @@
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/SplashTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -12,11 +12,16 @@ abstract class AsyncLoadViewModel : BaseViewModel() {
@MainThread
fun startLoading() {
if (loadingJob?.isActive == true) {
// Prevent multiple jobs from running at the same time
return
}
loadingJob = viewModelScope.launch { doLoadWork() }
}
@MainThread
fun reload() {
loadingJob?.cancel()
loadingJob = viewModelScope.launch { doLoadWork() }
}
protected abstract suspend fun doLoadWork()
}

View File

@@ -1,96 +0,0 @@
package com.topjohnwu.magisk.arch
import android.os.Bundle
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.MenuProvider
import androidx.databinding.DataBindingUtil
import androidx.databinding.OnRebindCallback
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavDirections
import com.topjohnwu.magisk.BR
abstract class BaseFragment<Binding : ViewDataBinding> : Fragment(), ViewModelHolder {
val activity get() = getActivity() as? NavigationActivity<*>
protected lateinit var binding: Binding
protected abstract val layoutRes: Int
private val navigation get() = activity?.navigation
open val snackbarView: View? get() = null
open val snackbarAnchorView: View? get() = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startObserveLiveData()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate<Binding>(inflater, layoutRes, container, false).also {
it.setVariable(BR.viewModel, viewModel)
it.lifecycleOwner = viewLifecycleOwner
}
if (this is MenuProvider) {
activity?.addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.STARTED)
}
savedInstanceState?.let { viewModel.onRestoreState(it) }
return binding.root
}
override fun onSaveInstanceState(outState: Bundle) {
viewModel.onSaveState(outState)
}
override fun onStart() {
super.onStart()
activity?.supportActionBar?.subtitle = null
}
override fun onEventDispatched(event: ViewEvent) = when(event) {
is ContextExecutor -> event(requireContext())
is ActivityExecutor -> activity?.let { event(it) } ?: Unit
is FragmentExecutor -> event(this)
else -> Unit
}
open fun onKeyEvent(event: KeyEvent): Boolean {
return false
}
open fun onBackPressed(): Boolean = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.addOnRebindCallback(object : OnRebindCallback<Binding>() {
override fun onPreBind(binding: Binding): Boolean {
this@BaseFragment.onPreBind(binding)
return true
}
})
}
override fun onResume() {
super.onResume()
viewModel.let {
if (it is AsyncLoadViewModel)
it.startLoading()
}
}
protected open fun onPreBind(binding: Binding) {
(binding.root as? ViewGroup)?.startAnimations()
}
fun NavDirections.navigate() {
navigation?.currentDestination?.getAction(actionId)?.let { navigation!!.navigate(this) }
}
}

View File

@@ -1,83 +1,32 @@
package com.topjohnwu.magisk.arch
import android.Manifest.permission.POST_NOTIFICATIONS
import android.Manifest.permission.REQUEST_INSTALL_PACKAGES
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.databinding.PropertyChangeRegistry
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.navigation.NavDirections
import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.databinding.ObservableHost
import com.topjohnwu.magisk.events.BackPressEvent
import com.topjohnwu.magisk.events.DialogBuilder
import com.topjohnwu.magisk.events.DialogEvent
import com.topjohnwu.magisk.events.NavigationEvent
import com.topjohnwu.magisk.events.PermissionEvent
import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.ui.navigation.Route
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
abstract class BaseViewModel : ViewModel(), ObservableHost {
abstract class BaseViewModel : ViewModel() {
override var callbacks: PropertyChangeRegistry? = null
private val _viewEvents = MutableLiveData<ViewEvent>()
val viewEvents: LiveData<ViewEvent> get() = _viewEvents
private val _navEvents = MutableSharedFlow<Route>(extraBufferCapacity = 1)
val navEvents: SharedFlow<Route> = _navEvents
open fun onSaveState(state: Bundle) {}
open fun onRestoreState(state: Bundle) {}
open fun onNetworkChanged(network: Boolean) {}
fun withPermission(permission: String, callback: (Boolean) -> Unit) {
PermissionEvent(permission, callback).publish()
fun showSnackbar(@StringRes resId: Int) {
AppContext.toast(resId, Toast.LENGTH_SHORT)
}
inline fun withExternalRW(crossinline callback: () -> Unit) {
withPermission(WRITE_EXTERNAL_STORAGE) {
if (!it) {
SnackbarEvent(R.string.external_rw_permission_denied).publish()
} else {
callback()
}
}
fun showSnackbar(msg: String) {
AppContext.toast(msg, Toast.LENGTH_SHORT)
}
@SuppressLint("InlinedApi")
inline fun withInstallPermission(crossinline callback: () -> Unit) {
withPermission(REQUEST_INSTALL_PACKAGES) {
if (!it) {
SnackbarEvent(R.string.install_unknown_denied).publish()
} else {
callback()
}
}
fun navigateTo(route: Route) {
_navEvents.tryEmit(route)
}
@SuppressLint("InlinedApi")
inline fun withPostNotificationPermission(crossinline callback: () -> Unit) {
withPermission(POST_NOTIFICATIONS) {
if (!it) {
SnackbarEvent(R.string.post_notifications_denied).publish()
} else {
callback()
}
}
}
fun back() = BackPressEvent().publish()
fun ViewEvent.publish() {
_viewEvents.postValue(this)
}
fun DialogBuilder.show() {
DialogEvent(this).publish()
}
fun NavDirections.navigate(pop: Boolean = false) {
_viewEvents.postValue(NavigationEvent(this, pop))
}
}

View File

@@ -1,50 +0,0 @@
package com.topjohnwu.magisk.arch
import android.content.ContentResolver
import android.view.KeyEvent
import androidx.databinding.ViewDataBinding
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.navOptions
import com.topjohnwu.magisk.utils.AccessibilityUtils
abstract class NavigationActivity<Binding : ViewDataBinding> : UIActivity<Binding>() {
abstract val navHostId: Int
private val navHostFragment by lazy {
supportFragmentManager.findFragmentById(navHostId) as NavHostFragment
}
protected val currentFragment get() =
navHostFragment.childFragmentManager.fragments.getOrNull(0) as? BaseFragment<*>
val navigation: NavController get() = navHostFragment.navController
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
return if (binded && currentFragment?.onKeyEvent(event) == true) true else super.dispatchKeyEvent(event)
}
override fun onBackPressed() {
if (binded) {
if (currentFragment?.onBackPressed() == false) {
super.onBackPressed()
}
}
}
companion object {
fun navigate(directions: NavDirections, navigation: NavController, cr: ContentResolver) {
if (AccessibilityUtils.isAnimationEnabled(cr)) {
navigation.navigate(directions)
} else {
navigation.navigate(directions, navOptions {})
}
}
}
fun NavDirections.navigate() {
navigate(this, navigation, contentResolver)
}
}

View File

@@ -1,141 +0,0 @@
package com.topjohnwu.magisk.arch
import android.content.Context
import android.content.res.Resources
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.res.use
import androidx.core.view.WindowCompat
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import com.google.android.material.snackbar.Snackbar
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.base.ActivityExtension
import com.topjohnwu.magisk.core.base.IActivityExtension
import com.topjohnwu.magisk.core.isRunningAsStub
import com.topjohnwu.magisk.core.ktx.reflectField
import com.topjohnwu.magisk.core.wrap
import rikka.insets.WindowInsetsHelper
import rikka.layoutinflater.view.LayoutInflaterFactory
abstract class UIActivity<Binding : ViewDataBinding>
: AppCompatActivity(), ViewModelHolder, IActivityExtension {
protected lateinit var binding: Binding
protected abstract val layoutRes: Int
override val extension = ActivityExtension(this)
protected val binded get() = ::binding.isInitialized
open val snackbarView get() = binding.root
open val snackbarAnchorView: View? get() = null
init {
AppCompatDelegate.setDefaultNightMode(Config.darkTheme)
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base.wrap())
}
override fun onCreate(savedInstanceState: Bundle?) {
layoutInflater.factory2 = LayoutInflaterFactory(delegate)
.addOnViewCreatedListener(WindowInsetsHelper.LISTENER)
extension.onCreate(savedInstanceState)
if (isRunningAsStub) {
// Overwrite private members to avoid nasty "false" stack traces being logged
val delegate = delegate
val clz = delegate.javaClass
clz.reflectField("mActivityHandlesConfigFlagsChecked").set(delegate, true)
clz.reflectField("mActivityHandlesConfigFlags").set(delegate, 0)
}
super.onCreate(savedInstanceState)
startObserveLiveData()
// We need to set the window background explicitly since for whatever reason it's not
// propagated upstream
obtainStyledAttributes(intArrayOf(android.R.attr.windowBackground))
.use { it.getDrawable(0) }
.also { window.setBackgroundDrawable(it) }
WindowCompat.setDecorFitsSystemWindows(window, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
window?.decorView?.post {
// If navigation bar is short enough (gesture navigation enabled), make it transparent
if ((window.decorView.rootWindowInsets?.systemWindowInsetBottom
?: 0) < Resources.getSystem().displayMetrics.density * 40) {
window.navigationBarColor = Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.navigationBarDividerColor = Color.TRANSPARENT
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
window.isStatusBarContrastEnforced = false
}
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
extension.onSaveInstanceState(outState)
}
fun setContentView() {
binding = DataBindingUtil.setContentView<Binding>(this, layoutRes).also {
it.setVariable(BR.viewModel, viewModel)
it.lifecycleOwner = this
}
}
fun setAccessibilityDelegate(delegate: View.AccessibilityDelegate?) {
binding.root.rootView.accessibilityDelegate = delegate
}
fun showSnackbar(
message: CharSequence,
length: Int = Snackbar.LENGTH_SHORT,
builder: Snackbar.() -> Unit = {}
) = Snackbar.make(snackbarView, message, length)
.setAnchorView(snackbarAnchorView).apply(builder).show()
override fun onResume() {
super.onResume()
viewModel.let {
if (it is AsyncLoadViewModel)
it.startLoading()
}
}
override fun onEventDispatched(event: ViewEvent) = when (event) {
is ContextExecutor -> event(this)
is ActivityExecutor -> event(this)
else -> Unit
}
}
fun ViewGroup.startAnimations() {
val transition = AutoTransition()
.setInterpolator(FastOutSlowInInterpolator())
.setDuration(400)
.excludeTarget(R.id.main_toolbar, true)
TransitionManager.beginDelayedTransition(
this,
transition
)
}

View File

@@ -1,21 +0,0 @@
package com.topjohnwu.magisk.arch
import android.content.Context
/**
* Class for passing events from ViewModels to Activities/Fragments
* (see https://medium.com/google-developers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150)
*/
abstract class ViewEvent
interface ContextExecutor {
operator fun invoke(context: Context)
}
interface ActivityExecutor {
operator fun invoke(activity: UIActivity<*>)
}
interface FragmentExecutor {
operator fun invoke(fragment: BaseFragment<*>)
}

View File

@@ -1,10 +1,7 @@
package com.topjohnwu.magisk.arch
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStoreOwner
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.di.ServiceLocator
import com.topjohnwu.magisk.ui.home.HomeViewModel
import com.topjohnwu.magisk.ui.install.InstallViewModel
@@ -12,21 +9,6 @@ import com.topjohnwu.magisk.ui.log.LogViewModel
import com.topjohnwu.magisk.ui.superuser.SuperuserViewModel
import com.topjohnwu.magisk.ui.surequest.SuRequestViewModel
interface ViewModelHolder : LifecycleOwner, ViewModelStoreOwner {
val viewModel: BaseViewModel
fun startObserveLiveData() {
viewModel.viewEvents.observe(this, this::onEventDispatched)
Info.isConnected.observe(this, viewModel::onNetworkChanged)
}
/**
* Called for all [ViewEvent]s published by associated viewModel.
*/
fun onEventDispatched(event: ViewEvent) {}
}
object VMFactory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@@ -35,15 +17,10 @@ object VMFactory : ViewModelProvider.Factory {
LogViewModel::class.java -> LogViewModel(ServiceLocator.logRepo)
SuperuserViewModel::class.java -> SuperuserViewModel(ServiceLocator.policyDB)
InstallViewModel::class.java ->
InstallViewModel(ServiceLocator.networkService, ServiceLocator.markwon)
InstallViewModel(ServiceLocator.networkService)
SuRequestViewModel::class.java ->
SuRequestViewModel(ServiceLocator.policyDB, ServiceLocator.timeoutPrefs)
else -> modelClass.newInstance()
} as T
}
}
inline fun <reified VM : ViewModel> ViewModelHolder.viewModel() =
lazy(LazyThreadSafetyMode.NONE) {
ViewModelProvider(this, VMFactory)[VM::class.java]
}

View File

@@ -1,346 +0,0 @@
package com.topjohnwu.magisk.databinding
import android.animation.ValueAnimator
import android.content.res.ColorStateList
import android.graphics.Paint
import android.graphics.drawable.Drawable
import android.text.Spanned
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.Spinner
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.appcompat.widget.Toolbar
import androidx.cardview.widget.CardView
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.updateLayoutParams
import androidx.core.widget.ImageViewCompat
import androidx.databinding.BindingAdapter
import androidx.databinding.InverseBindingAdapter
import androidx.databinding.InverseBindingListener
import androidx.databinding.InverseMethod
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.google.android.material.button.MaterialButton
import com.google.android.material.card.MaterialCardView
import com.google.android.material.chip.Chip
import com.google.android.material.slider.Slider
import com.google.android.material.textfield.TextInputLayout
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.di.ServiceLocator
import com.topjohnwu.magisk.core.model.su.SuPolicy
import com.topjohnwu.magisk.utils.TextHolder
import com.topjohnwu.superuser.internal.UiThreadHandler
import com.topjohnwu.widget.IndeterminateCheckBox
import kotlin.math.roundToInt
@BindingAdapter("gone")
fun setGone(view: View, gone: Boolean) {
view.isGone = gone
}
@BindingAdapter("invisible")
fun setInvisible(view: View, invisible: Boolean) {
view.isInvisible = invisible
}
@BindingAdapter("goneUnless")
fun setGoneUnless(view: View, goneUnless: Boolean) {
setGone(view, goneUnless.not())
}
@BindingAdapter("invisibleUnless")
fun setInvisibleUnless(view: View, invisibleUnless: Boolean) {
setInvisible(view, invisibleUnless.not())
}
@BindingAdapter("markdownText")
fun setMarkdownText(tv: TextView, markdown: Spanned) {
ServiceLocator.markwon.setParsedMarkdown(tv, markdown)
}
@BindingAdapter("onNavigationClick")
fun setOnNavigationClickedListener(view: Toolbar, listener: View.OnClickListener) {
view.setNavigationOnClickListener(listener)
}
@BindingAdapter("srcCompat")
fun setImageResource(view: ImageView, @DrawableRes resId: Int) {
view.setImageResource(resId)
}
@BindingAdapter("srcCompat")
fun setImageResource(view: ImageView, drawable: Drawable) {
view.setImageDrawable(drawable)
}
@BindingAdapter("onTouch")
fun setOnTouchListener(view: View, listener: View.OnTouchListener) {
view.setOnTouchListener(listener)
}
@BindingAdapter("scrollToLast")
fun setScrollToLast(view: RecyclerView, shouldScrollToLast: Boolean) {
fun scrollToLast() = UiThreadHandler.handler.postDelayed({
view.scrollToPosition(view.adapter?.itemCount?.minus(1) ?: 0)
}, 30)
fun wait(callback: () -> Unit) {
UiThreadHandler.handler.postDelayed(callback, 1000)
}
fun RecyclerView.Adapter<*>.setListener() {
val observer = object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
scrollToLast()
}
}
registerAdapterDataObserver(observer)
view.setTag(R.id.recyclerScrollListener, observer)
}
fun RecyclerView.Adapter<*>.removeListener() {
val observer =
view.getTag(R.id.recyclerScrollListener) as? RecyclerView.AdapterDataObserver ?: return
unregisterAdapterDataObserver(observer)
}
fun trySetListener(): Unit = view.adapter?.setListener() ?: wait { trySetListener() }
if (shouldScrollToLast) {
trySetListener()
} else {
view.adapter?.removeListener()
}
}
@BindingAdapter("isEnabled")
fun setEnabled(view: View, isEnabled: Boolean) {
view.isEnabled = isEnabled
}
@BindingAdapter("error")
fun TextInputLayout.setErrorString(error: String) {
val newError = error.let { if (it.isEmpty()) null else it }
if (this.error == null && newError == null) return
this.error = newError
}
// md2
@BindingAdapter(
"android:layout_marginLeft",
"android:layout_marginTop",
"android:layout_marginRight",
"android:layout_marginBottom",
"android:layout_marginStart",
"android:layout_marginEnd",
requireAll = false
)
fun View.setMargins(
marginLeft: Int?,
marginTop: Int?,
marginRight: Int?,
marginBottom: Int?,
marginStart: Int?,
marginEnd: Int?
) = updateLayoutParams<ViewGroup.MarginLayoutParams> {
marginLeft?.let { leftMargin = it }
marginTop?.let { topMargin = it }
marginRight?.let { rightMargin = it }
marginBottom?.let { bottomMargin = it }
marginStart?.let { this.marginStart = it }
marginEnd?.let { this.marginEnd = it }
}
@BindingAdapter("nestedScrollingEnabled")
fun RecyclerView.setNestedScrolling(enabled: Boolean) {
isNestedScrollingEnabled = enabled
}
@BindingAdapter("isSelected")
fun View.isSelected(isSelected: Boolean) {
this.isSelected = isSelected
}
@BindingAdapter("dividerVertical", "dividerHorizontal", requireAll = false)
fun RecyclerView.setDividers(dividerVertical: Drawable?, dividerHorizontal: Drawable?) {
if (dividerHorizontal != null) {
DividerItemDecoration(context, LinearLayoutManager.HORIZONTAL).apply {
setDrawable(dividerHorizontal)
}.let { addItemDecoration(it) }
}
if (dividerVertical != null) {
DividerItemDecoration(context, LinearLayoutManager.VERTICAL).apply {
setDrawable(dividerVertical)
}.let { addItemDecoration(it) }
}
}
@BindingAdapter("icon")
fun Button.setIconRes(res: Int) {
(this as MaterialButton).setIconResource(res)
}
@BindingAdapter("icon")
fun Button.setIcon(drawable: Drawable) {
(this as MaterialButton).icon = drawable
}
@BindingAdapter("strokeWidth")
fun MaterialCardView.setCardStrokeWidthBound(stroke: Float) {
strokeWidth = stroke.roundToInt()
}
@BindingAdapter("onMenuClick")
fun Toolbar.setOnMenuClickListener(listener: Toolbar.OnMenuItemClickListener) {
setOnMenuItemClickListener(listener)
}
@BindingAdapter("onCloseClicked")
fun Chip.setOnCloseClickedListenerBinding(listener: View.OnClickListener) {
setOnCloseIconClickListener(listener)
}
@BindingAdapter("progressAnimated")
fun ProgressBar.setProgressAnimated(newProgress: Int) {
val animator = tag as? ValueAnimator
animator?.cancel()
ValueAnimator.ofInt(progress, newProgress).apply {
interpolator = FastOutSlowInInterpolator()
addUpdateListener { progress = it.animatedValue as Int }
tag = this
}.start()
}
@BindingAdapter("android:text")
fun TextView.setTextSafe(text: Int) {
if (text == 0) this.text = null else setText(text)
}
@BindingAdapter("android:onLongClick")
fun View.setOnLongClickListenerBinding(listener: () -> Unit) {
setOnLongClickListener {
listener()
true
}
}
@BindingAdapter("strikeThrough")
fun TextView.setStrikeThroughEnabled(useStrikeThrough: Boolean) {
paintFlags = if (useStrikeThrough) {
paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
} else {
paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
}
}
@BindingAdapter("spanCount")
fun RecyclerView.setSpanCount(count: Int) {
when (val lama = layoutManager) {
is GridLayoutManager -> lama.spanCount = count
is StaggeredGridLayoutManager -> lama.spanCount = count
}
}
@BindingAdapter("state")
fun setState(view: IndeterminateCheckBox, state: Boolean?) {
if (view.state != state)
view.state = state
}
@InverseBindingAdapter(attribute = "state")
fun getState(view: IndeterminateCheckBox) = view.state
@BindingAdapter("stateAttrChanged")
fun setListeners(
view: IndeterminateCheckBox,
attrChange: InverseBindingListener
) {
view.setOnStateChangedListener { _, _ ->
attrChange.onChange()
}
}
@BindingAdapter("cardBackgroundColorAttr")
fun CardView.setCardBackgroundColorAttr(attr: Int) {
val tv = TypedValue()
context.theme.resolveAttribute(attr, tv, true)
setCardBackgroundColor(tv.data)
}
@BindingAdapter("tint")
fun ImageView.setTint(color: Int) {
ImageViewCompat.setImageTintList(this, ColorStateList.valueOf(color))
}
@BindingAdapter("tintAttr")
fun ImageView.setTintAttr(attr: Int) {
val tv = TypedValue()
context.theme.resolveAttribute(attr, tv, true)
ImageViewCompat.setImageTintList(this, ColorStateList.valueOf(tv.data))
}
@BindingAdapter("textColorAttr")
fun TextView.setTextColorAttr(attr: Int) {
val tv = TypedValue()
context.theme.resolveAttribute(attr, tv, true)
setTextColor(tv.data)
}
@BindingAdapter("android:text")
fun TextView.setText(text: TextHolder) {
this.text = text.getText(context.resources)
}
@BindingAdapter("items", "layout")
fun Spinner.setAdapter(items: Array<Any>, layoutRes: Int) {
adapter = ArrayAdapter(context, layoutRes, items)
}
@BindingAdapter("labelFormatter")
fun Slider.setLabelFormatter(formatter: (Float) -> Int) {
setLabelFormatter { value -> resources.getString(formatter(value)) }
}
@InverseBindingAdapter(attribute = "android:value")
fun Slider.getValueBinding() = value
@BindingAdapter("android:valueAttrChanged")
fun Slider.setListener(attrChange: InverseBindingListener) {
addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) = Unit
override fun onStopTrackingTouch(slider: Slider) = attrChange.onChange()
})
}
@InverseMethod("sliderValueToPolicy")
fun policyToSliderValue(policy: Int): Float {
return when (policy) {
SuPolicy.DENY -> 1f
SuPolicy.RESTRICT -> 2f
SuPolicy.ALLOW -> 3f
else -> 1f
}
}
fun sliderValueToPolicy(value: Float): Int {
return when (value) {
1f -> SuPolicy.DENY
2f -> SuPolicy.RESTRICT
3f -> SuPolicy.ALLOW
else -> SuPolicy.DENY
}
}

View File

@@ -1,156 +0,0 @@
package com.topjohnwu.magisk.databinding
import androidx.annotation.MainThread
import androidx.annotation.WorkerThread
import androidx.databinding.ListChangeRegistry
import androidx.databinding.ObservableList
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.AbstractList
// Only expose the immutable List types
interface DiffList<T : DiffItem<*>> : List<T> {
fun calculateDiff(newItems: List<T>): DiffUtil.DiffResult
@MainThread
fun update(newItems: List<T>, diffResult: DiffUtil.DiffResult)
@WorkerThread
suspend fun update(newItems: List<T>)
}
interface FilterList<T : DiffItem<*>> : List<T> {
fun filter(filter: (T) -> Boolean)
@MainThread
fun set(newItems: List<T>)
}
fun <T : DiffItem<*>> diffList(): DiffList<T> = DiffObservableList()
fun <T : DiffItem<*>> filterList(scope: CoroutineScope): FilterList<T> =
FilterableDiffObservableList(scope)
private open class DiffObservableList<T : DiffItem<*>>
: AbstractList<T>(), ObservableList<T>, DiffList<T>, ListUpdateCallback {
protected var list: List<T> = emptyList()
private val listeners = ListChangeRegistry()
override val size: Int get() = list.size
override fun get(index: Int) = list[index]
override fun calculateDiff(newItems: List<T>): DiffUtil.DiffResult {
return doCalculateDiff(list, newItems)
}
protected fun doCalculateDiff(oldItems: List<T>, newItems: List<T>): DiffUtil.DiffResult {
return DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize() = oldItems.size
override fun getNewListSize() = newItems.size
@Suppress("UNCHECKED_CAST")
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldItems[oldItemPosition]
val newItem = newItems[newItemPosition]
return (oldItem as DiffItem<Any>).itemSameAs(newItem)
}
@Suppress("UNCHECKED_CAST")
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldItems[oldItemPosition]
val newItem = newItems[newItemPosition]
return (oldItem as DiffItem<Any>).contentSameAs(newItem)
}
}, true)
}
@MainThread
override fun update(newItems: List<T>, diffResult: DiffUtil.DiffResult) {
list = ArrayList(newItems)
diffResult.dispatchUpdatesTo(this)
}
@WorkerThread
override suspend fun update(newItems: List<T>) {
val diffResult = calculateDiff(newItems)
withContext(Dispatchers.Main) {
update(newItems, diffResult)
}
}
override fun addOnListChangedCallback(listener: ObservableList.OnListChangedCallback<out ObservableList<T>>) {
listeners.add(listener)
}
override fun removeOnListChangedCallback(listener: ObservableList.OnListChangedCallback<out ObservableList<T>>) {
listeners.remove(listener)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
listeners.notifyChanged(this, position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
listeners.notifyMoved(this, fromPosition, toPosition, 1)
}
override fun onInserted(position: Int, count: Int) {
modCount += 1
listeners.notifyInserted(this, position, count)
}
override fun onRemoved(position: Int, count: Int) {
modCount += 1
listeners.notifyRemoved(this, position, count)
}
}
private class FilterableDiffObservableList<T : DiffItem<*>>(
private val scope: CoroutineScope
) : DiffObservableList<T>(), FilterList<T> {
private var sublist: List<T> = emptyList()
private var job: Job? = null
private var lastFilter: ((T) -> Boolean)? = null
// ---
override fun filter(filter: (T) -> Boolean) {
lastFilter = filter
job?.cancel()
job = scope.launch(Dispatchers.Default) {
val oldList = sublist
val newList = list.filter(filter)
val diff = doCalculateDiff(oldList, newList)
withContext(Dispatchers.Main) {
sublist = newList
diff.dispatchUpdatesTo(this@FilterableDiffObservableList)
}
}
}
// ---
override fun get(index: Int): T {
return sublist[index]
}
override val size: Int
get() = sublist.size
@MainThread
override fun set(newItems: List<T>) {
onRemoved(0, sublist.size)
list = newItems
sublist = emptyList()
lastFilter?.let { filter(it) }
}
}

View File

@@ -1,162 +0,0 @@
package com.topjohnwu.magisk.databinding
import androidx.databinding.ListChangeRegistry
import androidx.databinding.ObservableList
import androidx.databinding.ObservableList.OnListChangedCallback
import java.util.AbstractList
@Suppress("UNCHECKED_CAST")
class MergeObservableList<T> : AbstractList<T>(), ObservableList<T> {
private val lists: MutableList<List<T>> = mutableListOf()
private val listeners = ListChangeRegistry()
private val callback = Callback<T>()
override fun addOnListChangedCallback(callback: OnListChangedCallback<out ObservableList<T>>) {
listeners.add(callback)
}
override fun removeOnListChangedCallback(callback: OnListChangedCallback<out ObservableList<T>>) {
listeners.remove(callback)
}
override fun get(index: Int): T {
if (index < 0)
throw IndexOutOfBoundsException()
var idx = index
for (list in lists) {
val size = list.size
if (idx < size) {
return list[idx]
}
idx -= size
}
throw IndexOutOfBoundsException()
}
override val size: Int
get() = lists.fold(0) { i, it -> i + it.size }
fun insertItem(obj: T): MergeObservableList<T> {
val idx = size
lists.add(listOf(obj))
++modCount
listeners.notifyInserted(this, idx, 1)
return this
}
fun insertList(list: List<T>): MergeObservableList<T> {
val idx = size
lists.add(list)
++modCount
(list as? ObservableList<T>)?.addOnListChangedCallback(callback)
if (list.isNotEmpty())
listeners.notifyInserted(this, idx, list.size)
return this
}
fun removeItem(obj: T): Boolean {
var idx = 0
for ((i, list) in lists.withIndex()) {
if (list !is ObservableList<*>) {
if (obj == list[0]) {
lists.removeAt(i)
++modCount
listeners.notifyRemoved(this, idx, 1)
return true
}
}
idx += list.size
}
return false
}
fun removeList(listToRemove: List<T>): Boolean {
var idx = 0
for ((i, list) in lists.withIndex()) {
if (listToRemove === list) {
(list as? ObservableList<T>)?.removeOnListChangedCallback(callback)
lists.removeAt(i)
++modCount
listeners.notifyRemoved(this, idx, list.size)
return true
}
idx += list.size
}
return false
}
override fun clear() {
val sz = size
for (list in lists) {
if (list is ObservableList) {
list.removeOnListChangedCallback(callback)
}
}
++modCount
lists.clear()
if (sz > 0)
listeners.notifyRemoved(this, 0, sz)
}
private fun subIndexToIndex(subList: List<*>, index: Int): Int {
if (index < 0)
throw IndexOutOfBoundsException()
var idx = 0
for (list in lists) {
if (subList === list) {
return idx + index
}
idx += list.size
}
throw IllegalArgumentException()
}
inner class Callback<T> : OnListChangedCallback<ObservableList<T>>() {
override fun onChanged(sender: ObservableList<T>) {
++modCount
listeners.notifyChanged(this@MergeObservableList)
}
override fun onItemRangeChanged(
sender: ObservableList<T>,
positionStart: Int,
itemCount: Int
) {
listeners.notifyChanged(this@MergeObservableList,
subIndexToIndex(sender, positionStart), itemCount)
}
override fun onItemRangeInserted(
sender: ObservableList<T>,
positionStart: Int,
itemCount: Int
) {
++modCount
listeners.notifyInserted(this@MergeObservableList,
subIndexToIndex(sender, positionStart), itemCount)
}
override fun onItemRangeMoved(
sender: ObservableList<T>,
fromPosition: Int,
toPosition: Int,
itemCount: Int
) {
val idx = subIndexToIndex(sender, 0)
listeners.notifyMoved(this@MergeObservableList,
idx + fromPosition, idx + toPosition, itemCount)
}
override fun onItemRangeRemoved(
sender: ObservableList<T>,
positionStart: Int,
itemCount: Int
) {
++modCount
listeners.notifyRemoved(this@MergeObservableList,
subIndexToIndex(sender, positionStart), itemCount)
}
}
}

View File

@@ -1,97 +0,0 @@
package com.topjohnwu.magisk.databinding
import androidx.databinding.Observable
import androidx.databinding.PropertyChangeRegistry
/**
* Modified from https://github.com/skoumalcz/teanity/blob/1.2/core/src/main/java/com/skoumal/teanity/observable/Notifyable.kt
*
* Interface that allows user to be observed via DataBinding or manually by assigning listeners.
*
* @see [androidx.databinding.Observable]
* */
interface ObservableHost : Observable {
var callbacks: PropertyChangeRegistry?
/**
* Notifies all observers that something has changed. By default implementation this method is
* synchronous, hence observers will never be notified in undefined order. Observers might
* choose to refresh the view completely, which is beyond the scope of this function.
* */
fun notifyChange() {
synchronized(this) {
callbacks ?: return
}.notifyCallbacks(this, 0, null)
}
/**
* Notifies all observers about field with [fieldId] has been changed. This will happen
* synchronously before or after [notifyChange] has been called. It will never be called during
* the execution of aforementioned method.
* */
fun notifyPropertyChanged(fieldId: Int) {
synchronized(this) {
callbacks ?: return
}.notifyCallbacks(this, fieldId, null)
}
override fun addOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) {
synchronized(this) {
callbacks ?: PropertyChangeRegistry().also { callbacks = it }
}.add(callback)
}
override fun removeOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) {
synchronized(this) {
callbacks ?: return
}.remove(callback)
}
}
fun ObservableHost.addOnPropertyChangedCallback(
fieldId: Int,
removeAfterChanged: Boolean = false,
callback: () -> Unit
) {
addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
if (fieldId == propertyId) {
callback()
if (removeAfterChanged)
removeOnPropertyChangedCallback(this)
}
}
})
}
/**
* Injects boilerplate implementation for {@literal @}[androidx.databinding.Bindable] field setters.
*
* # Examples:
* ```kotlin
* @get:Bindable
* var myField = defaultValue
* set(value) = set(value, field, { field = it }, BR.myField) {
* doSomething(it)
* }
* ```
* */
inline fun <reified T> ObservableHost.set(
new: T, old: T, setter: (T) -> Unit, fieldId: Int, afterChanged: (T) -> Unit = {}) {
if (old != new) {
setter(new)
notifyPropertyChanged(fieldId)
afterChanged(new)
}
}
inline fun <reified T> ObservableHost.set(
new: T, old: T, setter: (T) -> Unit, vararg fieldIds: Int, afterChanged: (T) -> Unit = {}) {
if (old != new) {
setter(new)
fieldIds.forEach { notifyPropertyChanged(it) }
afterChanged(new)
}
}

View File

@@ -1,35 +0,0 @@
package com.topjohnwu.magisk.databinding
import androidx.databinding.PropertyChangeRegistry
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.RecyclerView
abstract class RvItem {
abstract val layoutRes: Int
}
abstract class ObservableRvItem : RvItem(), ObservableHost {
override var callbacks: PropertyChangeRegistry? = null
}
interface ItemWrapper<E> {
val item: E
}
interface ViewAwareItem {
fun onBind(binding: ViewDataBinding, recyclerView: RecyclerView)
}
interface DiffItem<T : Any> {
fun itemSameAs(other: T): Boolean {
if (this === other) return true
return when (this) {
is ItemWrapper<*> -> item == (other as ItemWrapper<*>).item
is Comparable<*> -> compareValues(this, other as Comparable<*>) == 0
else -> this == other
}
}
fun contentSameAs(other: T) = true
}

View File

@@ -1,121 +0,0 @@
package com.topjohnwu.magisk.databinding
import android.annotation.SuppressLint
import android.util.SparseArray
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.BindingAdapter
import androidx.databinding.DataBindingUtil
import androidx.databinding.ObservableList
import androidx.databinding.ObservableList.OnListChangedCallback
import androidx.databinding.ViewDataBinding
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import com.topjohnwu.magisk.BR
class RvItemAdapter<T: RvItem>(
val items: List<T>,
val extraBindings: SparseArray<*>?
) : RecyclerView.Adapter<RvItemAdapter.ViewHolder>() {
private var lifecycleOwner: LifecycleOwner? = null
private var recyclerView: RecyclerView? = null
private val observer by lazy(LazyThreadSafetyMode.NONE) { ListObserver<T>() }
override fun onAttachedToRecyclerView(rv: RecyclerView) {
lifecycleOwner = rv.findViewTreeLifecycleOwner()
recyclerView = rv
if (items is ObservableList)
items.addOnListChangedCallback(observer)
}
override fun onDetachedFromRecyclerView(rv: RecyclerView) {
lifecycleOwner = null
recyclerView = null
if (items is ObservableList)
items.removeOnListChangedCallback(observer)
}
override fun onCreateViewHolder(parent: ViewGroup, layoutRes: Int): ViewHolder {
val inflator = LayoutInflater.from(parent.context)
return ViewHolder(DataBindingUtil.inflate(inflator, layoutRes, parent, false))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.binding.setVariable(BR.item, item)
extraBindings?.let {
for (i in 0 until it.size()) {
holder.binding.setVariable(it.keyAt(i), it.valueAt(i))
}
}
holder.binding.lifecycleOwner = lifecycleOwner
holder.binding.executePendingBindings()
recyclerView?.let {
if (item is ViewAwareItem)
item.onBind(holder.binding, it)
}
}
override fun getItemCount() = items.size
override fun getItemViewType(position: Int) = items[position].layoutRes
class ViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
inner class ListObserver<T: RvItem> : OnListChangedCallback<ObservableList<T>>() {
@SuppressLint("NotifyDataSetChanged")
override fun onChanged(sender: ObservableList<T>) {
notifyDataSetChanged()
}
override fun onItemRangeChanged(
sender: ObservableList<T>,
positionStart: Int,
itemCount: Int
) {
notifyItemRangeChanged(positionStart, itemCount)
}
override fun onItemRangeInserted(
sender: ObservableList<T>?,
positionStart: Int,
itemCount: Int
) {
notifyItemRangeInserted(positionStart, itemCount)
}
override fun onItemRangeMoved(
sender: ObservableList<T>?,
fromPosition: Int,
toPosition: Int,
itemCount: Int
) {
for (i in 0 until itemCount) {
notifyItemMoved(fromPosition + i, toPosition + i)
}
}
override fun onItemRangeRemoved(
sender: ObservableList<T>?,
positionStart: Int,
itemCount: Int
) {
notifyItemRangeRemoved(positionStart, itemCount)
}
}
}
inline fun bindExtra(body: (SparseArray<Any?>) -> Unit) = SparseArray<Any?>().also(body)
@BindingAdapter("items", "extraBindings", requireAll = false)
fun <T: RvItem> RecyclerView.setAdapter(items: List<T>?, extraBindings: SparseArray<*>?) {
if (items != null) {
val rva = (adapter as? RvItemAdapter<*>)
if (rva == null || rva.items !== items || rva.extraBindings !== extraBindings) {
adapter = RvItemAdapter(items, extraBindings)
}
}
}

View File

@@ -1,41 +0,0 @@
package com.topjohnwu.magisk.dialog
import android.app.Activity
import androidx.appcompat.app.AppCompatDelegate
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.UIActivity
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.events.DialogBuilder
import com.topjohnwu.magisk.view.MagiskDialog
import com.topjohnwu.magisk.core.R as CoreR
class DarkThemeDialog : DialogBuilder {
override fun build(dialog: MagiskDialog) {
val activity = dialog.ownerActivity!!
dialog.apply {
setTitle(CoreR.string.settings_dark_mode_title)
setMessage(CoreR.string.settings_dark_mode_message)
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = CoreR.string.settings_dark_mode_light
icon = R.drawable.ic_day
onClick { selectTheme(AppCompatDelegate.MODE_NIGHT_NO, activity) }
}
setButton(MagiskDialog.ButtonType.NEUTRAL) {
text = CoreR.string.settings_dark_mode_system
icon = R.drawable.ic_day_night
onClick { selectTheme(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, activity) }
}
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = CoreR.string.settings_dark_mode_dark
icon = R.drawable.ic_night
onClick { selectTheme(AppCompatDelegate.MODE_NIGHT_YES, activity) }
}
}
}
private fun selectTheme(mode: Int, activity: Activity) {
Config.darkTheme = mode
(activity as UIActivity<*>).delegate.localNightMode = mode
}
}

View File

@@ -1,65 +0,0 @@
package com.topjohnwu.magisk.dialog
import android.widget.Toast
import androidx.core.os.postDelayed
import androidx.lifecycle.lifecycleScope
import com.topjohnwu.magisk.core.BuildConfig
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.core.ktx.reboot
import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.core.tasks.MagiskInstaller
import com.topjohnwu.magisk.events.DialogBuilder
import com.topjohnwu.magisk.ui.home.HomeViewModel
import com.topjohnwu.magisk.view.MagiskDialog
import com.topjohnwu.superuser.internal.UiThreadHandler
import kotlinx.coroutines.launch
class EnvFixDialog(private val vm: HomeViewModel, private val code: Int) : DialogBuilder {
override fun build(dialog: MagiskDialog) {
dialog.apply {
setTitle(R.string.env_fix_title)
setMessage(R.string.env_fix_msg)
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = android.R.string.ok
doNotDismiss = true
onClick {
dialog.apply {
setTitle(R.string.setup_title)
setMessage(R.string.setup_msg)
resetButtons()
setCancelable(false)
}
dialog.activity.lifecycleScope.launch {
MagiskInstaller.FixEnv().exec { success ->
dialog.dismiss()
context.toast(
if (success) R.string.reboot_delay_toast else R.string.setup_fail,
Toast.LENGTH_LONG
)
if (success)
UiThreadHandler.handler.postDelayed(5000) { reboot() }
}
}
}
}
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = android.R.string.cancel
}
}
if (code == 2 || // No rules block, module policy not loaded
Info.env.versionCode != BuildConfig.APP_VERSION_CODE ||
Info.env.versionString != BuildConfig.APP_VERSION_NAME) {
dialog.setMessage(R.string.env_full_fix_msg)
dialog.setButton(MagiskDialog.ButtonType.POSITIVE) {
text = android.R.string.ok
onClick {
vm.onMagiskPressed()
dialog.dismiss()
}
}
}
}
}

View File

@@ -1,33 +0,0 @@
package com.topjohnwu.magisk.dialog
import android.net.Uri
import com.topjohnwu.magisk.MainDirections
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.events.DialogBuilder
import com.topjohnwu.magisk.ui.module.ModuleViewModel
import com.topjohnwu.magisk.view.MagiskDialog
class LocalModuleInstallDialog(
private val viewModel: ModuleViewModel,
private val uri: Uri,
private val displayName: String
) : DialogBuilder {
override fun build(dialog: MagiskDialog) {
dialog.apply {
setTitle(R.string.confirm_install_title)
setMessage(context.getString(R.string.confirm_install, displayName))
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = android.R.string.ok
onClick {
viewModel.apply {
MainDirections.actionFlashFragment(Const.Value.FLASH_ZIP, uri).navigate()
}
}
}
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = android.R.string.cancel
}
}
}
}

View File

@@ -1,34 +0,0 @@
package com.topjohnwu.magisk.dialog
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.core.download.DownloadEngine
import com.topjohnwu.magisk.core.download.Subject
import com.topjohnwu.magisk.view.MagiskDialog
import java.io.File
class ManagerInstallDialog : MarkDownDialog() {
override suspend fun getMarkdownText(): String {
val text = Info.update.note
// Cache the changelog
File(AppContext.cacheDir, "${Info.update.versionCode}.md").writeText(text)
return text
}
override fun build(dialog: MagiskDialog) {
super.build(dialog)
dialog.apply {
setCancelable(true)
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = R.string.install
onClick { DownloadEngine.startWithActivity(activity, Subject.App()) }
}
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = android.R.string.cancel
}
}
}
}

View File

@@ -1,39 +0,0 @@
package com.topjohnwu.magisk.dialog
import android.view.LayoutInflater
import android.widget.TextView
import androidx.annotation.CallSuper
import androidx.lifecycle.lifecycleScope
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.di.ServiceLocator
import com.topjohnwu.magisk.events.DialogBuilder
import com.topjohnwu.magisk.view.MagiskDialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.IOException
import com.topjohnwu.magisk.core.R as CoreR
abstract class MarkDownDialog : DialogBuilder {
abstract suspend fun getMarkdownText(): String
@CallSuper
override fun build(dialog: MagiskDialog) {
with(dialog) {
val view = LayoutInflater.from(context).inflate(R.layout.markdown_window_md2, null)
setView(view)
val tv = view.findViewById<TextView>(R.id.md_txt)
activity.lifecycleScope.launch {
try {
val text = withContext(Dispatchers.IO) { getMarkdownText() }
ServiceLocator.markwon.setMarkdown(tv, text)
} catch (e: IOException) {
Timber.e(e)
tv.setText(CoreR.string.download_file_error)
}
}
}
}
}

View File

@@ -1,59 +0,0 @@
package com.topjohnwu.magisk.dialog
import android.content.Context
import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.core.di.ServiceLocator
import com.topjohnwu.magisk.core.download.DownloadEngine
import com.topjohnwu.magisk.core.download.Subject
import com.topjohnwu.magisk.core.model.module.OnlineModule
import com.topjohnwu.magisk.ui.flash.FlashFragment
import com.topjohnwu.magisk.view.MagiskDialog
import com.topjohnwu.magisk.view.Notifications
import kotlinx.parcelize.Parcelize
class OnlineModuleInstallDialog(private val item: OnlineModule) : MarkDownDialog() {
private val svc get() = ServiceLocator.networkService
override suspend fun getMarkdownText(): String {
val str = svc.fetchString(item.changelog)
return if (str.length > 1000) str.substring(0, 1000) else str
}
@Parcelize
class Module(
override val module: OnlineModule,
override val autoLaunch: Boolean,
override val notifyId: Int = Notifications.nextId()
) : Subject.Module() {
override fun pendingIntent(context: Context) = FlashFragment.installIntent(context, file)
}
override fun build(dialog: MagiskDialog) {
super.build(dialog)
dialog.apply {
fun download(install: Boolean) {
DownloadEngine.startWithActivity(activity, Module(item, install))
}
val title = context.getString(R.string.repo_install_title,
item.name, item.version, item.versionCode)
setTitle(title)
setCancelable(true)
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = R.string.download
onClick { download(false) }
}
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = R.string.install
onClick { download(true) }
}
setButton(MagiskDialog.ButtonType.NEUTRAL) {
text = android.R.string.cancel
}
}
}
}

View File

@@ -1,19 +0,0 @@
package com.topjohnwu.magisk.dialog
import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.events.DialogBuilder
import com.topjohnwu.magisk.view.MagiskDialog
class SecondSlotWarningDialog : DialogBuilder {
override fun build(dialog: MagiskDialog) {
dialog.apply {
setTitle(android.R.string.dialog_alert_title)
setMessage(R.string.install_inactive_slot_msg)
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = android.R.string.ok
}
setCancelable(true)
}
}
}

View File

@@ -1,25 +0,0 @@
package com.topjohnwu.magisk.dialog
import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.events.DialogBuilder
import com.topjohnwu.magisk.view.MagiskDialog
class SuperuserRevokeDialog(
private val appName: String,
private val onSuccess: () -> Unit
) : DialogBuilder {
override fun build(dialog: MagiskDialog) {
dialog.apply {
setTitle(R.string.su_revoke_title)
setMessage(R.string.su_revoke_msg, appName)
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = android.R.string.ok
onClick { onSuccess() }
}
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = android.R.string.cancel
}
}
}
}

View File

@@ -1,57 +0,0 @@
package com.topjohnwu.magisk.dialog
import android.app.ProgressDialog
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import com.topjohnwu.magisk.arch.NavigationActivity
import com.topjohnwu.magisk.arch.UIActivity
import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.core.tasks.MagiskInstaller
import com.topjohnwu.magisk.events.DialogBuilder
import com.topjohnwu.magisk.ui.flash.FlashFragment
import com.topjohnwu.magisk.view.MagiskDialog
import kotlinx.coroutines.launch
class UninstallDialog : DialogBuilder {
override fun build(dialog: MagiskDialog) {
dialog.apply {
setTitle(R.string.uninstall_magisk_title)
setMessage(R.string.uninstall_magisk_msg)
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = R.string.restore_img
onClick { restore(dialog.activity) }
}
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = R.string.complete_uninstall
onClick { completeUninstall(dialog) }
}
}
}
@Suppress("DEPRECATION")
private fun restore(activity: UIActivity<*>) {
val dialog = ProgressDialog(activity).apply {
setMessage(activity.getString(R.string.restore_img_msg))
show()
}
activity.lifecycleScope.launch {
MagiskInstaller.Restore().exec { success ->
dialog.dismiss()
if (success) {
activity.toast(R.string.restore_done, Toast.LENGTH_SHORT)
} else {
activity.toast(R.string.restore_fail, Toast.LENGTH_LONG)
}
}
}
}
private fun completeUninstall(dialog: MagiskDialog) {
(dialog.ownerActivity as NavigationActivity<*>)
.navigation.navigate(FlashFragment.uninstall())
}
}

View File

@@ -1,124 +0,0 @@
package com.topjohnwu.magisk.events
import android.content.Context
import android.view.View
import androidx.annotation.StringRes
import androidx.navigation.NavDirections
import com.google.android.material.snackbar.Snackbar
import com.topjohnwu.magisk.arch.ActivityExecutor
import com.topjohnwu.magisk.arch.ContextExecutor
import com.topjohnwu.magisk.arch.NavigationActivity
import com.topjohnwu.magisk.arch.UIActivity
import com.topjohnwu.magisk.arch.ViewEvent
import com.topjohnwu.magisk.core.base.ContentResultCallback
import com.topjohnwu.magisk.core.base.relaunch
import com.topjohnwu.magisk.utils.TextHolder
import com.topjohnwu.magisk.utils.asText
import com.topjohnwu.magisk.view.MagiskDialog
import com.topjohnwu.magisk.view.Shortcuts
class PermissionEvent(
private val permission: String,
private val callback: (Boolean) -> Unit
) : ViewEvent(), ActivityExecutor {
override fun invoke(activity: UIActivity<*>) =
activity.withPermission(permission, callback)
}
class BackPressEvent : ViewEvent(), ActivityExecutor {
override fun invoke(activity: UIActivity<*>) {
activity.onBackPressed()
}
}
class DieEvent : ViewEvent(), ActivityExecutor {
override fun invoke(activity: UIActivity<*>) {
activity.finish()
}
}
class ShowUIEvent(private val delegate: View.AccessibilityDelegate?)
: ViewEvent(), ActivityExecutor {
override fun invoke(activity: UIActivity<*>) {
activity.setContentView()
activity.setAccessibilityDelegate(delegate)
}
}
class RecreateEvent : ViewEvent(), ActivityExecutor {
override fun invoke(activity: UIActivity<*>) {
activity.relaunch()
}
}
class AuthEvent(
private val callback: () -> Unit
) : ViewEvent(), ActivityExecutor {
override fun invoke(activity: UIActivity<*>) {
activity.withAuthentication { if (it) callback() }
}
}
class GetContentEvent(
private val type: String,
private val callback: ContentResultCallback
) : ViewEvent(), ActivityExecutor {
override fun invoke(activity: UIActivity<*>) {
activity.getContent(type, callback)
}
}
class NavigationEvent(
private val directions: NavDirections,
private val pop: Boolean
) : ViewEvent(), ActivityExecutor {
override fun invoke(activity: UIActivity<*>) {
(activity as? NavigationActivity<*>)?.apply {
if (pop) navigation.popBackStack()
directions.navigate()
}
}
}
class AddHomeIconEvent : ViewEvent(), ContextExecutor {
override fun invoke(context: Context) {
Shortcuts.addHomeIcon(context)
}
}
class SnackbarEvent(
private val msg: TextHolder,
private val length: Int = Snackbar.LENGTH_SHORT,
private val builder: Snackbar.() -> Unit = {}
) : ViewEvent(), ActivityExecutor {
constructor(
@StringRes res: Int,
length: Int = Snackbar.LENGTH_SHORT,
builder: Snackbar.() -> Unit = {}
) : this(res.asText(), length, builder)
constructor(
msg: String,
length: Int = Snackbar.LENGTH_SHORT,
builder: Snackbar.() -> Unit = {}
) : this(msg.asText(), length, builder)
override fun invoke(activity: UIActivity<*>) {
activity.showSnackbar(msg.getText(activity.resources), length, builder)
}
}
class DialogEvent(
private val builder: DialogBuilder
) : ViewEvent(), ActivityExecutor {
override fun invoke(activity: UIActivity<*>) {
MagiskDialog(activity).apply(builder::build).show()
}
}
interface DialogBuilder {
fun build(dialog: MagiskDialog)
}

View File

@@ -0,0 +1,452 @@
package com.topjohnwu.magisk.terminal
import java.util.Arrays
/**
* A circular buffer of [TerminalRow]s which keeps notes about what is visible on a logical screen and the scroll
* history.
*
* See [externalToInternalRow] for how to map from logical screen rows to array indices.
*/
class TerminalBuffer(columns: Int, totalRows: Int, screenRows: Int) {
var lines: Array<TerminalRow?>
/** The length of [lines]. */
var totalRows: Int = totalRows
private set
/** The number of rows and columns visible on the screen. */
var screenRows: Int = screenRows
var columns: Int = columns
/** The number of rows kept in history. */
var activeTranscriptRows: Int = 0
private set
/** The index in the circular buffer where the visible screen starts. */
private var screenFirstRow = 0
init {
lines = arrayOfNulls(totalRows)
blockSet(0, 0, columns, screenRows, ' '.code, TextStyle.NORMAL)
}
val transcriptText: String
get() = getSelectedText(0, -activeTranscriptRows, columns, screenRows).trim()
val transcriptTextWithoutJoinedLines: String
get() = getSelectedText(0, -activeTranscriptRows, columns, screenRows, false).trim()
val transcriptTextWithFullLinesJoined: String
get() = getSelectedText(0, -activeTranscriptRows, columns, screenRows, joinBackLines = true, joinFullLines = true).trim()
fun getSelectedText(selX1: Int, selY1: Int, selX2: Int, selY2: Int, joinBackLines: Boolean = true, joinFullLines: Boolean = false): String {
val builder = StringBuilder()
var y1 = selY1
var y2 = selY2
if (y1 < -activeTranscriptRows) y1 = -activeTranscriptRows
if (y2 >= screenRows) y2 = screenRows - 1
for (row in y1..y2) {
val x1 = if (row == y1) selX1 else 0
var x2: Int
if (row == y2) {
x2 = selX2 + 1
if (x2 > columns) x2 = columns
} else {
x2 = columns
}
val lineObject = lines[externalToInternalRow(row)]!!
val x1Index = lineObject.findStartOfColumn(x1)
var x2Index = if (x2 < columns) lineObject.findStartOfColumn(x2) else lineObject.spaceUsed
if (x2Index == x1Index) {
x2Index = lineObject.findStartOfColumn(x2 + 1)
}
val line = lineObject.text
var lastPrintingCharIndex = -1
val rowLineWrap = getLineWrap(row)
if (rowLineWrap && x2 == columns) {
lastPrintingCharIndex = x2Index - 1
} else {
for (i in x1Index until x2Index) {
val c = line[i]
if (c != ' ') lastPrintingCharIndex = i
}
}
val len = lastPrintingCharIndex - x1Index + 1
if (lastPrintingCharIndex != -1 && len > 0)
builder.append(line, x1Index, len)
val lineFillsWidth = lastPrintingCharIndex == x2Index - 1
if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth)
&& row < y2 && row < screenRows - 1) builder.append('\n')
}
return builder.toString()
}
fun getWordAtLocation(x: Int, y: Int): String {
var y1 = y
var y2 = y
while (y1 > 0 && !getSelectedText(0, y1 - 1, columns, y, joinBackLines = true, joinFullLines = true).contains("\n")) {
y1--
}
while (y2 < screenRows && !getSelectedText(0, y, columns, y2 + 1, joinBackLines = true, joinFullLines = true).contains("\n")) {
y2++
}
val text = getSelectedText(0, y1, columns, y2, joinBackLines = true, joinFullLines = true)
val textOffset = (y - y1) * columns + x
if (textOffset >= text.length) {
return ""
}
val x1 = text.lastIndexOf(' ', textOffset)
var x2 = text.indexOf(' ', textOffset)
if (x2 == -1) {
x2 = text.length
}
if (x1 == x2) {
return ""
}
return text.substring(x1 + 1, x2)
}
val activeRows: Int get() = activeTranscriptRows + screenRows
/**
* Convert a row value from the public external coordinate system to our internal private coordinate system.
*
* ```
* - External coordinate system: -activeTranscriptRows to screenRows-1, with the screen being 0..screenRows-1.
* - Internal coordinate system: the screenRows lines starting at screenFirstRow comprise the screen, while the
* activeTranscriptRows lines ending at screenFirstRow-1 form the transcript (as a circular buffer).
*
* External <-> Internal:
*
* [ ... ] [ ... ]
* [ -activeTranscriptRows ] [ screenFirstRow - activeTranscriptRows ]
* [ ... ] [ ... ]
* [ 0 (visible screen starts here) ] <-> [ screenFirstRow ]
* [ ... ] [ ... ]
* [ screenRows-1 ] [ screenFirstRow + screenRows-1 ]
* ```
*
* @param externalRow a row in the external coordinate system.
* @return The row corresponding to the input argument in the private coordinate system.
*/
fun externalToInternalRow(externalRow: Int): Int {
if (externalRow < -activeTranscriptRows || externalRow > screenRows)
throw IllegalArgumentException("extRow=$externalRow, screenRows=$screenRows, activeTranscriptRows=$activeTranscriptRows")
val internalRow = screenFirstRow + externalRow
return if (internalRow < 0) (totalRows + internalRow) else (internalRow % totalRows)
}
fun setLineWrap(row: Int) {
lines[externalToInternalRow(row)]!!.lineWrap = true
}
fun getLineWrap(row: Int): Boolean {
return lines[externalToInternalRow(row)]!!.lineWrap
}
fun clearLineWrap(row: Int) {
lines[externalToInternalRow(row)]!!.lineWrap = false
}
/**
* Resize the screen which this transcript backs. Currently, this only works if the number of columns does not
* change or the rows expand (that is, it only works when shrinking the number of rows).
*
* @param newColumns The number of columns the screen should have.
* @param newRows The number of rows the screen should have.
* @param cursor An int[2] containing the (column, row) cursor location.
*/
fun resize(newColumns: Int, newRows: Int, newTotalRows: Int, cursor: IntArray, currentStyle: Long, altScreen: Boolean) {
// newRows > totalRows should not normally happen since totalRows is TRANSCRIPT_ROWS (10000):
if (newColumns == columns && newRows <= totalRows) {
// Fast resize where just the rows changed.
var shiftDownOfTopRow = screenRows - newRows
if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < screenRows) {
// Shrinking. Check if we can skip blank rows at bottom below cursor.
for (i in screenRows - 1 downTo 1) {
if (cursor[1] >= i) break
val r = externalToInternalRow(i)
if (lines[r] == null || lines[r]!!.isBlank()) {
if (--shiftDownOfTopRow == 0) break
}
}
} else if (shiftDownOfTopRow < 0) {
// Negative shift down = expanding. Only move screen up if there is transcript to show:
val actualShift = maxOf(shiftDownOfTopRow, -activeTranscriptRows)
if (shiftDownOfTopRow != actualShift) {
for (i in 0 until actualShift - shiftDownOfTopRow)
allocateFullLineIfNecessary((screenFirstRow + screenRows + i) % totalRows).clear(currentStyle)
shiftDownOfTopRow = actualShift
}
}
screenFirstRow += shiftDownOfTopRow
screenFirstRow = if (screenFirstRow < 0) (screenFirstRow + totalRows) else (screenFirstRow % totalRows)
totalRows = newTotalRows
activeTranscriptRows = if (altScreen) 0 else maxOf(0, activeTranscriptRows + shiftDownOfTopRow)
cursor[1] -= shiftDownOfTopRow
screenRows = newRows
} else {
// Copy away old state and update new:
val oldLines = lines
lines = arrayOfNulls(newTotalRows)
for (i in 0 until newTotalRows)
lines[i] = TerminalRow(newColumns, currentStyle)
val oldActiveTranscriptRows = activeTranscriptRows
val oldScreenFirstRow = screenFirstRow
val oldScreenRows = screenRows
val oldTotalRows = totalRows
totalRows = newTotalRows
screenRows = newRows
activeTranscriptRows = 0
screenFirstRow = 0
columns = newColumns
var newCursorRow = -1
var newCursorColumn = -1
val oldCursorRow = cursor[1]
val oldCursorColumn = cursor[0]
var newCursorPlaced = false
var currentOutputExternalRow = 0
var currentOutputExternalColumn = 0
var skippedBlankLines = 0
for (externalOldRow in -oldActiveTranscriptRows until oldScreenRows) {
var internalOldRow = oldScreenFirstRow + externalOldRow
internalOldRow = if (internalOldRow < 0) (oldTotalRows + internalOldRow) else (internalOldRow % oldTotalRows)
val oldLine = oldLines[internalOldRow]
val cursorAtThisRow = externalOldRow == oldCursorRow
if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) {
skippedBlankLines++
continue
} else if (skippedBlankLines > 0) {
for (i in 0 until skippedBlankLines) {
if (currentOutputExternalRow == screenRows - 1) {
scrollDownOneLine(0, screenRows, currentStyle)
} else {
currentOutputExternalRow++
}
currentOutputExternalColumn = 0
}
skippedBlankLines = 0
}
var lastNonSpaceIndex = 0
var justToCursor = false
if (cursorAtThisRow || oldLine.lineWrap) {
lastNonSpaceIndex = oldLine.spaceUsed
if (cursorAtThisRow) justToCursor = true
} else {
for (i in 0 until oldLine.spaceUsed)
// NEWLY INTRODUCED BUG! Should not index oldLine.styles with char indices
if (oldLine.text[i] != ' '/* || oldLine.styles[i] != currentStyle */)
lastNonSpaceIndex = i + 1
}
var currentOldCol = 0
var styleAtCol = 0L
var i = 0
while (i < lastNonSpaceIndex) {
val c = oldLine.text[i]
val codePoint: Int
if (Character.isHighSurrogate(c)) {
i++
codePoint = Character.toCodePoint(c, oldLine.text[i])
} else {
codePoint = c.code
}
val displayWidth = WcWidth.width(codePoint)
if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol)
if (currentOutputExternalColumn + displayWidth > columns) {
setLineWrap(currentOutputExternalRow)
if (currentOutputExternalRow == screenRows - 1) {
if (newCursorPlaced) newCursorRow--
scrollDownOneLine(0, screenRows, currentStyle)
} else {
currentOutputExternalRow++
}
currentOutputExternalColumn = 0
}
val offsetDueToCombiningChar = if (displayWidth <= 0 && currentOutputExternalColumn > 0) 1 else 0
val outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar
setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol)
if (displayWidth > 0) {
if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) {
newCursorColumn = currentOutputExternalColumn
newCursorRow = currentOutputExternalRow
newCursorPlaced = true
}
currentOldCol += displayWidth
currentOutputExternalColumn += displayWidth
if (justToCursor && newCursorPlaced) break
}
i++
}
if (externalOldRow != (oldScreenRows - 1) && !oldLine.lineWrap) {
if (currentOutputExternalRow == screenRows - 1) {
if (newCursorPlaced) newCursorRow--
scrollDownOneLine(0, screenRows, currentStyle)
} else {
currentOutputExternalRow++
}
currentOutputExternalColumn = 0
}
}
cursor[0] = newCursorColumn
cursor[1] = newCursorRow
}
// Handle cursor scrolling off screen:
if (cursor[0] < 0 || cursor[1] < 0) {
cursor[0] = 0
cursor[1] = 0
}
}
/**
* Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound
* into account.
*
* @param srcInternal The first line to be copied.
* @param len The number of lines to be copied.
*/
private fun blockCopyLinesDown(srcInternal: Int, len: Int) {
if (len == 0) return
val start = len - 1
val lineToBeOverWritten = lines[(srcInternal + start + 1) % totalRows]
for (i in start downTo 0)
lines[(srcInternal + i + 1) % totalRows] = lines[(srcInternal + i) % totalRows]
lines[srcInternal % totalRows] = lineToBeOverWritten
}
/**
* Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24).
*
* @param topMargin First line that is scrolled.
* @param bottomMargin One line after the last line that is scrolled.
* @param style the style for the newly exposed line.
*/
fun scrollDownOneLine(topMargin: Int, bottomMargin: Int, style: Long) {
if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > screenRows)
throw IllegalArgumentException("topMargin=$topMargin, bottomMargin=$bottomMargin, screenRows=$screenRows")
blockCopyLinesDown(screenFirstRow, topMargin)
blockCopyLinesDown(externalToInternalRow(bottomMargin), screenRows - bottomMargin)
screenFirstRow = (screenFirstRow + 1) % totalRows
if (activeTranscriptRows < totalRows - screenRows) activeTranscriptRows++
val blankRow = externalToInternalRow(bottomMargin - 1)
if (lines[blankRow] == null) {
lines[blankRow] = TerminalRow(columns, style)
} else {
lines[blankRow]!!.clear(style)
}
}
/**
* Block copy characters from one position in the screen to another. The two positions can overlap. All characters
* of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will
* be thrown.
*
* @param sx source X coordinate
* @param sy source Y coordinate
* @param w width
* @param h height
* @param dx destination X coordinate
* @param dy destination Y coordinate
*/
fun blockCopy(sx: Int, sy: Int, w: Int, h: Int, dx: Int, dy: Int) {
if (w == 0) return
if (sx < 0 || sx + w > columns || sy < 0 || sy + h > screenRows || dx < 0 || dx + w > columns || dy < 0 || dy + h > screenRows)
throw IllegalArgumentException()
val copyingUp = sy > dy
for (y in 0 until h) {
val y2 = if (copyingUp) y else (h - (y + 1))
val sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2))
allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx)
}
}
/**
* Block set characters. All characters must be within the bounds of the screen, or else an
* InvalidParameterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block
* of characters.
*/
fun blockSet(sx: Int, sy: Int, w: Int, h: Int, `val`: Int, style: Long) {
if (sx < 0 || sx + w > columns || sy < 0 || sy + h > screenRows) {
throw IllegalArgumentException(
"Illegal arguments! blockSet($sx, $sy, $w, $h, $`val`, $columns, $screenRows)")
}
for (y in 0 until h)
for (x in 0 until w)
setChar(sx + x, sy + y, `val`, style)
}
fun allocateFullLineIfNecessary(row: Int): TerminalRow {
return lines[row] ?: TerminalRow(columns, 0).also { lines[row] = it }
}
fun setChar(column: Int, row: Int, codePoint: Int, style: Long) {
if (row < 0 || row >= screenRows || column < 0 || column >= columns)
throw IllegalArgumentException("TerminalBuffer.setChar(): row=$row, column=$column, screenRows=$screenRows, columns=$columns")
val internalRow = externalToInternalRow(row)
allocateFullLineIfNecessary(internalRow).setChar(column, codePoint, style)
}
fun getStyleAt(externalRow: Int, column: Int): Long {
return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column)
}
/** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */
fun setOrClearEffect(bits: Int, setOrClear: Boolean, reverse: Boolean, rectangular: Boolean, leftMargin: Int, rightMargin: Int, top: Int, left: Int,
bottom: Int, right: Int) {
for (y in top until bottom) {
val line = lines[externalToInternalRow(y)]!!
val startOfLine = if (rectangular || y == top) left else leftMargin
val endOfLine = if (rectangular || y + 1 == bottom) right else rightMargin
for (x in startOfLine until endOfLine) {
val currentStyle = line.getStyle(x)
val foreColor = TextStyle.decodeForeColor(currentStyle)
val backColor = TextStyle.decodeBackColor(currentStyle)
var effect = TextStyle.decodeEffect(currentStyle)
if (reverse) {
effect = (effect and bits.inv()) or (bits and effect.inv())
} else if (setOrClear) {
effect = effect or bits
} else {
effect = effect and bits.inv()
}
line.styles[x] = TextStyle.encode(foreColor, backColor, effect)
}
}
}
fun clearTranscript() {
if (screenFirstRow < activeTranscriptRows) {
Arrays.fill(lines, totalRows + screenFirstRow - activeTranscriptRows, totalRows, null)
Arrays.fill(lines, 0, screenFirstRow, null)
} else {
Arrays.fill(lines, screenFirstRow - activeTranscriptRows, screenFirstRow, null)
}
activeTranscriptRows = 0
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
package com.topjohnwu.magisk.terminal
import android.os.Handler
import android.os.Looper
import com.topjohnwu.superuser.Shell
import timber.log.Timber
private val busyboxPath: String by lazy {
Shell.cmd("readlink /proc/self/exe").exec().out.firstOrNull()
?: "/data/adb/magisk/busybox"
}
private val mainHandler = Handler(Looper.getMainLooper())
fun TerminalEmulator.appendOnMain(bytes: ByteArray, len: Int) {
mainHandler.post {
append(bytes, len)
onScreenUpdate?.invoke()
}
}
fun TerminalEmulator.appendLineOnMain(line: String) {
val bytes = "$line\r\n".toByteArray(Charsets.UTF_8)
appendOnMain(bytes, bytes.size)
}
/**
* Run a command as root inside a PTY (via busybox script).
* Reads raw bytes from the process and feeds them to the terminal emulator.
* Must be called from a background thread.
* Returns true if the process exits with code 0.
*/
fun runSuCommand(emulator: TerminalEmulator, command: String): Boolean {
return try {
val cols = emulator.mColumns
val rows = emulator.mRows
val wrappedCmd = "export TERM=xterm-256color; stty cols $cols rows $rows 2>/dev/null; $command"
val escapedCmd = wrappedCmd.replace("'", "'\\''")
val process = ProcessBuilder(
"su", "-c",
"$busyboxPath script -q -c '$escapedCmd' /dev/null"
).redirectErrorStream(true).start()
process.outputStream.close()
val buffer = ByteArray(4096)
process.inputStream.use { input ->
while (true) {
val n = input.read(buffer)
if (n == -1) break
emulator.appendOnMain(buffer.copyOf(n), n)
}
}
process.waitFor() == 0
} catch (e: Exception) {
Timber.e(e, "runSuCommand failed")
emulator.appendLineOnMain("! Error: ${e.message}")
false
}
}

View File

@@ -0,0 +1,267 @@
package com.topjohnwu.magisk.terminal
import java.util.Arrays
/**
* A row in a terminal, composed of a fixed number of cells.
*
* The text in the row is stored in a char[] array, [text], for quick access during rendering.
*/
class TerminalRow(private val columns: Int, style: Long) {
/**
* Max combining characters that can exist in a column, that are separate from the base character
* itself. Any additional combining characters will be ignored and not added to the column.
*
* There does not seem to be limit in unicode standard for max number of combination characters
* that can be combined but such characters are primarily under 10.
*
* "Section 3.6 Combination" of unicode standard contains combining characters info.
* - https://www.unicode.org/versions/Unicode15.0.0/ch03.pdf
* - https://en.wikipedia.org/wiki/Combining_character#Unicode_ranges
* - https://stackoverflow.com/questions/71237212/what-is-the-maximum-number-of-unicode-combined-characters-that-may-be-needed-to
*
* UAX15-D3 Stream-Safe Text Format limits to max 30 combining characters.
* > The value of 30 is chosen to be significantly beyond what is required for any linguistic or technical usage.
* > While it would have been feasible to chose a smaller number, this value provides a very wide margin,
* > yet is well within the buffer size limits of practical implementations.
* - https://unicode.org/reports/tr15/#Stream_Safe_Text_Format
* - https://stackoverflow.com/a/11983435/14686958
*
* We choose the value 15 because it should be enough for terminal based applications and keep
* the memory usage low for a terminal row, won't affect performance or cause terminal to
* lag or hang, and will keep malicious applications from causing harm. The value can be
* increased if ever needed for legitimate applications.
*/
companion object {
private const val SPARE_CAPACITY_FACTOR = 1.5f
private const val MAX_COMBINING_CHARACTERS_PER_COLUMN = 15
}
/** The text filling this terminal row. */
var text: CharArray = CharArray((SPARE_CAPACITY_FACTOR * columns).toInt())
/** The number of java chars used in [text]. */
private var _spaceUsed: Short = 0
/** If this row has been line wrapped due to text output at the end of line. */
var lineWrap: Boolean = false
/** The style bits of each cell in the row. See [TextStyle]. */
val styles: LongArray = LongArray(columns)
/** If this row might contain chars with width != 1, used for deactivating fast path */
var hasNonOneWidthOrSurrogateChars: Boolean = false
init {
clear(style)
}
/** NOTE: The sourceX2 is exclusive. */
fun copyInterval(line: TerminalRow, sourceX1: Int, sourceX2: Int, destinationX: Int) {
hasNonOneWidthOrSurrogateChars = hasNonOneWidthOrSurrogateChars or line.hasNonOneWidthOrSurrogateChars
val x1 = line.findStartOfColumn(sourceX1)
val x2 = line.findStartOfColumn(sourceX2)
var startingFromSecondHalfOfWideChar = sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1)
val sourceChars = if (this === line) line.text.copyOf() else line.text
var latestNonCombiningWidth = 0
var destX = destinationX
var srcX1 = sourceX1
var i = x1
while (i < x2) {
val sourceChar = sourceChars[i]
var codePoint: Int
if (Character.isHighSurrogate(sourceChar)) {
i++
codePoint = Character.toCodePoint(sourceChar, sourceChars[i])
} else {
codePoint = sourceChar.code
}
if (startingFromSecondHalfOfWideChar) {
codePoint = ' '.code
startingFromSecondHalfOfWideChar = false
}
val w = WcWidth.width(codePoint)
if (w > 0) {
destX += latestNonCombiningWidth
srcX1 += latestNonCombiningWidth
latestNonCombiningWidth = w
}
setChar(destX, codePoint, line.getStyle(srcX1))
i++
}
}
val spaceUsed: Int get() = _spaceUsed.toInt()
/** Note that the column may end of second half of wide character. */
fun findStartOfColumn(column: Int): Int {
if (column == columns) return spaceUsed
var currentColumn = 0
var currentCharIndex = 0
while (true) {
var newCharIndex = currentCharIndex
val c = text[newCharIndex++]
val isHigh = Character.isHighSurrogate(c)
val codePoint = if (isHigh) Character.toCodePoint(c, text[newCharIndex++]) else c.code
val wcwidth = WcWidth.width(codePoint)
if (wcwidth > 0) {
currentColumn += wcwidth
if (currentColumn == column) {
while (newCharIndex < _spaceUsed) {
if (Character.isHighSurrogate(text[newCharIndex])) {
if (WcWidth.width(Character.toCodePoint(text[newCharIndex], text[newCharIndex + 1])) <= 0) {
newCharIndex += 2
} else {
break
}
} else if (WcWidth.width(text[newCharIndex].code) <= 0) {
newCharIndex++
} else {
break
}
}
return newCharIndex
} else if (currentColumn > column) {
return currentCharIndex
}
}
currentCharIndex = newCharIndex
}
}
private fun wideDisplayCharacterStartingAt(column: Int): Boolean {
var currentCharIndex = 0
var currentColumn = 0
while (currentCharIndex < _spaceUsed) {
val c = text[currentCharIndex++]
val codePoint = if (Character.isHighSurrogate(c)) Character.toCodePoint(c, text[currentCharIndex++]) else c.code
val wcwidth = WcWidth.width(codePoint)
if (wcwidth > 0) {
if (currentColumn == column && wcwidth == 2) return true
currentColumn += wcwidth
if (currentColumn > column) return false
}
}
return false
}
fun clear(style: Long) {
Arrays.fill(text, ' ')
Arrays.fill(styles, style)
_spaceUsed = columns.toShort()
hasNonOneWidthOrSurrogateChars = false
}
// https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26
fun setChar(columnToSet: Int, codePoint: Int, style: Long) {
if (columnToSet < 0 || columnToSet >= styles.size)
throw IllegalArgumentException("TerminalRow.setChar(): columnToSet=$columnToSet, codePoint=$codePoint, style=$style")
styles[columnToSet] = style
val newCodePointDisplayWidth = WcWidth.width(codePoint)
// Fast path when we don't have any chars with width != 1
if (!hasNonOneWidthOrSurrogateChars) {
if (codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT || newCodePointDisplayWidth != 1) {
hasNonOneWidthOrSurrogateChars = true
} else {
text[columnToSet] = codePoint.toChar()
return
}
}
val newIsCombining = newCodePointDisplayWidth <= 0
val wasExtraColForWideChar = columnToSet > 0 && wideDisplayCharacterStartingAt(columnToSet - 1)
var col = columnToSet
if (newIsCombining) {
if (wasExtraColForWideChar) col--
} else {
if (wasExtraColForWideChar) setChar(col - 1, ' '.code, style)
val overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(col + 1)
if (overwritingWideCharInNextColumn) setChar(col + 1, ' '.code, style)
}
var textArray = text
val oldStartOfColumnIndex = findStartOfColumn(col)
val oldCodePointDisplayWidth = WcWidth.width(textArray, oldStartOfColumnIndex)
val oldCharactersUsedForColumn: Int
if (col + oldCodePointDisplayWidth < columns) {
val oldEndOfColumnIndex = findStartOfColumn(col + oldCodePointDisplayWidth)
oldCharactersUsedForColumn = oldEndOfColumnIndex - oldStartOfColumnIndex
} else {
oldCharactersUsedForColumn = _spaceUsed - oldStartOfColumnIndex
}
if (newIsCombining) {
val combiningCharsCount = WcWidth.zeroWidthCharsCount(textArray, oldStartOfColumnIndex, oldStartOfColumnIndex + oldCharactersUsedForColumn)
if (combiningCharsCount >= MAX_COMBINING_CHARACTERS_PER_COLUMN)
return
}
var newCharactersUsedForColumn = Character.charCount(codePoint)
if (newIsCombining) {
newCharactersUsedForColumn += oldCharactersUsedForColumn
}
val oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn
val newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn
val javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn
if (javaCharDifference > 0) {
val oldCharactersAfterColumn = _spaceUsed - oldNextColumnIndex
if (_spaceUsed + javaCharDifference > textArray.size) {
val newText = CharArray(textArray.size + columns)
System.arraycopy(textArray, 0, newText, 0, oldNextColumnIndex)
System.arraycopy(textArray, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn)
text = newText
textArray = newText
} else {
System.arraycopy(textArray, oldNextColumnIndex, textArray, newNextColumnIndex, oldCharactersAfterColumn)
}
} else if (javaCharDifference < 0) {
System.arraycopy(textArray, oldNextColumnIndex, textArray, newNextColumnIndex, _spaceUsed - oldNextColumnIndex)
}
_spaceUsed = (_spaceUsed + javaCharDifference).toShort()
Character.toChars(codePoint, textArray, oldStartOfColumnIndex + if (newIsCombining) oldCharactersUsedForColumn else 0)
if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) {
if (_spaceUsed + 1 > textArray.size) {
val newText = CharArray(textArray.size + columns)
System.arraycopy(textArray, 0, newText, 0, newNextColumnIndex)
System.arraycopy(textArray, newNextColumnIndex, newText, newNextColumnIndex + 1, _spaceUsed - newNextColumnIndex)
text = newText
textArray = newText
} else {
System.arraycopy(textArray, newNextColumnIndex, textArray, newNextColumnIndex + 1, _spaceUsed - newNextColumnIndex)
}
textArray[newNextColumnIndex] = ' '
++_spaceUsed
} else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) {
if (col == columns - 1) {
throw IllegalArgumentException("Cannot put wide character in last column")
} else if (col == columns - 2) {
_spaceUsed = newNextColumnIndex.toShort()
} else {
val newNextNextColumnIndex = newNextColumnIndex + if (Character.isHighSurrogate(textArray[newNextColumnIndex])) 2 else 1
val nextLen = newNextNextColumnIndex - newNextColumnIndex
System.arraycopy(textArray, newNextNextColumnIndex, textArray, newNextColumnIndex, _spaceUsed - newNextNextColumnIndex)
_spaceUsed = (_spaceUsed - nextLen).toShort()
}
}
}
internal fun isBlank(): Boolean {
for (charIndex in 0 until spaceUsed)
if (text[charIndex] != ' ') return false
return true
}
fun getStyle(column: Int): Long = styles[column]
}

View File

@@ -0,0 +1,246 @@
package com.topjohnwu.magisk.terminal
import android.graphics.Color
import java.util.Properties
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.sqrt
object TextStyle {
const val CHARACTER_ATTRIBUTE_BOLD = 1
const val CHARACTER_ATTRIBUTE_ITALIC = 1 shl 1
const val CHARACTER_ATTRIBUTE_UNDERLINE = 1 shl 2
const val CHARACTER_ATTRIBUTE_BLINK = 1 shl 3
const val CHARACTER_ATTRIBUTE_INVERSE = 1 shl 4
const val CHARACTER_ATTRIBUTE_INVISIBLE = 1 shl 5
const val CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 shl 6
const val CHARACTER_ATTRIBUTE_PROTECTED = 1 shl 7
const val CHARACTER_ATTRIBUTE_DIM = 1 shl 8
private const val CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 shl 9
private const val CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND = 1 shl 10
const val COLOR_INDEX_FOREGROUND = 256
const val COLOR_INDEX_BACKGROUND = 257
const val COLOR_INDEX_CURSOR = 258
const val NUM_INDEXED_COLORS = 259
val NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0)
fun encode(foreColor: Int, backColor: Int, effect: Int): Long {
var result = (effect and 0b111111111).toLong()
if (foreColor and 0xff000000.toInt() == 0xff000000.toInt()) {
result = result or CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND.toLong() or ((foreColor.toLong() and 0x00ffffffL) shl 40)
} else {
result = result or ((foreColor.toLong() and 0b111111111L) shl 40)
}
if (backColor and 0xff000000.toInt() == 0xff000000.toInt()) {
result = result or CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND.toLong() or ((backColor.toLong() and 0x00ffffffL) shl 16)
} else {
result = result or ((backColor.toLong() and 0b111111111L) shl 16)
}
return result
}
fun decodeForeColor(style: Long): Int {
return if (style and CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND.toLong() == 0L) {
((style ushr 40) and 0b111111111L).toInt()
} else {
0xff000000.toInt() or ((style ushr 40) and 0x00ffffffL).toInt()
}
}
fun decodeBackColor(style: Long): Int {
return if (style and CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND.toLong() == 0L) {
((style ushr 16) and 0b111111111L).toInt()
} else {
0xff000000.toInt() or ((style ushr 16) and 0x00ffffffL).toInt()
}
}
fun decodeEffect(style: Long): Int {
return (style and 0b11111111111L).toInt()
}
}
/**
* Color scheme for a terminal with default colors, which may be overridden (and then reset) from the shell using
* Operating System Control (OSC) sequences.
*/
class TerminalColorScheme {
val defaultColors: IntArray = IntArray(TextStyle.NUM_INDEXED_COLORS)
init {
reset()
}
fun updateWith(props: Properties) {
reset()
var cursorPropExists = false
for ((keyObj, valueObj) in props) {
val key = keyObj as String
val value = valueObj as String
val colorIndex: Int = when {
key == "foreground" -> TextStyle.COLOR_INDEX_FOREGROUND
key == "background" -> TextStyle.COLOR_INDEX_BACKGROUND
key == "cursor" -> {
cursorPropExists = true
TextStyle.COLOR_INDEX_CURSOR
}
key.startsWith("color") -> {
try {
key.substring(5).toInt()
} catch (_: NumberFormatException) {
throw IllegalArgumentException("Invalid property: '$key'")
}
}
else -> throw IllegalArgumentException("Invalid property: '$key'")
}
val colorValue = TerminalColors.parse(value)
if (colorValue == 0) {
throw IllegalArgumentException("Property '$key' has invalid color: '$value'")
}
defaultColors[colorIndex] = colorValue
}
if (!cursorPropExists) {
setCursorColorForBackground()
}
}
fun setCursorColorForBackground() {
val backgroundColor = defaultColors[TextStyle.COLOR_INDEX_BACKGROUND]
val brightness = TerminalColors.perceivedBrightness(backgroundColor)
if (brightness > 0) {
defaultColors[TextStyle.COLOR_INDEX_CURSOR] = if (brightness < 130) {
0xffffffff.toInt()
} else {
0xff000000.toInt()
}
}
}
private fun reset() {
System.arraycopy(DEFAULT_COLORSCHEME, 0, defaultColors, 0, TextStyle.NUM_INDEXED_COLORS)
}
companion object {
private val DEFAULT_COLORSCHEME = longArrayOf(
// 16 original colors. First 8 are dim.
0xff000000, // black
0xffcd0000, // dim red
0xff00cd00, // dim green
0xffcdcd00, // dim yellow
0xff6495ed, // dim blue
0xffcd00cd, // dim magenta
0xff00cdcd, // dim cyan
0xffe5e5e5, // dim white
// Second 8 are bright:
0xff7f7f7f, // medium grey
0xffff0000, // bright red
0xff00ff00, // bright green
0xffffff00, // bright yellow
0xff5c5cff, // light blue
0xffff00ff, // bright magenta
0xff00ffff, // bright cyan
0xffffffffL, // bright white
// 216 color cube, six shades of each color:
0xff000000, 0xff00005f, 0xff000087, 0xff0000af, 0xff0000d7, 0xff0000ff, 0xff005f00, 0xff005f5f, 0xff005f87, 0xff005faf, 0xff005fd7, 0xff005fff,
0xff008700, 0xff00875f, 0xff008787, 0xff0087af, 0xff0087d7, 0xff0087ff, 0xff00af00, 0xff00af5f, 0xff00af87, 0xff00afaf, 0xff00afd7, 0xff00afff,
0xff00d700, 0xff00d75f, 0xff00d787, 0xff00d7af, 0xff00d7d7, 0xff00d7ff, 0xff00ff00, 0xff00ff5f, 0xff00ff87, 0xff00ffaf, 0xff00ffd7, 0xff00ffff,
0xff5f0000, 0xff5f005f, 0xff5f0087, 0xff5f00af, 0xff5f00d7, 0xff5f00ff, 0xff5f5f00, 0xff5f5f5f, 0xff5f5f87, 0xff5f5faf, 0xff5f5fd7, 0xff5f5fff,
0xff5f8700, 0xff5f875f, 0xff5f8787, 0xff5f87af, 0xff5f87d7, 0xff5f87ff, 0xff5faf00, 0xff5faf5f, 0xff5faf87, 0xff5fafaf, 0xff5fafd7, 0xff5fafff,
0xff5fd700, 0xff5fd75f, 0xff5fd787, 0xff5fd7af, 0xff5fd7d7, 0xff5fd7ff, 0xff5fff00, 0xff5fff5f, 0xff5fff87, 0xff5fffaf, 0xff5fffd7, 0xff5fffff,
0xff870000, 0xff87005f, 0xff870087, 0xff8700af, 0xff8700d7, 0xff8700ff, 0xff875f00, 0xff875f5f, 0xff875f87, 0xff875faf, 0xff875fd7, 0xff875fff,
0xff878700, 0xff87875f, 0xff878787, 0xff8787af, 0xff8787d7, 0xff8787ff, 0xff87af00, 0xff87af5f, 0xff87af87, 0xff87afaf, 0xff87afd7, 0xff87afff,
0xff87d700, 0xff87d75f, 0xff87d787, 0xff87d7af, 0xff87d7d7, 0xff87d7ff, 0xff87ff00, 0xff87ff5f, 0xff87ff87, 0xff87ffaf, 0xff87ffd7, 0xff87ffff,
0xffaf0000, 0xffaf005f, 0xffaf0087, 0xffaf00af, 0xffaf00d7, 0xffaf00ff, 0xffaf5f00, 0xffaf5f5f, 0xffaf5f87, 0xffaf5faf, 0xffaf5fd7, 0xffaf5fff,
0xffaf8700, 0xffaf875f, 0xffaf8787, 0xffaf87af, 0xffaf87d7, 0xffaf87ff, 0xffafaf00, 0xffafaf5f, 0xffafaf87, 0xffafafaf, 0xffafafd7, 0xffafafff,
0xffafd700, 0xffafd75f, 0xffafd787, 0xffafd7af, 0xffafd7d7, 0xffafd7ff, 0xffafff00, 0xffafff5f, 0xffafff87, 0xffafffaf, 0xffafffd7, 0xffafffff,
0xffd70000, 0xffd7005f, 0xffd70087, 0xffd700af, 0xffd700d7, 0xffd700ff, 0xffd75f00, 0xffd75f5f, 0xffd75f87, 0xffd75faf, 0xffd75fd7, 0xffd75fff,
0xffd78700, 0xffd7875f, 0xffd78787, 0xffd787af, 0xffd787d7, 0xffd787ff, 0xffd7af00, 0xffd7af5f, 0xffd7af87, 0xffd7afaf, 0xffd7afd7, 0xffd7afff,
0xffd7d700, 0xffd7d75f, 0xffd7d787, 0xffd7d7af, 0xffd7d7d7, 0xffd7d7ff, 0xffd7ff00, 0xffd7ff5f, 0xffd7ff87, 0xffd7ffaf, 0xffd7ffd7, 0xffd7ffff,
0xffff0000, 0xffff005f, 0xffff0087, 0xffff00af, 0xffff00d7, 0xffff00ff, 0xffff5f00, 0xffff5f5f, 0xffff5f87, 0xffff5faf, 0xffff5fd7, 0xffff5fff,
0xffff8700, 0xffff875f, 0xffff8787, 0xffff87af, 0xffff87d7, 0xffff87ff, 0xffffaf00, 0xffffaf5f, 0xffffaf87, 0xffffafaf, 0xffffafd7, 0xffffafff,
0xffffd700, 0xffffd75f, 0xffffd787, 0xffffd7af, 0xffffd7d7, 0xffffd7ff, 0xffffff00, 0xffffff5f, 0xffffff87, 0xffffffaf, 0xffffffd7, 0xffffffffL,
// 24 grey scale ramp:
0xff080808, 0xff121212, 0xff1c1c1c, 0xff262626, 0xff303030, 0xff3a3a3a, 0xff444444, 0xff4e4e4e, 0xff585858, 0xff626262, 0xff6c6c6c, 0xff767676,
0xff808080, 0xff8a8a8a, 0xff949494, 0xff9e9e9e, 0xffa8a8a8, 0xffb2b2b2, 0xffbcbcbc, 0xffc6c6c6, 0xffd0d0d0, 0xffdadada, 0xffe4e4e4, 0xffeeeeee,
// COLOR_INDEX_DEFAULT_FOREGROUND, COLOR_INDEX_DEFAULT_BACKGROUND and COLOR_INDEX_DEFAULT_CURSOR:
0xffffffffL, 0xff000000L, 0xffffffffL
).map { it.toInt() }.toIntArray()
}
}
/** Current terminal colors (if different from default). */
class TerminalColors {
val currentColors: IntArray = IntArray(TextStyle.NUM_INDEXED_COLORS)
init {
reset()
}
fun reset(index: Int) {
currentColors[index] = COLOR_SCHEME.defaultColors[index]
}
fun reset() {
System.arraycopy(COLOR_SCHEME.defaultColors, 0, currentColors, 0, TextStyle.NUM_INDEXED_COLORS)
}
fun tryParseColor(intoIndex: Int, textParameter: String) {
val c = parse(textParameter)
if (c != 0) currentColors[intoIndex] = c
}
companion object {
val COLOR_SCHEME = TerminalColorScheme()
internal fun parse(c: String): Int {
return try {
val (skipInitial, skipBetween) = when {
c[0] == '#' -> 1 to 0
c.startsWith("rgb:") -> 4 to 1
else -> return 0
}
val charsForColors = c.length - skipInitial - 2 * skipBetween
if (charsForColors % 3 != 0) return 0
val componentLength = charsForColors / 3
val mult = 255.0 / (2.0.pow(componentLength * 4) - 1)
var currentPosition = skipInitial
val rString = c.substring(currentPosition, currentPosition + componentLength)
currentPosition += componentLength + skipBetween
val gString = c.substring(currentPosition, currentPosition + componentLength)
currentPosition += componentLength + skipBetween
val bString = c.substring(currentPosition, currentPosition + componentLength)
val r = (rString.toInt(16) * mult).toInt()
val g = (gString.toInt(16) * mult).toInt()
val b = (bString.toInt(16) * mult).toInt()
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
} catch (_: NumberFormatException) {
0
} catch (_: IndexOutOfBoundsException) {
0
}
}
fun perceivedBrightness(color: Int): Int {
return floor(
sqrt(
Color.red(color).toDouble().pow(2) * 0.241 +
Color.green(color).toDouble().pow(2) * 0.691 +
Color.blue(color).toDouble().pow(2) * 0.068
)
).toInt()
}
}
}

View File

@@ -0,0 +1,559 @@
package com.topjohnwu.magisk.terminal
/**
* Implementation of wcwidth(3) for Unicode 15.
*
* Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters.
*
* IMPORTANT:
* Must be kept in sync with the following:
* https://github.com/termux/wcwidth
* https://github.com/termux/libandroid-support
* https://github.com/termux/termux-packages/tree/master/packages/libandroid-support
*/
object WcWidth {
// From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py
// from https://github.com/jquast/wcwidth/pull/64
// at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
private val ZERO_WIDTH = arrayOf(
intArrayOf(0x00300, 0x0036f), // Combining Grave Accent ..Combining Latin Small Le
intArrayOf(0x00483, 0x00489), // Combining Cyrillic Titlo..Combining Cyrillic Milli
intArrayOf(0x00591, 0x005bd), // Hebrew Accent Etnahta ..Hebrew Point Meteg
intArrayOf(0x005bf, 0x005bf), // Hebrew Point Rafe ..Hebrew Point Rafe
intArrayOf(0x005c1, 0x005c2), // Hebrew Point Shin Dot ..Hebrew Point Sin Dot
intArrayOf(0x005c4, 0x005c5), // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot
intArrayOf(0x005c7, 0x005c7), // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata
intArrayOf(0x00610, 0x0061a), // Arabic Sign Sallallahou ..Arabic Small Kasra
intArrayOf(0x0064b, 0x0065f), // Arabic Fathatan ..Arabic Wavy Hamza Below
intArrayOf(0x00670, 0x00670), // Arabic Letter Superscrip..Arabic Letter Superscrip
intArrayOf(0x006d6, 0x006dc), // Arabic Small High Ligatu..Arabic Small High Seen
intArrayOf(0x006df, 0x006e4), // Arabic Small High Rounde..Arabic Small High Madda
intArrayOf(0x006e7, 0x006e8), // Arabic Small High Yeh ..Arabic Small High Noon
intArrayOf(0x006ea, 0x006ed), // Arabic Empty Centre Low ..Arabic Small Low Meem
intArrayOf(0x00711, 0x00711), // Syriac Letter Superscrip..Syriac Letter Superscrip
intArrayOf(0x00730, 0x0074a), // Syriac Pthaha Above ..Syriac Barrekh
intArrayOf(0x007a6, 0x007b0), // Thaana Abafili ..Thaana Sukun
intArrayOf(0x007eb, 0x007f3), // Nko Combining Short High..Nko Combining Double Dot
intArrayOf(0x007fd, 0x007fd), // Nko Dantayalan ..Nko Dantayalan
intArrayOf(0x00816, 0x00819), // Samaritan Mark In ..Samaritan Mark Dagesh
intArrayOf(0x0081b, 0x00823), // Samaritan Mark Epentheti..Samaritan Vowel Sign A
intArrayOf(0x00825, 0x00827), // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U
intArrayOf(0x00829, 0x0082d), // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa
intArrayOf(0x00859, 0x0085b), // Mandaic Affrication Mark..Mandaic Gemination Mark
intArrayOf(0x00898, 0x0089f), // Arabic Small High Word A..Arabic Half Madda Over M
intArrayOf(0x008ca, 0x008e1), // Arabic Small High Farsi ..Arabic Small High Sign S
intArrayOf(0x008e3, 0x00902), // Arabic Turned Damma Belo..Devanagari Sign Anusvara
intArrayOf(0x0093a, 0x0093a), // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe
intArrayOf(0x0093c, 0x0093c), // Devanagari Sign Nukta ..Devanagari Sign Nukta
intArrayOf(0x00941, 0x00948), // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai
intArrayOf(0x0094d, 0x0094d), // Devanagari Sign Virama ..Devanagari Sign Virama
intArrayOf(0x00951, 0x00957), // Devanagari Stress Sign U..Devanagari Vowel Sign Uu
intArrayOf(0x00962, 0x00963), // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo
intArrayOf(0x00981, 0x00981), // Bengali Sign Candrabindu..Bengali Sign Candrabindu
intArrayOf(0x009bc, 0x009bc), // Bengali Sign Nukta ..Bengali Sign Nukta
intArrayOf(0x009c1, 0x009c4), // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal
intArrayOf(0x009cd, 0x009cd), // Bengali Sign Virama ..Bengali Sign Virama
intArrayOf(0x009e2, 0x009e3), // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal
intArrayOf(0x009fe, 0x009fe), // Bengali Sandhi Mark ..Bengali Sandhi Mark
intArrayOf(0x00a01, 0x00a02), // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi
intArrayOf(0x00a3c, 0x00a3c), // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta
intArrayOf(0x00a41, 0x00a42), // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu
intArrayOf(0x00a47, 0x00a48), // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai
intArrayOf(0x00a4b, 0x00a4d), // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama
intArrayOf(0x00a51, 0x00a51), // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat
intArrayOf(0x00a70, 0x00a71), // Gurmukhi Tippi ..Gurmukhi Addak
intArrayOf(0x00a75, 0x00a75), // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash
intArrayOf(0x00a81, 0x00a82), // Gujarati Sign Candrabind..Gujarati Sign Anusvara
intArrayOf(0x00abc, 0x00abc), // Gujarati Sign Nukta ..Gujarati Sign Nukta
intArrayOf(0x00ac1, 0x00ac5), // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand
intArrayOf(0x00ac7, 0x00ac8), // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai
intArrayOf(0x00acd, 0x00acd), // Gujarati Sign Virama ..Gujarati Sign Virama
intArrayOf(0x00ae2, 0x00ae3), // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca
intArrayOf(0x00afa, 0x00aff), // Gujarati Sign Sukun ..Gujarati Sign Two-circle
intArrayOf(0x00b01, 0x00b01), // Oriya Sign Candrabindu ..Oriya Sign Candrabindu
intArrayOf(0x00b3c, 0x00b3c), // Oriya Sign Nukta ..Oriya Sign Nukta
intArrayOf(0x00b3f, 0x00b3f), // Oriya Vowel Sign I ..Oriya Vowel Sign I
intArrayOf(0x00b41, 0x00b44), // Oriya Vowel Sign U ..Oriya Vowel Sign Vocalic
intArrayOf(0x00b4d, 0x00b4d), // Oriya Sign Virama ..Oriya Sign Virama
intArrayOf(0x00b55, 0x00b56), // Oriya Sign Overline ..Oriya Ai Length Mark
intArrayOf(0x00b62, 0x00b63), // Oriya Vowel Sign Vocalic..Oriya Vowel Sign Vocalic
intArrayOf(0x00b82, 0x00b82), // Tamil Sign Anusvara ..Tamil Sign Anusvara
intArrayOf(0x00bc0, 0x00bc0), // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii
intArrayOf(0x00bcd, 0x00bcd), // Tamil Sign Virama ..Tamil Sign Virama
intArrayOf(0x00c00, 0x00c00), // Telugu Sign Combining Ca..Telugu Sign Combining Ca
intArrayOf(0x00c04, 0x00c04), // Telugu Sign Combining An..Telugu Sign Combining An
intArrayOf(0x00c3c, 0x00c3c), // Telugu Sign Nukta ..Telugu Sign Nukta
intArrayOf(0x00c3e, 0x00c40), // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii
intArrayOf(0x00c46, 0x00c48), // Telugu Vowel Sign E ..Telugu Vowel Sign Ai
intArrayOf(0x00c4a, 0x00c4d), // Telugu Vowel Sign O ..Telugu Sign Virama
intArrayOf(0x00c55, 0x00c56), // Telugu Length Mark ..Telugu Ai Length Mark
intArrayOf(0x00c62, 0x00c63), // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali
intArrayOf(0x00c81, 0x00c81), // Kannada Sign Candrabindu..Kannada Sign Candrabindu
intArrayOf(0x00cbc, 0x00cbc), // Kannada Sign Nukta ..Kannada Sign Nukta
intArrayOf(0x00cbf, 0x00cbf), // Kannada Vowel Sign I ..Kannada Vowel Sign I
intArrayOf(0x00cc6, 0x00cc6), // Kannada Vowel Sign E ..Kannada Vowel Sign E
intArrayOf(0x00ccc, 0x00ccd), // Kannada Vowel Sign Au ..Kannada Sign Virama
intArrayOf(0x00ce2, 0x00ce3), // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal
intArrayOf(0x00d00, 0x00d01), // Malayalam Sign Combining..Malayalam Sign Candrabin
intArrayOf(0x00d3b, 0x00d3c), // Malayalam Sign Vertical ..Malayalam Sign Circular
intArrayOf(0x00d41, 0x00d44), // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc
intArrayOf(0x00d4d, 0x00d4d), // Malayalam Sign Virama ..Malayalam Sign Virama
intArrayOf(0x00d62, 0x00d63), // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc
intArrayOf(0x00d81, 0x00d81), // Sinhala Sign Candrabindu..Sinhala Sign Candrabindu
intArrayOf(0x00dca, 0x00dca), // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna
intArrayOf(0x00dd2, 0x00dd4), // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti
intArrayOf(0x00dd6, 0x00dd6), // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga
intArrayOf(0x00e31, 0x00e31), // Thai Character Mai Han-a..Thai Character Mai Han-a
intArrayOf(0x00e34, 0x00e3a), // Thai Character Sara I ..Thai Character Phinthu
intArrayOf(0x00e47, 0x00e4e), // Thai Character Maitaikhu..Thai Character Yamakkan
intArrayOf(0x00eb1, 0x00eb1), // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan
intArrayOf(0x00eb4, 0x00ebc), // Lao Vowel Sign I ..Lao Semivowel Sign Lo
intArrayOf(0x00ec8, 0x00ece), // Lao Tone Mai Ek ..(nil)
intArrayOf(0x00f18, 0x00f19), // Tibetan Astrological Sig..Tibetan Astrological Sig
intArrayOf(0x00f35, 0x00f35), // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
intArrayOf(0x00f37, 0x00f37), // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
intArrayOf(0x00f39, 0x00f39), // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru
intArrayOf(0x00f71, 0x00f7e), // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga
intArrayOf(0x00f80, 0x00f84), // Tibetan Vowel Sign Rever..Tibetan Mark Halanta
intArrayOf(0x00f86, 0x00f87), // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags
intArrayOf(0x00f8d, 0x00f97), // Tibetan Subjoined Sign L..Tibetan Subjoined Letter
intArrayOf(0x00f99, 0x00fbc), // Tibetan Subjoined Letter..Tibetan Subjoined Letter
intArrayOf(0x00fc6, 0x00fc6), // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda
intArrayOf(0x0102d, 0x01030), // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu
intArrayOf(0x01032, 0x01037), // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below
intArrayOf(0x01039, 0x0103a), // Myanmar Sign Virama ..Myanmar Sign Asat
intArrayOf(0x0103d, 0x0103e), // Myanmar Consonant Sign M..Myanmar Consonant Sign M
intArrayOf(0x01058, 0x01059), // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal
intArrayOf(0x0105e, 0x01060), // Myanmar Consonant Sign M..Myanmar Consonant Sign M
intArrayOf(0x01071, 0x01074), // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah
intArrayOf(0x01082, 0x01082), // Myanmar Consonant Sign S..Myanmar Consonant Sign S
intArrayOf(0x01085, 0x01086), // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan
intArrayOf(0x0108d, 0x0108d), // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci
intArrayOf(0x0109d, 0x0109d), // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton
intArrayOf(0x0135d, 0x0135f), // Ethiopic Combining Gemin..Ethiopic Combining Gemin
intArrayOf(0x01712, 0x01714), // Tagalog Vowel Sign I ..Tagalog Sign Virama
intArrayOf(0x01732, 0x01733), // Hanunoo Vowel Sign I ..Hanunoo Vowel Sign U
intArrayOf(0x01752, 0x01753), // Buhid Vowel Sign I ..Buhid Vowel Sign U
intArrayOf(0x01772, 0x01773), // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U
intArrayOf(0x017b4, 0x017b5), // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa
intArrayOf(0x017b7, 0x017bd), // Khmer Vowel Sign I ..Khmer Vowel Sign Ua
intArrayOf(0x017c6, 0x017c6), // Khmer Sign Nikahit ..Khmer Sign Nikahit
intArrayOf(0x017c9, 0x017d3), // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat
intArrayOf(0x017dd, 0x017dd), // Khmer Sign Atthacan ..Khmer Sign Atthacan
intArrayOf(0x0180b, 0x0180d), // Mongolian Free Variation..Mongolian Free Variation
intArrayOf(0x0180f, 0x0180f), // Mongolian Free Variation..Mongolian Free Variation
intArrayOf(0x01885, 0x01886), // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
intArrayOf(0x018a9, 0x018a9), // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
intArrayOf(0x01920, 0x01922), // Limbu Vowel Sign A ..Limbu Vowel Sign U
intArrayOf(0x01927, 0x01928), // Limbu Vowel Sign E ..Limbu Vowel Sign O
intArrayOf(0x01932, 0x01932), // Limbu Small Letter Anusv..Limbu Small Letter Anusv
intArrayOf(0x01939, 0x0193b), // Limbu Sign Mukphreng ..Limbu Sign Sa-i
intArrayOf(0x01a17, 0x01a18), // Buginese Vowel Sign I ..Buginese Vowel Sign U
intArrayOf(0x01a1b, 0x01a1b), // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae
intArrayOf(0x01a56, 0x01a56), // Tai Tham Consonant Sign ..Tai Tham Consonant Sign
intArrayOf(0x01a58, 0x01a5e), // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign
intArrayOf(0x01a60, 0x01a60), // Tai Tham Sign Sakot ..Tai Tham Sign Sakot
intArrayOf(0x01a62, 0x01a62), // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai
intArrayOf(0x01a65, 0x01a6c), // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B
intArrayOf(0x01a73, 0x01a7c), // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue
intArrayOf(0x01a7f, 0x01a7f), // Tai Tham Combining Crypt..Tai Tham Combining Crypt
intArrayOf(0x01ab0, 0x01ace), // Combining Doubled Circum..Combining Latin Small Le
intArrayOf(0x01b00, 0x01b03), // Balinese Sign Ulu Ricem ..Balinese Sign Surang
intArrayOf(0x01b34, 0x01b34), // Balinese Sign Rerekan ..Balinese Sign Rerekan
intArrayOf(0x01b36, 0x01b3a), // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R
intArrayOf(0x01b3c, 0x01b3c), // Balinese Vowel Sign La L..Balinese Vowel Sign La L
intArrayOf(0x01b42, 0x01b42), // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe
intArrayOf(0x01b6b, 0x01b73), // Balinese Musical Symbol ..Balinese Musical Symbol
intArrayOf(0x01b80, 0x01b81), // Sundanese Sign Panyecek ..Sundanese Sign Panglayar
intArrayOf(0x01ba2, 0x01ba5), // Sundanese Consonant Sign..Sundanese Vowel Sign Pan
intArrayOf(0x01ba8, 0x01ba9), // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan
intArrayOf(0x01bab, 0x01bad), // Sundanese Sign Virama ..Sundanese Consonant Sign
intArrayOf(0x01be6, 0x01be6), // Batak Sign Tompi ..Batak Sign Tompi
intArrayOf(0x01be8, 0x01be9), // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee
intArrayOf(0x01bed, 0x01bed), // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O
intArrayOf(0x01bef, 0x01bf1), // Batak Vowel Sign U For S..Batak Consonant Sign H
intArrayOf(0x01c2c, 0x01c33), // Lepcha Vowel Sign E ..Lepcha Consonant Sign T
intArrayOf(0x01c36, 0x01c37), // Lepcha Sign Ran ..Lepcha Sign Nukta
intArrayOf(0x01cd0, 0x01cd2), // Vedic Tone Karshana ..Vedic Tone Prenkha
intArrayOf(0x01cd4, 0x01ce0), // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash
intArrayOf(0x01ce2, 0x01ce8), // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda
intArrayOf(0x01ced, 0x01ced), // Vedic Sign Tiryak ..Vedic Sign Tiryak
intArrayOf(0x01cf4, 0x01cf4), // Vedic Tone Candra Above ..Vedic Tone Candra Above
intArrayOf(0x01cf8, 0x01cf9), // Vedic Tone Ring Above ..Vedic Tone Double Ring A
intArrayOf(0x01dc0, 0x01dff), // Combining Dotted Grave A..Combining Right Arrowhea
intArrayOf(0x020d0, 0x020f0), // Combining Left Harpoon A..Combining Asterisk Above
intArrayOf(0x02cef, 0x02cf1), // Coptic Combining Ni Abov..Coptic Combining Spiritu
intArrayOf(0x02d7f, 0x02d7f), // Tifinagh Consonant Joine..Tifinagh Consonant Joine
intArrayOf(0x02de0, 0x02dff), // Combining Cyrillic Lette..Combining Cyrillic Lette
intArrayOf(0x0302a, 0x0302d), // Ideographic Level Tone M..Ideographic Entering Ton
intArrayOf(0x03099, 0x0309a), // Combining Katakana-hirag..Combining Katakana-hirag
intArrayOf(0x0a66f, 0x0a672), // Combining Cyrillic Vzmet..Combining Cyrillic Thous
intArrayOf(0x0a674, 0x0a67d), // Combining Cyrillic Lette..Combining Cyrillic Payer
intArrayOf(0x0a69e, 0x0a69f), // Combining Cyrillic Lette..Combining Cyrillic Lette
intArrayOf(0x0a6f0, 0x0a6f1), // Bamum Combining Mark Koq..Bamum Combining Mark Tuk
intArrayOf(0x0a802, 0x0a802), // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva
intArrayOf(0x0a806, 0x0a806), // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant
intArrayOf(0x0a80b, 0x0a80b), // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva
intArrayOf(0x0a825, 0x0a826), // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign
intArrayOf(0x0a82c, 0x0a82c), // Syloti Nagri Sign Altern..Syloti Nagri Sign Altern
intArrayOf(0x0a8c4, 0x0a8c5), // Saurashtra Sign Virama ..Saurashtra Sign Candrabi
intArrayOf(0x0a8e0, 0x0a8f1), // Combining Devanagari Dig..Combining Devanagari Sig
intArrayOf(0x0a8ff, 0x0a8ff), // Devanagari Vowel Sign Ay..Devanagari Vowel Sign Ay
intArrayOf(0x0a926, 0x0a92d), // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop
intArrayOf(0x0a947, 0x0a951), // Rejang Vowel Sign I ..Rejang Consonant Sign R
intArrayOf(0x0a980, 0x0a982), // Javanese Sign Panyangga ..Javanese Sign Layar
intArrayOf(0x0a9b3, 0x0a9b3), // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu
intArrayOf(0x0a9b6, 0x0a9b9), // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku
intArrayOf(0x0a9bc, 0x0a9bd), // Javanese Vowel Sign Pepe..Javanese Consonant Sign
intArrayOf(0x0a9e5, 0x0a9e5), // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw
intArrayOf(0x0aa29, 0x0aa2e), // Cham Vowel Sign Aa ..Cham Vowel Sign Oe
intArrayOf(0x0aa31, 0x0aa32), // Cham Vowel Sign Au ..Cham Vowel Sign Ue
intArrayOf(0x0aa35, 0x0aa36), // Cham Consonant Sign La ..Cham Consonant Sign Wa
intArrayOf(0x0aa43, 0x0aa43), // Cham Consonant Sign Fina..Cham Consonant Sign Fina
intArrayOf(0x0aa4c, 0x0aa4c), // Cham Consonant Sign Fina..Cham Consonant Sign Fina
intArrayOf(0x0aa7c, 0x0aa7c), // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T
intArrayOf(0x0aab0, 0x0aab0), // Tai Viet Mai Kang ..Tai Viet Mai Kang
intArrayOf(0x0aab2, 0x0aab4), // Tai Viet Vowel I ..Tai Viet Vowel U
intArrayOf(0x0aab7, 0x0aab8), // Tai Viet Mai Khit ..Tai Viet Vowel Ia
intArrayOf(0x0aabe, 0x0aabf), // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek
intArrayOf(0x0aac1, 0x0aac1), // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho
intArrayOf(0x0aaec, 0x0aaed), // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
intArrayOf(0x0aaf6, 0x0aaf6), // Meetei Mayek Virama ..Meetei Mayek Virama
intArrayOf(0x0abe5, 0x0abe5), // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
intArrayOf(0x0abe8, 0x0abe8), // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
intArrayOf(0x0abed, 0x0abed), // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek
intArrayOf(0x0fb1e, 0x0fb1e), // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani
intArrayOf(0x0fe00, 0x0fe0f), // Variation Selector-1 ..Variation Selector-16
intArrayOf(0x0fe20, 0x0fe2f), // Combining Ligature Left ..Combining Cyrillic Titlo
intArrayOf(0x101fd, 0x101fd), // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi
intArrayOf(0x102e0, 0x102e0), // Coptic Epact Thousands M..Coptic Epact Thousands M
intArrayOf(0x10376, 0x1037a), // Combining Old Permic Let..Combining Old Permic Let
intArrayOf(0x10a01, 0x10a03), // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo
intArrayOf(0x10a05, 0x10a06), // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O
intArrayOf(0x10a0c, 0x10a0f), // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga
intArrayOf(0x10a38, 0x10a3a), // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo
intArrayOf(0x10a3f, 0x10a3f), // Kharoshthi Virama ..Kharoshthi Virama
intArrayOf(0x10ae5, 0x10ae6), // Manichaean Abbreviation ..Manichaean Abbreviation
intArrayOf(0x10d24, 0x10d27), // Hanifi Rohingya Sign Har..Hanifi Rohingya Sign Tas
intArrayOf(0x10eab, 0x10eac), // Yezidi Combining Hamza M..Yezidi Combining Madda M
intArrayOf(0x10efd, 0x10eff), // (nil) ..(nil)
intArrayOf(0x10f46, 0x10f50), // Sogdian Combining Dot Be..Sogdian Combining Stroke
intArrayOf(0x10f82, 0x10f85), // Old Uyghur Combining Dot..Old Uyghur Combining Two
intArrayOf(0x11001, 0x11001), // Brahmi Sign Anusvara ..Brahmi Sign Anusvara
intArrayOf(0x11038, 0x11046), // Brahmi Vowel Sign Aa ..Brahmi Virama
intArrayOf(0x11070, 0x11070), // Brahmi Sign Old Tamil Vi..Brahmi Sign Old Tamil Vi
intArrayOf(0x11073, 0x11074), // Brahmi Vowel Sign Old Ta..Brahmi Vowel Sign Old Ta
intArrayOf(0x1107f, 0x11081), // Brahmi Number Joiner ..Kaithi Sign Anusvara
intArrayOf(0x110b3, 0x110b6), // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai
intArrayOf(0x110b9, 0x110ba), // Kaithi Sign Virama ..Kaithi Sign Nukta
intArrayOf(0x110c2, 0x110c2), // Kaithi Vowel Sign Vocali..Kaithi Vowel Sign Vocali
intArrayOf(0x11100, 0x11102), // Chakma Sign Candrabindu ..Chakma Sign Visarga
intArrayOf(0x11127, 0x1112b), // Chakma Vowel Sign A ..Chakma Vowel Sign Uu
intArrayOf(0x1112d, 0x11134), // Chakma Vowel Sign Ai ..Chakma Maayyaa
intArrayOf(0x11173, 0x11173), // Mahajani Sign Nukta ..Mahajani Sign Nukta
intArrayOf(0x11180, 0x11181), // Sharada Sign Candrabindu..Sharada Sign Anusvara
intArrayOf(0x111b6, 0x111be), // Sharada Vowel Sign U ..Sharada Vowel Sign O
intArrayOf(0x111c9, 0x111cc), // Sharada Sandhi Mark ..Sharada Extra Short Vowe
intArrayOf(0x111cf, 0x111cf), // Sharada Sign Inverted Ca..Sharada Sign Inverted Ca
intArrayOf(0x1122f, 0x11231), // Khojki Vowel Sign U ..Khojki Vowel Sign Ai
intArrayOf(0x11234, 0x11234), // Khojki Sign Anusvara ..Khojki Sign Anusvara
intArrayOf(0x11236, 0x11237), // Khojki Sign Nukta ..Khojki Sign Shadda
intArrayOf(0x1123e, 0x1123e), // Khojki Sign Sukun ..Khojki Sign Sukun
intArrayOf(0x11241, 0x11241), // (nil) ..(nil)
intArrayOf(0x112df, 0x112df), // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara
intArrayOf(0x112e3, 0x112ea), // Khudawadi Vowel Sign U ..Khudawadi Sign Virama
intArrayOf(0x11300, 0x11301), // Grantha Sign Combining A..Grantha Sign Candrabindu
intArrayOf(0x1133b, 0x1133c), // Combining Bindu Below ..Grantha Sign Nukta
intArrayOf(0x11340, 0x11340), // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii
intArrayOf(0x11366, 0x1136c), // Combining Grantha Digit ..Combining Grantha Digit
intArrayOf(0x11370, 0x11374), // Combining Grantha Letter..Combining Grantha Letter
intArrayOf(0x11438, 0x1143f), // Newa Vowel Sign U ..Newa Vowel Sign Ai
intArrayOf(0x11442, 0x11444), // Newa Sign Virama ..Newa Sign Anusvara
intArrayOf(0x11446, 0x11446), // Newa Sign Nukta ..Newa Sign Nukta
intArrayOf(0x1145e, 0x1145e), // Newa Sandhi Mark ..Newa Sandhi Mark
intArrayOf(0x114b3, 0x114b8), // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal
intArrayOf(0x114ba, 0x114ba), // Tirhuta Vowel Sign Short..Tirhuta Vowel Sign Short
intArrayOf(0x114bf, 0x114c0), // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara
intArrayOf(0x114c2, 0x114c3), // Tirhuta Sign Virama ..Tirhuta Sign Nukta
intArrayOf(0x115b2, 0x115b5), // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal
intArrayOf(0x115bc, 0x115bd), // Siddham Sign Candrabindu..Siddham Sign Anusvara
intArrayOf(0x115bf, 0x115c0), // Siddham Sign Virama ..Siddham Sign Nukta
intArrayOf(0x115dc, 0x115dd), // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter
intArrayOf(0x11633, 0x1163a), // Modi Vowel Sign U ..Modi Vowel Sign Ai
intArrayOf(0x1163d, 0x1163d), // Modi Sign Anusvara ..Modi Sign Anusvara
intArrayOf(0x1163f, 0x11640), // Modi Sign Virama ..Modi Sign Ardhacandra
intArrayOf(0x116ab, 0x116ab), // Takri Sign Anusvara ..Takri Sign Anusvara
intArrayOf(0x116ad, 0x116ad), // Takri Vowel Sign Aa ..Takri Vowel Sign Aa
intArrayOf(0x116b0, 0x116b5), // Takri Vowel Sign U ..Takri Vowel Sign Au
intArrayOf(0x116b7, 0x116b7), // Takri Sign Nukta ..Takri Sign Nukta
intArrayOf(0x1171d, 0x1171f), // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi
intArrayOf(0x11722, 0x11725), // Ahom Vowel Sign I ..Ahom Vowel Sign Uu
intArrayOf(0x11727, 0x1172b), // Ahom Vowel Sign Aw ..Ahom Sign Killer
intArrayOf(0x1182f, 0x11837), // Dogra Vowel Sign U ..Dogra Sign Anusvara
intArrayOf(0x11839, 0x1183a), // Dogra Sign Virama ..Dogra Sign Nukta
intArrayOf(0x1193b, 0x1193c), // Dives Akuru Sign Anusvar..Dives Akuru Sign Candrab
intArrayOf(0x1193e, 0x1193e), // Dives Akuru Virama ..Dives Akuru Virama
intArrayOf(0x11943, 0x11943), // Dives Akuru Sign Nukta ..Dives Akuru Sign Nukta
intArrayOf(0x119d4, 0x119d7), // Nandinagari Vowel Sign U..Nandinagari Vowel Sign V
intArrayOf(0x119da, 0x119db), // Nandinagari Vowel Sign E..Nandinagari Vowel Sign A
intArrayOf(0x119e0, 0x119e0), // Nandinagari Sign Virama ..Nandinagari Sign Virama
intArrayOf(0x11a01, 0x11a0a), // Zanabazar Square Vowel S..Zanabazar Square Vowel L
intArrayOf(0x11a33, 0x11a38), // Zanabazar Square Final C..Zanabazar Square Sign An
intArrayOf(0x11a3b, 0x11a3e), // Zanabazar Square Cluster..Zanabazar Square Cluster
intArrayOf(0x11a47, 0x11a47), // Zanabazar Square Subjoin..Zanabazar Square Subjoin
intArrayOf(0x11a51, 0x11a56), // Soyombo Vowel Sign I ..Soyombo Vowel Sign Oe
intArrayOf(0x11a59, 0x11a5b), // Soyombo Vowel Sign Vocal..Soyombo Vowel Length Mar
intArrayOf(0x11a8a, 0x11a96), // Soyombo Final Consonant ..Soyombo Sign Anusvara
intArrayOf(0x11a98, 0x11a99), // Soyombo Gemination Mark ..Soyombo Subjoiner
intArrayOf(0x11c30, 0x11c36), // Bhaiksuki Vowel Sign I ..Bhaiksuki Vowel Sign Voc
intArrayOf(0x11c38, 0x11c3d), // Bhaiksuki Vowel Sign E ..Bhaiksuki Sign Anusvara
intArrayOf(0x11c3f, 0x11c3f), // Bhaiksuki Sign Virama ..Bhaiksuki Sign Virama
intArrayOf(0x11c92, 0x11ca7), // Marchen Subjoined Letter..Marchen Subjoined Letter
intArrayOf(0x11caa, 0x11cb0), // Marchen Subjoined Letter..Marchen Vowel Sign Aa
intArrayOf(0x11cb2, 0x11cb3), // Marchen Vowel Sign U ..Marchen Vowel Sign E
intArrayOf(0x11cb5, 0x11cb6), // Marchen Sign Anusvara ..Marchen Sign Candrabindu
intArrayOf(0x11d31, 0x11d36), // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
intArrayOf(0x11d3a, 0x11d3a), // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
intArrayOf(0x11d3c, 0x11d3d), // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign
intArrayOf(0x11d3f, 0x11d45), // Masaram Gondi Vowel Sign..Masaram Gondi Virama
intArrayOf(0x11d47, 0x11d47), // Masaram Gondi Ra-kara ..Masaram Gondi Ra-kara
intArrayOf(0x11d90, 0x11d91), // Gunjala Gondi Vowel Sign..Gunjala Gondi Vowel Sign
intArrayOf(0x11d95, 0x11d95), // Gunjala Gondi Sign Anusv..Gunjala Gondi Sign Anusv
intArrayOf(0x11d97, 0x11d97), // Gunjala Gondi Virama ..Gunjala Gondi Virama
intArrayOf(0x11ef3, 0x11ef4), // Makasar Vowel Sign I ..Makasar Vowel Sign U
intArrayOf(0x11f00, 0x11f01), // (nil) ..(nil)
intArrayOf(0x11f36, 0x11f3a), // (nil) ..(nil)
intArrayOf(0x11f40, 0x11f40), // (nil) ..(nil)
intArrayOf(0x11f42, 0x11f42), // (nil) ..(nil)
intArrayOf(0x13440, 0x13440), // (nil) ..(nil)
intArrayOf(0x13447, 0x13455), // (nil) ..(nil)
intArrayOf(0x16af0, 0x16af4), // Bassa Vah Combining High..Bassa Vah Combining High
intArrayOf(0x16b30, 0x16b36), // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta
intArrayOf(0x16f4f, 0x16f4f), // Miao Sign Consonant Modi..Miao Sign Consonant Modi
intArrayOf(0x16f8f, 0x16f92), // Miao Tone Right ..Miao Tone Below
intArrayOf(0x16fe4, 0x16fe4), // Khitan Small Script Fill..Khitan Small Script Fill
intArrayOf(0x1bc9d, 0x1bc9e), // Duployan Thick Letter Se..Duployan Double Mark
intArrayOf(0x1cf00, 0x1cf2d), // Znamenny Combining Mark ..Znamenny Combining Mark
intArrayOf(0x1cf30, 0x1cf46), // Znamenny Combining Tonal..Znamenny Priznak Modifie
intArrayOf(0x1d167, 0x1d169), // Musical Symbol Combining..Musical Symbol Combining
intArrayOf(0x1d17b, 0x1d182), // Musical Symbol Combining..Musical Symbol Combining
intArrayOf(0x1d185, 0x1d18b), // Musical Symbol Combining..Musical Symbol Combining
intArrayOf(0x1d1aa, 0x1d1ad), // Musical Symbol Combining..Musical Symbol Combining
intArrayOf(0x1d242, 0x1d244), // Combining Greek Musical ..Combining Greek Musical
intArrayOf(0x1da00, 0x1da36), // Signwriting Head Rim ..Signwriting Air Sucking
intArrayOf(0x1da3b, 0x1da6c), // Signwriting Mouth Closed..Signwriting Excitement
intArrayOf(0x1da75, 0x1da75), // Signwriting Upper Body T..Signwriting Upper Body T
intArrayOf(0x1da84, 0x1da84), // Signwriting Location Hea..Signwriting Location Hea
intArrayOf(0x1da9b, 0x1da9f), // Signwriting Fill Modifie..Signwriting Fill Modifie
intArrayOf(0x1daa1, 0x1daaf), // Signwriting Rotation Mod..Signwriting Rotation Mod
intArrayOf(0x1e000, 0x1e006), // Combining Glagolitic Let..Combining Glagolitic Let
intArrayOf(0x1e008, 0x1e018), // Combining Glagolitic Let..Combining Glagolitic Let
intArrayOf(0x1e01b, 0x1e021), // Combining Glagolitic Let..Combining Glagolitic Let
intArrayOf(0x1e023, 0x1e024), // Combining Glagolitic Let..Combining Glagolitic Let
intArrayOf(0x1e026, 0x1e02a), // Combining Glagolitic Let..Combining Glagolitic Let
intArrayOf(0x1e08f, 0x1e08f), // (nil) ..(nil)
intArrayOf(0x1e130, 0x1e136), // Nyiakeng Puachue Hmong T..Nyiakeng Puachue Hmong T
intArrayOf(0x1e2ae, 0x1e2ae), // Toto Sign Rising Tone ..Toto Sign Rising Tone
intArrayOf(0x1e2ec, 0x1e2ef), // Wancho Tone Tup ..Wancho Tone Koini
intArrayOf(0x1e4ec, 0x1e4ef), // (nil) ..(nil)
intArrayOf(0x1e8d0, 0x1e8d6), // Mende Kikakui Combining ..Mende Kikakui Combining
intArrayOf(0x1e944, 0x1e94a), // Adlam Alif Lengthener ..Adlam Nukta
intArrayOf(0xe0100, 0xe01ef), // Variation Selector-17 ..Variation Selector-256
)
// https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py
// from https://github.com/jquast/wcwidth/pull/64
// at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16):
private val WIDE_EASTASIAN = arrayOf(
intArrayOf(0x01100, 0x0115f), // Hangul Choseong Kiyeok ..Hangul Choseong Filler
intArrayOf(0x0231a, 0x0231b), // Watch ..Hourglass
intArrayOf(0x02329, 0x0232a), // Left-pointing Angle Brac..Right-pointing Angle Bra
intArrayOf(0x023e9, 0x023ec), // Black Right-pointing Dou..Black Down-pointing Doub
intArrayOf(0x023f0, 0x023f0), // Alarm Clock ..Alarm Clock
intArrayOf(0x023f3, 0x023f3), // Hourglass With Flowing S..Hourglass With Flowing S
intArrayOf(0x025fd, 0x025fe), // White Medium Small Squar..Black Medium Small Squar
intArrayOf(0x02614, 0x02615), // Umbrella With Rain Drops..Hot Beverage
intArrayOf(0x02648, 0x02653), // Aries ..Pisces
intArrayOf(0x0267f, 0x0267f), // Wheelchair Symbol ..Wheelchair Symbol
intArrayOf(0x02693, 0x02693), // Anchor ..Anchor
intArrayOf(0x026a1, 0x026a1), // High Voltage Sign ..High Voltage Sign
intArrayOf(0x026aa, 0x026ab), // Medium White Circle ..Medium Black Circle
intArrayOf(0x026bd, 0x026be), // Soccer Ball ..Baseball
intArrayOf(0x026c4, 0x026c5), // Snowman Without Snow ..Sun Behind Cloud
intArrayOf(0x026ce, 0x026ce), // Ophiuchus ..Ophiuchus
intArrayOf(0x026d4, 0x026d4), // No Entry ..No Entry
intArrayOf(0x026ea, 0x026ea), // Church ..Church
intArrayOf(0x026f2, 0x026f3), // Fountain ..Flag In Hole
intArrayOf(0x026f5, 0x026f5), // Sailboat ..Sailboat
intArrayOf(0x026fa, 0x026fa), // Tent ..Tent
intArrayOf(0x026fd, 0x026fd), // Fuel Pump ..Fuel Pump
intArrayOf(0x02705, 0x02705), // White Heavy Check Mark ..White Heavy Check Mark
intArrayOf(0x0270a, 0x0270b), // Raised Fist ..Raised Hand
intArrayOf(0x02728, 0x02728), // Sparkles ..Sparkles
intArrayOf(0x0274c, 0x0274c), // Cross Mark ..Cross Mark
intArrayOf(0x0274e, 0x0274e), // Negative Squared Cross M..Negative Squared Cross M
intArrayOf(0x02753, 0x02755), // Black Question Mark Orna..White Exclamation Mark O
intArrayOf(0x02757, 0x02757), // Heavy Exclamation Mark S..Heavy Exclamation Mark S
intArrayOf(0x02795, 0x02797), // Heavy Plus Sign ..Heavy Division Sign
intArrayOf(0x027b0, 0x027b0), // Curly Loop ..Curly Loop
intArrayOf(0x027bf, 0x027bf), // Double Curly Loop ..Double Curly Loop
intArrayOf(0x02b1b, 0x02b1c), // Black Large Square ..White Large Square
intArrayOf(0x02b50, 0x02b50), // White Medium Star ..White Medium Star
intArrayOf(0x02b55, 0x02b55), // Heavy Large Circle ..Heavy Large Circle
intArrayOf(0x02e80, 0x02e99), // Cjk Radical Repeat ..Cjk Radical Rap
intArrayOf(0x02e9b, 0x02ef3), // Cjk Radical Choke ..Cjk Radical C-simplified
intArrayOf(0x02f00, 0x02fd5), // Kangxi Radical One ..Kangxi Radical Flute
intArrayOf(0x02ff0, 0x02ffb), // Ideographic Description ..Ideographic Description
intArrayOf(0x03000, 0x0303e), // Ideographic Space ..Ideographic Variation In
intArrayOf(0x03041, 0x03096), // Hiragana Letter Small A ..Hiragana Letter Small Ke
intArrayOf(0x03099, 0x030ff), // Combining Katakana-hirag..Katakana Digraph Koto
intArrayOf(0x03105, 0x0312f), // Bopomofo Letter B ..Bopomofo Letter Nn
intArrayOf(0x03131, 0x0318e), // Hangul Letter Kiyeok ..Hangul Letter Araeae
intArrayOf(0x03190, 0x031e3), // Ideographic Annotation L..Cjk Stroke Q
intArrayOf(0x031f0, 0x0321e), // Katakana Letter Small Ku..Parenthesized Korean Cha
intArrayOf(0x03220, 0x03247), // Parenthesized Ideograph ..Circled Ideograph Koto
intArrayOf(0x03250, 0x04dbf), // Partnership Sign ..Cjk Unified Ideograph-4d
intArrayOf(0x04e00, 0x0a48c), // Cjk Unified Ideograph-4e..Yi Syllable Yyr
intArrayOf(0x0a490, 0x0a4c6), // Yi Radical Qot ..Yi Radical Ke
intArrayOf(0x0a960, 0x0a97c), // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo
intArrayOf(0x0ac00, 0x0d7a3), // Hangul Syllable Ga ..Hangul Syllable Hih
intArrayOf(0x0f900, 0x0faff), // Cjk Compatibility Ideogr..(nil)
intArrayOf(0x0fe10, 0x0fe19), // Presentation Form For Ve..Presentation Form For Ve
intArrayOf(0x0fe30, 0x0fe52), // Presentation Form For Ve..Small Full Stop
intArrayOf(0x0fe54, 0x0fe66), // Small Semicolon ..Small Equals Sign
intArrayOf(0x0fe68, 0x0fe6b), // Small Reverse Solidus ..Small Commercial At
intArrayOf(0x0ff01, 0x0ff60), // Fullwidth Exclamation Ma..Fullwidth Right White Pa
intArrayOf(0x0ffe0, 0x0ffe6), // Fullwidth Cent Sign ..Fullwidth Won Sign
intArrayOf(0x16fe0, 0x16fe4), // Tangut Iteration Mark ..Khitan Small Script Fill
intArrayOf(0x16ff0, 0x16ff1), // Vietnamese Alternate Rea..Vietnamese Alternate Rea
intArrayOf(0x17000, 0x187f7), // (nil) ..(nil)
intArrayOf(0x18800, 0x18cd5), // Tangut Component-001 ..Khitan Small Script Char
intArrayOf(0x18d00, 0x18d08), // (nil) ..(nil)
intArrayOf(0x1aff0, 0x1aff3), // Katakana Letter Minnan T..Katakana Letter Minnan T
intArrayOf(0x1aff5, 0x1affb), // Katakana Letter Minnan T..Katakana Letter Minnan N
intArrayOf(0x1affd, 0x1affe), // Katakana Letter Minnan N..Katakana Letter Minnan N
intArrayOf(0x1b000, 0x1b122), // Katakana Letter Archaic ..Katakana Letter Archaic
intArrayOf(0x1b132, 0x1b132), // (nil) ..(nil)
intArrayOf(0x1b150, 0x1b152), // Hiragana Letter Small Wi..Hiragana Letter Small Wo
intArrayOf(0x1b155, 0x1b155), // (nil) ..(nil)
intArrayOf(0x1b164, 0x1b167), // Katakana Letter Small Wi..Katakana Letter Small N
intArrayOf(0x1b170, 0x1b2fb), // Nushu Character-1b170 ..Nushu Character-1b2fb
intArrayOf(0x1f004, 0x1f004), // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon
intArrayOf(0x1f0cf, 0x1f0cf), // Playing Card Black Joker..Playing Card Black Joker
intArrayOf(0x1f18e, 0x1f18e), // Negative Squared Ab ..Negative Squared Ab
intArrayOf(0x1f191, 0x1f19a), // Squared Cl ..Squared Vs
intArrayOf(0x1f200, 0x1f202), // Square Hiragana Hoka ..Squared Katakana Sa
intArrayOf(0x1f210, 0x1f23b), // Squared Cjk Unified Ideo..Squared Cjk Unified Ideo
intArrayOf(0x1f240, 0x1f248), // Tortoise Shell Bracketed..Tortoise Shell Bracketed
intArrayOf(0x1f250, 0x1f251), // Circled Ideograph Advant..Circled Ideograph Accept
intArrayOf(0x1f260, 0x1f265), // Rounded Symbol For Fu ..Rounded Symbol For Cai
intArrayOf(0x1f300, 0x1f320), // Cyclone ..Shooting Star
intArrayOf(0x1f32d, 0x1f335), // Hot Dog ..Cactus
intArrayOf(0x1f337, 0x1f37c), // Tulip ..Baby Bottle
intArrayOf(0x1f37e, 0x1f393), // Bottle With Popping Cork..Graduation Cap
intArrayOf(0x1f3a0, 0x1f3ca), // Carousel Horse ..Swimmer
intArrayOf(0x1f3cf, 0x1f3d3), // Cricket Bat And Ball ..Table Tennis Paddle And
intArrayOf(0x1f3e0, 0x1f3f0), // House Building ..European Castle
intArrayOf(0x1f3f4, 0x1f3f4), // Waving Black Flag ..Waving Black Flag
intArrayOf(0x1f3f8, 0x1f43e), // Badminton Racquet And Sh..Paw Prints
intArrayOf(0x1f440, 0x1f440), // Eyes ..Eyes
intArrayOf(0x1f442, 0x1f4fc), // Ear ..Videocassette
intArrayOf(0x1f4ff, 0x1f53d), // Prayer Beads ..Down-pointing Small Red
intArrayOf(0x1f54b, 0x1f54e), // Kaaba ..Menorah With Nine Branch
intArrayOf(0x1f550, 0x1f567), // Clock Face One Oclock ..Clock Face Twelve-thirty
intArrayOf(0x1f57a, 0x1f57a), // Man Dancing ..Man Dancing
intArrayOf(0x1f595, 0x1f596), // Reversed Hand With Middl..Raised Hand With Part Be
intArrayOf(0x1f5a4, 0x1f5a4), // Black Heart ..Black Heart
intArrayOf(0x1f5fb, 0x1f64f), // Mount Fuji ..Person With Folded Hands
intArrayOf(0x1f680, 0x1f6c5), // Rocket ..Left Luggage
intArrayOf(0x1f6cc, 0x1f6cc), // Sleeping Accommodation ..Sleeping Accommodation
intArrayOf(0x1f6d0, 0x1f6d2), // Place Of Worship ..Shopping Trolley
intArrayOf(0x1f6d5, 0x1f6d7), // Hindu Temple ..Elevator
intArrayOf(0x1f6dc, 0x1f6df), // (nil) ..Ring Buoy
intArrayOf(0x1f6eb, 0x1f6ec), // Airplane Departure ..Airplane Arriving
intArrayOf(0x1f6f4, 0x1f6fc), // Scooter ..Roller Skate
intArrayOf(0x1f7e0, 0x1f7eb), // Large Orange Circle ..Large Brown Square
intArrayOf(0x1f7f0, 0x1f7f0), // Heavy Equals Sign ..Heavy Equals Sign
intArrayOf(0x1f90c, 0x1f93a), // Pinched Fingers ..Fencer
intArrayOf(0x1f93c, 0x1f945), // Wrestlers ..Goal Net
intArrayOf(0x1f947, 0x1f9ff), // First Place Medal ..Nazar Amulet
intArrayOf(0x1fa70, 0x1fa7c), // Ballet Shoes ..Crutch
intArrayOf(0x1fa80, 0x1fa88), // Yo-yo ..(nil)
intArrayOf(0x1fa90, 0x1fabd), // Ringed Planet ..(nil)
intArrayOf(0x1fabf, 0x1fac5), // (nil) ..Person With Crown
intArrayOf(0x1face, 0x1fadb), // (nil) ..(nil)
intArrayOf(0x1fae0, 0x1fae8), // Melting Face ..(nil)
intArrayOf(0x1faf0, 0x1faf8), // Hand With Index Finger A..(nil)
intArrayOf(0x20000, 0x2fffd), // Cjk Unified Ideograph-20..(nil)
intArrayOf(0x30000, 0x3fffd), // Cjk Unified Ideograph-30..(nil)
)
private fun intable(table: Array<IntArray>, c: Int): Boolean {
if (c < table[0][0]) return false
var bot = 0
var top = table.size - 1
while (top >= bot) {
val mid = (bot + top) / 2
if (table[mid][1] < c) {
bot = mid + 1
} else if (table[mid][0] > c) {
top = mid - 1
} else {
return true
}
}
return false
}
/** Return the terminal display width of a code point: 0, 1 or 2. */
fun width(ucs: Int): Int {
if (ucs == 0 ||
ucs == 0x034F ||
(ucs in 0x200B..0x200F) ||
ucs == 0x2028 ||
ucs == 0x2029 ||
(ucs in 0x202A..0x202E) ||
(ucs in 0x2060..0x2063)) {
return 0
}
// C0/C1 control characters
// Termux change: Return 0 instead of -1.
if (ucs < 32 || (ucs in 0x07F until 0x0A0)) return 0
if (intable(ZERO_WIDTH, ucs)) return 0
return if (intable(WIDE_EASTASIAN, ucs)) 2 else 1
}
/** The width at an index position in a java char array. */
fun width(chars: CharArray, index: Int): Int {
val c = chars[index]
return if (Character.isHighSurrogate(c)) width(Character.toCodePoint(c, chars[index + 1])) else width(c.code)
}
/**
* The zero width characters count like combining characters in the `chars` array from start
* index to end index (exclusive).
*/
fun zeroWidthCharsCount(chars: CharArray, start: Int, end: Int): Int {
if (start < 0 || start >= chars.size) return 0
var count = 0
var i = start
while (i < end && i < chars.size) {
if (Character.isHighSurrogate(chars[i])) {
if (width(Character.toCodePoint(chars[i], chars[i + 1])) <= 0) {
count++
}
i += 2
} else {
if (width(chars[i].code) <= 0) {
count++
}
i++
}
}
return count
}
}

View File

@@ -3,73 +3,103 @@ package com.topjohnwu.magisk.ui
import android.Manifest
import android.Manifest.permission.REQUEST_INSTALL_PACKAGES
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.res.Resources
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.view.forEach
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.content.res.use
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavDirections
import com.topjohnwu.magisk.MainDirections
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.arch.NavigationActivity
import com.topjohnwu.magisk.arch.startAnimations
import com.topjohnwu.magisk.arch.viewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.topjohnwu.magisk.arch.VMFactory
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.base.ActivityExtension
import com.topjohnwu.magisk.core.base.SplashController
import com.topjohnwu.magisk.core.base.SplashScreenHost
import com.topjohnwu.magisk.core.isRunningAsStub
import com.topjohnwu.magisk.core.ktx.reflectField
import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.core.model.module.LocalModule
import com.topjohnwu.magisk.core.tasks.AppMigration
import com.topjohnwu.magisk.databinding.ActivityMainMd2Binding
import com.topjohnwu.magisk.ui.home.HomeFragmentDirections
import com.topjohnwu.magisk.core.wrap
import com.topjohnwu.magisk.ui.deny.DenyListScreen
import com.topjohnwu.magisk.ui.deny.DenyListViewModel
import com.topjohnwu.magisk.ui.flash.FlashScreen
import com.topjohnwu.magisk.ui.flash.FlashUtils
import com.topjohnwu.magisk.ui.flash.FlashViewModel
import com.topjohnwu.magisk.ui.module.ActionScreen
import com.topjohnwu.magisk.ui.module.ActionViewModel
import com.topjohnwu.magisk.ui.superuser.SuperuserDetailScreen
import com.topjohnwu.magisk.ui.superuser.SuperuserViewModel
import com.topjohnwu.magisk.ui.navigation.CollectNavEvents
import com.topjohnwu.magisk.ui.navigation.LocalNavigator
import com.topjohnwu.magisk.ui.navigation.Navigator
import com.topjohnwu.magisk.ui.navigation.Route
import com.topjohnwu.magisk.ui.navigation.rememberNavigator
import com.topjohnwu.magisk.ui.theme.MagiskTheme
import com.topjohnwu.magisk.ui.theme.Theme
import com.topjohnwu.magisk.view.MagiskDialog
import com.topjohnwu.magisk.view.Shortcuts
import top.yukonga.miuix.kmp.utils.MiuixPopupUtils.Companion.MiuixPopupHost
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import java.io.File
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import com.topjohnwu.magisk.core.R as CoreR
class MainViewModel : BaseViewModel()
class MainActivity : AppCompatActivity(), SplashScreenHost {
class MainActivity : NavigationActivity<ActivityMainMd2Binding>(), SplashScreenHost {
override val layoutRes = R.layout.activity_main_md2
override val viewModel by viewModel<MainViewModel>()
override val navHostId: Int = R.id.main_nav_host
override val extension = ActivityExtension(this)
override val splashController = SplashController(this)
override val snackbarView: View
get() {
val fragmentOverride = currentFragment?.snackbarView
return fragmentOverride ?: super.snackbarView
}
override val snackbarAnchorView: View?
get() {
val fragmentAnchor = currentFragment?.snackbarAnchorView
return when {
fragmentAnchor?.isVisible == true -> fragmentAnchor
binding.mainNavigation.isVisible -> return binding.mainNavigation
else -> null
}
}
private var isRootFragment = true
private val intentState = MutableStateFlow(0)
internal val showInvalidState = MutableStateFlow(false)
internal val showUnsupported = MutableStateFlow<List<Pair<Int, Int>>>(emptyList())
internal val showShortcutPrompt = MutableStateFlow(false)
init {
AppCompatDelegate.setDefaultNightMode(Config.darkTheme)
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base.wrap())
}
override fun onCreate(savedInstanceState: Bundle?) {
extension.onCreate(savedInstanceState)
if (isRunningAsStub) {
val delegate = delegate
val clz = delegate.javaClass
clz.reflectField("mActivityHandlesConfigFlagsChecked").set(delegate, true)
clz.reflectField("mActivityHandlesConfigFlags").set(delegate, 0)
}
setTheme(Theme.selected.themeRes)
splashController.preOnCreate()
super.onCreate(savedInstanceState)
splashController.onCreate(savedInstanceState)
setupWindow()
}
override fun onResume() {
@@ -77,202 +107,257 @@ class MainActivity : NavigationActivity<ActivityMainMd2Binding>(), SplashScreenH
splashController.onResume()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
extension.onSaveInstanceState(outState)
}
private fun setupWindow() {
obtainStyledAttributes(intArrayOf(android.R.attr.windowBackground))
.use { it.getDrawable(0) }
.also { window.setBackgroundDrawable(it) }
WindowCompat.setDecorFitsSystemWindows(window, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
window?.decorView?.post {
if ((window.decorView.rootWindowInsets?.systemWindowInsetBottom
?: 0) < Resources.getSystem().displayMetrics.density * 40) {
window.navigationBarColor = Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.navigationBarDividerColor = Color.TRANSPARENT
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
window.isStatusBarContrastEnforced = false
}
}
}
}
}
@SuppressLint("InlinedApi")
override fun onCreateUi(savedInstanceState: Bundle?) {
setContentView()
showUnsupportedMessage()
askForHomeShortcut()
// Ask permission to post notifications for background update check
if (Config.checkUpdate) {
withPermission(Manifest.permission.POST_NOTIFICATIONS) {
extension.withPermission(Manifest.permission.POST_NOTIFICATIONS) {
Config.checkUpdate = it
}
}
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
navigation.addOnDestinationChangedListener { _, destination, _ ->
isRootFragment = when (destination.id) {
R.id.homeFragment,
R.id.modulesFragment,
R.id.superuserFragment,
R.id.logFragment -> true
else -> false
}
val initialTab = getInitialTab(intent)
setDisplayHomeAsUpEnabled(!isRootFragment)
requestNavigationHidden(!isRootFragment)
setContent {
MagiskTheme {
Box(modifier = Modifier.fillMaxSize()) {
val navigator = rememberNavigator(Route.Main)
CompositionLocalProvider(LocalNavigator provides navigator) {
HandleFlashIntent(navigator)
binding.mainNavigation.menu.forEach {
if (it.itemId == destination.id) {
it.isChecked = true
NavDisplay(
backStack = navigator.backStack,
onBack = { navigator.pop() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Route.Main> {
MainScreen(initialTab = initialTab)
}
entry<Route.DenyList> { _ ->
val vm: DenyListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = VMFactory)
LaunchedEffect(Unit) { vm.startLoading() }
DenyListScreen(vm, onBack = { navigator.pop() })
}
entry<Route.Flash> { key ->
val vm: FlashViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = VMFactory)
LaunchedEffect(key) {
if (vm.flashAction.isEmpty()) {
vm.flashAction = key.action
vm.flashUri = key.additionalData?.let { Uri.parse(it) }
vm.startFlashing()
}
}
FlashScreen(vm, action = key.action, onBack = { navigator.pop() })
}
entry<Route.SuperuserDetail> { key ->
val vm: SuperuserViewModel = androidx.lifecycle.viewmodel.compose.viewModel(
viewModelStoreOwner = this@MainActivity, factory = VMFactory
)
LaunchedEffect(Unit) {
vm.authenticate = { onSuccess ->
extension.withAuthentication { if (it) onSuccess() }
}
}
SuperuserDetailScreen(uid = key.uid, viewModel = vm, onBack = { navigator.pop() })
}
entry<Route.Action> { key ->
val vm: ActionViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = VMFactory)
LaunchedEffect(key) {
if (vm.actionId.isEmpty()) {
vm.actionId = key.id
vm.actionName = key.name
vm.startRunAction()
}
}
ActionScreen(vm, actionName = key.name, onBack = { navigator.pop() })
}
}
)
}
MainActivityDialogs(activity = this@MainActivity)
MiuixPopupHost()
}
}
}
}
setSupportActionBar(binding.mainToolbar)
binding.mainNavigation.setOnItemSelectedListener {
getScreen(it.itemId)?.navigate()
true
}
binding.mainNavigation.setOnItemReselectedListener {
// https://issuetracker.google.com/issues/124538620
}
binding.mainNavigation.menu.apply {
findItem(R.id.superuserFragment)?.isEnabled = Info.showSuperUser
findItem(R.id.modulesFragment)?.isEnabled = Info.env.isActive && LocalModule.loaded()
}
val section =
if (intent.action == Intent.ACTION_APPLICATION_PREFERENCES)
Const.Nav.SETTINGS
else
intent.getStringExtra(Const.Key.OPEN_SECTION)
getScreen(section)?.navigate()
if (!isRootFragment) {
requestNavigationHidden(requiresAnimation = savedInstanceState == null)
@Composable
private fun HandleFlashIntent(navigator: Navigator) {
val intentVersion by intentState.collectAsState()
LaunchedEffect(intentVersion) {
val currentIntent = intent ?: return@LaunchedEffect
if (currentIntent.action == FlashUtils.INTENT_FLASH) {
val action = currentIntent.getStringExtra(FlashUtils.EXTRA_FLASH_ACTION)
?: return@LaunchedEffect
val uri = currentIntent.getStringExtra(FlashUtils.EXTRA_FLASH_URI)
navigator.push(Route.Flash(action, uri))
currentIntent.action = null
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> onBackPressed()
else -> return super.onOptionsItemSelected(item)
}
return true
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
intentState.value += 1
}
fun setDisplayHomeAsUpEnabled(isEnabled: Boolean) {
binding.mainToolbar.startAnimations()
when {
isEnabled -> binding.mainToolbar.setNavigationIcon(R.drawable.ic_back_md2)
else -> binding.mainToolbar.navigationIcon = null
}
}
internal fun requestNavigationHidden(hide: Boolean = true, requiresAnimation: Boolean = true) {
val bottomView = binding.mainNavigation
if (requiresAnimation) {
bottomView.isVisible = true
bottomView.isHidden = hide
private fun getInitialTab(intent: Intent?): Int {
val section = if (intent?.action == Intent.ACTION_APPLICATION_PREFERENCES) {
Const.Nav.SETTINGS
} else {
bottomView.isGone = hide
intent?.getStringExtra(Const.Key.OPEN_SECTION)
}
}
fun invalidateToolbar() {
//binding.mainToolbar.startAnimations()
binding.mainToolbar.invalidate()
}
private fun getScreen(name: String?): NavDirections? {
return when (name) {
Const.Nav.SUPERUSER -> MainDirections.actionSuperuserFragment()
Const.Nav.MODULES -> MainDirections.actionModuleFragment()
Const.Nav.SETTINGS -> HomeFragmentDirections.actionHomeFragmentToSettingsFragment()
else -> null
}
}
private fun getScreen(id: Int): NavDirections? {
return when (id) {
R.id.homeFragment -> MainDirections.actionHomeFragment()
R.id.modulesFragment -> MainDirections.actionModuleFragment()
R.id.superuserFragment -> MainDirections.actionSuperuserFragment()
R.id.logFragment -> MainDirections.actionLogFragment()
else -> null
return when (section) {
Const.Nav.SUPERUSER -> Tab.SUPERUSER.ordinal
Const.Nav.MODULES -> Tab.MODULES.ordinal
Const.Nav.SETTINGS -> Tab.SETTINGS.ordinal
else -> Tab.HOME.ordinal
}
}
@SuppressLint("InlinedApi")
override fun showInvalidStateMessage(): Unit = runOnUiThread {
MagiskDialog(this).apply {
setTitle(CoreR.string.unsupport_nonroot_stub_title)
setMessage(CoreR.string.unsupport_nonroot_stub_msg)
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = CoreR.string.install
onClick {
withPermission(REQUEST_INSTALL_PACKAGES) {
if (!it) {
toast(CoreR.string.install_unknown_denied, Toast.LENGTH_SHORT)
showInvalidStateMessage()
} else {
lifecycleScope.launch {
AppMigration.restore(this@MainActivity)
}
}
override fun showInvalidStateMessage() {
showInvalidState.value = true
}
internal fun handleInvalidStateInstall() {
extension.withPermission(REQUEST_INSTALL_PACKAGES) {
if (!it) {
toast(CoreR.string.install_unknown_denied, Toast.LENGTH_SHORT)
showInvalidState.value = true
} else {
lifecycleScope.launch {
if (!AppMigration.restoreApp(this@MainActivity)) {
toast(CoreR.string.failure, Toast.LENGTH_LONG)
}
}
}
setCancelable(false)
show()
}
}
private fun showUnsupportedMessage() {
if (Info.env.isUnsupported) {
MagiskDialog(this).apply {
setTitle(CoreR.string.unsupport_magisk_title)
setMessage(CoreR.string.unsupport_magisk_msg, Const.Version.MIN_VERSION)
setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok }
setCancelable(false)
}.show()
}
val messages = mutableListOf<Pair<Int, Int>>()
if (Info.env.isUnsupported) {
messages.add(CoreR.string.unsupport_magisk_title to CoreR.string.unsupport_magisk_msg)
}
if (!Info.isEmulator && Info.env.isActive && System.getenv("PATH")
?.split(':')
?.filterNot { File("$it/magisk").exists() }
?.any { File("$it/su").exists() } == true) {
MagiskDialog(this).apply {
setTitle(CoreR.string.unsupport_general_title)
setMessage(CoreR.string.unsupport_other_su_msg)
setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok }
setCancelable(false)
}.show()
?.filterNot { java.io.File("$it/magisk").exists() }
?.any { java.io.File("$it/su").exists() } == true) {
messages.add(CoreR.string.unsupport_general_title to CoreR.string.unsupport_other_su_msg)
}
if (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0) {
MagiskDialog(this).apply {
setTitle(CoreR.string.unsupport_general_title)
setMessage(CoreR.string.unsupport_system_app_msg)
setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok }
setCancelable(false)
}.show()
messages.add(CoreR.string.unsupport_general_title to CoreR.string.unsupport_system_app_msg)
}
if (applicationInfo.flags and ApplicationInfo.FLAG_EXTERNAL_STORAGE != 0) {
messages.add(CoreR.string.unsupport_general_title to CoreR.string.unsupport_external_storage_msg)
}
if (applicationInfo.flags and ApplicationInfo.FLAG_EXTERNAL_STORAGE != 0) {
MagiskDialog(this).apply {
setTitle(CoreR.string.unsupport_general_title)
setMessage(CoreR.string.unsupport_external_storage_msg)
setButton(MagiskDialog.ButtonType.POSITIVE) { text = android.R.string.ok }
setCancelable(false)
}.show()
if (messages.isNotEmpty()) {
showUnsupported.value = messages
}
}
private fun askForHomeShortcut() {
if (isRunningAsStub && !Config.askedHome &&
ShortcutManagerCompat.isRequestPinShortcutSupported(this)) {
// Ask and show dialog
Config.askedHome = true
MagiskDialog(this).apply {
setTitle(CoreR.string.add_shortcut_title)
setMessage(CoreR.string.add_shortcut_msg)
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = android.R.string.cancel
}
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = android.R.string.ok
onClick {
Shortcuts.addHomeIcon(this@MainActivity)
}
}
setCancelable(true)
}.show()
showShortcutPrompt.value = true
}
}
}
@Composable
private fun MainActivityDialogs(activity: MainActivity) {
val showInvalid by activity.showInvalidState.collectAsState()
val unsupportedMessages by activity.showUnsupported.collectAsState()
val showShortcut by activity.showShortcutPrompt.collectAsState()
val invalidDialog = com.topjohnwu.magisk.ui.component.rememberConfirmDialog(
onConfirm = {
activity.showInvalidState.value = false
activity.handleInvalidStateInstall()
},
onDismiss = {}
)
LaunchedEffect(showInvalid) {
if (showInvalid) {
invalidDialog.showConfirm(
title = activity.getString(CoreR.string.unsupport_nonroot_stub_title),
content = activity.getString(CoreR.string.unsupport_nonroot_stub_msg),
confirm = activity.getString(CoreR.string.install),
)
}
}
for ((index, pair) in unsupportedMessages.withIndex()) {
val (titleRes, msgRes) = pair
val show = rememberSaveable { androidx.compose.runtime.mutableStateOf(true) }
com.topjohnwu.magisk.ui.component.rememberConfirmDialog(
onConfirm = { show.value = false },
).also { dialog ->
LaunchedEffect(Unit) {
dialog.showConfirm(
title = activity.getString(titleRes),
content = activity.getString(msgRes),
)
}
}
}
val shortcutDialog = com.topjohnwu.magisk.ui.component.rememberConfirmDialog(
onConfirm = {
activity.showShortcutPrompt.value = false
Shortcuts.addHomeIcon(activity)
},
onDismiss = { activity.showShortcutPrompt.value = false }
)
LaunchedEffect(showShortcut) {
if (showShortcut) {
shortcutDialog.showConfirm(
title = activity.getString(CoreR.string.add_shortcut_title),
content = activity.getString(CoreR.string.add_shortcut_msg),
)
}
}
}

View File

@@ -0,0 +1,228 @@
package com.topjohnwu.magisk.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.VMFactory
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.base.SplashScreenHost
import com.topjohnwu.magisk.core.model.module.LocalModule
import com.topjohnwu.magisk.ui.home.HomeScreen
import com.topjohnwu.magisk.ui.home.HomeViewModel
import com.topjohnwu.magisk.ui.install.InstallViewModel
import com.topjohnwu.magisk.ui.log.LogScreen
import com.topjohnwu.magisk.ui.log.LogViewModel
import com.topjohnwu.magisk.ui.module.ModuleScreen
import com.topjohnwu.magisk.ui.module.ModuleViewModel
import com.topjohnwu.magisk.ui.navigation.CollectNavEvents
import com.topjohnwu.magisk.ui.navigation.LocalNavigator
import com.topjohnwu.magisk.ui.settings.SettingsScreen
import com.topjohnwu.magisk.ui.settings.SettingsViewModel
import com.topjohnwu.magisk.ui.superuser.SuperuserScreen
import com.topjohnwu.magisk.ui.superuser.SuperuserViewModel
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
import com.topjohnwu.magisk.core.R as CoreR
enum class Tab(val titleRes: Int, val iconRes: Int) {
MODULES(CoreR.string.modules, R.drawable.ic_module_outlined_md2),
SUPERUSER(CoreR.string.superuser, R.drawable.ic_superuser_outlined_md2),
HOME(CoreR.string.section_home, R.drawable.ic_home_outlined_md2),
LOG(CoreR.string.logs, R.drawable.ic_bug_outlined_md2),
SETTINGS(CoreR.string.settings, R.drawable.ic_settings_outlined_md2);
}
@Composable
fun MainScreen(initialTab: Int = Tab.HOME.ordinal) {
val navigator = LocalNavigator.current
val visibleTabs = remember {
Tab.entries.filter { tab ->
when (tab) {
Tab.SUPERUSER -> Info.showSuperUser
Tab.MODULES -> Info.env.isActive && LocalModule.loaded()
else -> true
}
}
}
val initialPage = visibleTabs.indexOf(Tab.entries[initialTab]).coerceAtLeast(0)
val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { visibleTabs.size })
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = visibleTabs.size - 1,
userScrollEnabled = true,
) { page ->
when (visibleTabs[page]) {
Tab.HOME -> {
val vm: HomeViewModel = viewModel(factory = VMFactory)
val installVm: InstallViewModel = viewModel(factory = VMFactory)
LaunchedEffect(Unit) { vm.startLoading() }
CollectNavEvents(vm, navigator)
CollectNavEvents(installVm, navigator)
HomeScreen(vm, installVm)
}
Tab.SUPERUSER -> {
val activity = LocalContext.current as MainActivity
val vm: SuperuserViewModel = viewModel(viewModelStoreOwner = activity, factory = VMFactory)
LaunchedEffect(Unit) {
vm.authenticate = { onSuccess ->
activity.extension.withAuthentication { if (it) onSuccess() }
}
vm.startLoading()
}
SuperuserScreen(vm)
}
Tab.LOG -> {
val vm: LogViewModel = viewModel(factory = VMFactory)
LaunchedEffect(Unit) { vm.startLoading() }
LogScreen(vm)
}
Tab.MODULES -> {
val vm: ModuleViewModel = viewModel(factory = VMFactory)
LaunchedEffect(Unit) { vm.startLoading() }
CollectNavEvents(vm, navigator)
ModuleScreen(vm)
}
Tab.SETTINGS -> {
val activity = LocalContext.current as MainActivity
val vm: SettingsViewModel = viewModel(factory = VMFactory)
LaunchedEffect(Unit) {
vm.authenticate = { onSuccess ->
activity.extension.withAuthentication { if (it) onSuccess() }
}
}
CollectNavEvents(vm, navigator)
SettingsScreen(vm)
}
}
}
FloatingNavigationBar(
pagerState = pagerState,
visibleTabs = visibleTabs,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
@Composable
private fun FloatingNavigationBar(
pagerState: PagerState,
visibleTabs: List<Tab>,
modifier: Modifier = Modifier
) {
val scope = rememberCoroutineScope()
val shape = RoundedCornerShape(28.dp)
val navBarInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
Row(
modifier = modifier
.padding(bottom = navBarInset + 12.dp, start = 24.dp, end = 24.dp)
.shadow(elevation = 6.dp, shape = shape)
.clip(shape)
.background(MiuixTheme.colorScheme.surfaceContainer)
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
visibleTabs.forEachIndexed { index, tab ->
FloatingNavItem(
icon = ImageVector.vectorResource(tab.iconRes),
label = stringResource(tab.titleRes),
selected = pagerState.currentPage == index,
enabled = true,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
private fun FloatingNavItem(
icon: ImageVector,
label: String,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val contentColor by animateColorAsState(
targetValue = when {
!enabled -> MiuixTheme.colorScheme.disabledOnSecondaryVariant
selected -> MiuixTheme.colorScheme.primary
else -> MiuixTheme.colorScheme.onSurfaceVariantActions
},
animationSpec = tween(200),
label = "navItemColor"
)
Column(
modifier = modifier
.clickable(
enabled = enabled,
indication = null,
interactionSource = remember { MutableInteractionSource() },
role = Role.Tab,
onClick = onClick,
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(24.dp),
tint = contentColor,
)
Spacer(Modifier.height(2.dp))
Text(
text = label,
fontSize = 11.sp,
color = contentColor,
)
}
}

View File

@@ -0,0 +1,374 @@
package com.topjohnwu.magisk.ui.component
import android.widget.TextView
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import com.topjohnwu.magisk.core.di.ServiceLocator
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import timber.log.Timber
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.io.IOException
import kotlin.coroutines.resume
sealed interface ConfirmResult {
data object Confirmed : ConfirmResult
data object Canceled : ConfirmResult
}
data class DialogVisuals(
val title: String = "",
val content: String? = null,
val markdown: Boolean = false,
val confirm: String? = null,
val dismiss: String? = null,
)
interface LoadingDialogHandle {
suspend fun <R> withLoading(block: suspend () -> R): R
}
interface ConfirmDialogHandle {
fun showConfirm(
title: String,
content: String? = null,
markdown: Boolean = false,
confirm: String? = null,
dismiss: String? = null
)
suspend fun awaitConfirm(
title: String,
content: String? = null,
markdown: Boolean = false,
confirm: String? = null,
dismiss: String? = null
): ConfirmResult
}
private class LoadingDialogHandleImpl(
private val visible: MutableState<Boolean>,
private val coroutineScope: CoroutineScope
) : LoadingDialogHandle {
override suspend fun <R> withLoading(block: suspend () -> R): R {
return coroutineScope.async {
try {
visible.value = true
block()
} finally {
visible.value = false
}
}.await()
}
}
private class ConfirmDialogHandleImpl(
private val visible: MutableState<Boolean>,
private val coroutineScope: CoroutineScope,
private val callback: ConfirmCallback,
private val resultChannel: Channel<ConfirmResult>
) : ConfirmDialogHandle {
var visuals by mutableStateOf(DialogVisuals())
private set
private var awaitContinuation: CancellableContinuation<ConfirmResult>? = null
init {
coroutineScope.launch {
resultChannel
.consumeAsFlow()
.onEach { result ->
awaitContinuation?.let {
awaitContinuation = null
if (it.isActive) it.resume(result)
}
}
.onEach { visible.value = false }
.collect { result ->
when (result) {
ConfirmResult.Confirmed -> callback.onConfirm?.invoke()
ConfirmResult.Canceled -> callback.onDismiss?.invoke()
}
}
}
}
override fun showConfirm(
title: String,
content: String?,
markdown: Boolean,
confirm: String?,
dismiss: String?
) {
coroutineScope.launch {
visuals = DialogVisuals(title, content, markdown, confirm, dismiss)
visible.value = true
}
}
override suspend fun awaitConfirm(
title: String,
content: String?,
markdown: Boolean,
confirm: String?,
dismiss: String?
): ConfirmResult {
coroutineScope.launch {
visuals = DialogVisuals(title, content, markdown, confirm, dismiss)
visible.value = true
}
return suspendCancellableCoroutine { cont ->
awaitContinuation = cont.apply {
invokeOnCancellation { visible.value = false }
}
}
}
}
interface ConfirmCallback {
val onConfirm: (() -> Unit)?
val onDismiss: (() -> Unit)?
}
@Composable
fun rememberConfirmCallback(
onConfirm: (() -> Unit)? = null,
onDismiss: (() -> Unit)? = null
): ConfirmCallback {
val currentOnConfirm by rememberUpdatedState(onConfirm)
val currentOnDismiss by rememberUpdatedState(onDismiss)
return remember {
object : ConfirmCallback {
override val onConfirm get() = currentOnConfirm
override val onDismiss get() = currentOnDismiss
}
}
}
@Composable
fun rememberLoadingDialog(): LoadingDialogHandle {
val visible = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LoadingDialog(visible)
return remember { LoadingDialogHandleImpl(visible, scope) }
}
@Composable
fun rememberConfirmDialog(
onConfirm: (() -> Unit)? = null,
onDismiss: (() -> Unit)? = null
): ConfirmDialogHandle {
return rememberConfirmDialog(rememberConfirmCallback(onConfirm, onDismiss))
}
@Composable
fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle {
val visible = rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val resultChannel = remember { Channel<ConfirmResult>() }
val handle = remember {
ConfirmDialogHandleImpl(visible, scope, callback, resultChannel)
}
if (visible.value) {
ConfirmDialogContent(
visuals = handle.visuals,
confirm = { scope.launch { resultChannel.send(ConfirmResult.Confirmed) } },
dismiss = { scope.launch { resultChannel.send(ConfirmResult.Canceled) } },
showDialog = visible
)
}
return handle
}
@Composable
private fun LoadingDialog(showDialog: MutableState<Boolean>) {
SuperDialog(
show = showDialog,
onDismissRequest = {},
content = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
InfiniteProgressIndicator(
color = MiuixTheme.colorScheme.onBackground
)
Text(
modifier = Modifier.padding(start = 12.dp),
text = stringResource(com.topjohnwu.magisk.core.R.string.loading),
)
}
}
}
)
}
@Composable
private fun ConfirmDialogContent(
visuals: DialogVisuals,
confirm: () -> Unit,
dismiss: () -> Unit,
showDialog: MutableState<Boolean>
) {
SuperDialog(
modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
show = showDialog,
title = visuals.title,
onDismissRequest = {
dismiss()
showDialog.value = false
},
content = {
Layout(
content = {
visuals.content?.let { content ->
if (visuals.markdown) {
MarkdownText(content)
} else {
Text(
text = content,
color = MiuixTheme.colorScheme.onSurface,
)
}
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(top = 12.dp)
) {
TextButton(
text = visuals.dismiss
?: stringResource(android.R.string.cancel),
onClick = {
dismiss()
showDialog.value = false
},
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(20.dp))
TextButton(
text = visuals.confirm
?: stringResource(android.R.string.ok),
onClick = {
confirm()
showDialog.value = false
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary()
)
}
}
) { measurables, constraints ->
if (measurables.size != 2) {
val button = measurables[0].measure(constraints)
layout(constraints.maxWidth, button.height) {
button.place(0, 0)
}
} else {
val button = measurables[1].measure(constraints)
val content = measurables[0].measure(
constraints.copy(maxHeight = constraints.maxHeight - button.height)
)
layout(constraints.maxWidth, content.height + button.height) {
content.place(0, 0)
button.place(0, content.height)
}
}
}
}
)
}
@Composable
fun MarkdownText(text: String) {
val contentColor = MiuixTheme.colorScheme.onBackground.toArgb()
AndroidView(
factory = { context ->
TextView(context).apply {
setTextColor(contentColor)
ServiceLocator.markwon.setMarkdown(this, text)
}
},
update = { textView ->
textView.setTextColor(contentColor)
ServiceLocator.markwon.setMarkdown(textView, text)
},
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
)
}
@Composable
fun MarkdownTextAsync(getMarkdownText: suspend () -> String) {
var mdText by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
try {
mdText = withContext(Dispatchers.IO) { getMarkdownText() }
} catch (e: IOException) {
Timber.e(e)
error = true
}
}
when {
error -> Text(stringResource(com.topjohnwu.magisk.core.R.string.download_file_error))
mdText != null -> MarkdownText(mdText!!)
else -> Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
InfiniteProgressIndicator(color = MiuixTheme.colorScheme.onBackground)
}
}
}

View File

@@ -0,0 +1,84 @@
package com.topjohnwu.magisk.ui.component
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
object ListPopupDefaults {
val MenuPositionProvider = object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowBounds: IntRect,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
popupMargin: IntRect,
alignment: PopupPositionProvider.Align,
): IntOffset {
val resolved = alignment.resolve(layoutDirection)
val offsetX: Int
val offsetY: Int
when (resolved) {
PopupPositionProvider.Align.TopStart -> {
offsetX = anchorBounds.left + popupMargin.left
offsetY = anchorBounds.bottom + popupMargin.top
}
PopupPositionProvider.Align.TopEnd -> {
offsetX = anchorBounds.right - popupContentSize.width - popupMargin.right
offsetY = anchorBounds.bottom + popupMargin.top
}
PopupPositionProvider.Align.BottomStart -> {
offsetX = anchorBounds.left + popupMargin.left
offsetY = anchorBounds.top - popupContentSize.height - popupMargin.bottom
}
PopupPositionProvider.Align.BottomEnd -> {
offsetX = anchorBounds.right - popupContentSize.width - popupMargin.right
offsetY = anchorBounds.top - popupContentSize.height - popupMargin.bottom
}
else -> {
offsetX = if (resolved == PopupPositionProvider.Align.End) {
anchorBounds.right - popupContentSize.width - popupMargin.right
} else {
anchorBounds.left + popupMargin.left
}
offsetY = if (windowBounds.bottom - anchorBounds.bottom > popupContentSize.height) {
anchorBounds.bottom + popupMargin.bottom
} else if (anchorBounds.top - windowBounds.top > popupContentSize.height) {
anchorBounds.top - popupContentSize.height - popupMargin.top
} else {
anchorBounds.top + anchorBounds.height / 2 - popupContentSize.height / 2
}
}
}
return IntOffset(
x = offsetX.coerceIn(
windowBounds.left,
(windowBounds.right - popupContentSize.width - popupMargin.right)
.coerceAtLeast(windowBounds.left),
),
y = offsetY.coerceIn(
(windowBounds.top + popupMargin.top)
.coerceAtMost(windowBounds.bottom - popupContentSize.height - popupMargin.bottom),
windowBounds.bottom - popupContentSize.height - popupMargin.bottom,
),
)
}
override fun getMargins(): PaddingValues = PaddingValues(start = 20.dp)
}
}
private fun PopupPositionProvider.Align.resolve(layoutDirection: LayoutDirection): PopupPositionProvider.Align {
if (layoutDirection == LayoutDirection.Ltr) return this
return when (this) {
PopupPositionProvider.Align.Start -> PopupPositionProvider.Align.End
PopupPositionProvider.Align.End -> PopupPositionProvider.Align.Start
PopupPositionProvider.Align.TopStart -> PopupPositionProvider.Align.TopEnd
PopupPositionProvider.Align.TopEnd -> PopupPositionProvider.Align.TopStart
PopupPositionProvider.Align.BottomStart -> PopupPositionProvider.Align.BottomEnd
PopupPositionProvider.Align.BottomEnd -> PopupPositionProvider.Align.BottomStart
}
}

View File

@@ -46,6 +46,10 @@ class AppProcessInfo(
val label = info.getLabel(pm)
val iconImage: Drawable = runCatching { info.loadIcon(pm) }.getOrDefault(pm.defaultActivityIcon)
val packageName: String get() = info.packageName
var firstInstallTime: Long = 0L
private set
var lastUpdateTime: Long = 0L
private set
val processes = fetchProcesses(pm)
override fun compareTo(other: AppProcessInfo) = comparator.compare(this, other)
@@ -67,19 +71,30 @@ class AppProcessInfo(
private fun Array<out ComponentInfo>?.toProcessList() =
orEmpty().map { createProcess(it.getProcName()) }
private fun Array<ServiceInfo>?.toProcessList() = orEmpty().map {
if (it.isIsolated) {
if (it.useAppZygote) {
val proc = info.processName ?: info.packageName
createProcess("${proc}_zygote")
private fun Array<ServiceInfo>?.toProcessList(): List<ProcessInfo> {
if (this == null) return emptyList()
val result = mutableListOf<ProcessInfo>()
var hasIsolated = false
for (si in this) {
if (si.isIsolated) {
if (si.useAppZygote) {
val proc = info.processName ?: info.packageName
result.add(createProcess("${proc}_zygote"))
} else {
hasIsolated = true
}
} else {
val proc = if (SDK_INT >= Build.VERSION_CODES.Q)
"${it.getProcName()}:${it.name}" else it.getProcName()
createProcess(proc, ISOLATED_MAGIC)
result.add(createProcess(si.getProcName()))
}
} else {
createProcess(it.getProcName())
}
if (hasIsolated) {
val prefix = "${info.processName ?: info.packageName}:"
val isEnabled = denyList.any {
it.packageName == ISOLATED_MAGIC && it.process.startsWith(prefix)
}
result.add(ProcessInfo(prefix, ISOLATED_MAGIC, isEnabled))
}
return result
}
private fun fetchProcesses(pm: PackageManager): Collection<ProcessInfo> {
@@ -92,6 +107,9 @@ class AppProcessInfo(
pm.getPackageArchiveInfo(info.sourceDir, flag) ?: return emptyList()
}
firstInstallTime = packageInfo.firstInstallTime
lastUpdateTime = packageInfo.lastUpdateTime
val processSet = TreeSet<ProcessInfo>(compareBy({ it.name }, { it.isIsolated }))
processSet += packageInfo.activities.toProcessList()
processSet += packageInfo.services.toProcessList()

View File

@@ -1,99 +0,0 @@
package com.topjohnwu.magisk.ui.deny
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
import androidx.recyclerview.widget.RecyclerView
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.core.ktx.hideKeyboard
import com.topjohnwu.magisk.databinding.FragmentDenyMd2Binding
import rikka.recyclerview.addEdgeSpacing
import rikka.recyclerview.addItemSpacing
import rikka.recyclerview.fixEdgeEffect
import com.topjohnwu.magisk.core.R as CoreR
class DenyListFragment : BaseFragment<FragmentDenyMd2Binding>(), MenuProvider {
override val layoutRes = R.layout.fragment_deny_md2
override val viewModel by viewModel<DenyListViewModel>()
private lateinit var searchView: SearchView
override fun onStart() {
super.onStart()
activity?.setTitle(CoreR.string.denylist)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.appList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState != RecyclerView.SCROLL_STATE_IDLE) activity?.hideKeyboard()
}
})
binding.appList.apply {
addEdgeSpacing(top = R.dimen.l_50, bottom = R.dimen.l1)
addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1)
fixEdgeEffect()
}
}
override fun onPreBind(binding: FragmentDenyMd2Binding) = Unit
override fun onBackPressed(): Boolean {
if (searchView.isIconfiedByDefault && !searchView.isIconified) {
searchView.isIconified = true
return true
}
return super.onBackPressed()
}
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_deny_md2, menu)
searchView = menu.findItem(R.id.action_search).actionView as SearchView
searchView.queryHint = searchView.context.getString(CoreR.string.hide_filter_hint)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
viewModel.query = query ?: ""
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.query = newText ?: ""
return true
}
})
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_show_system -> {
val check = !item.isChecked
viewModel.isShowSystem = check
item.isChecked = check
return true
}
R.id.action_show_OS -> {
val check = !item.isChecked
viewModel.isShowOS = check
item.isChecked = check
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onPrepareMenu(menu: Menu) {
val showSystem = menu.findItem(R.id.action_show_system)
val showOS = menu.findItem(R.id.action_show_OS)
showOS.isEnabled = showSystem.isChecked
}
}

View File

@@ -1,130 +0,0 @@
package com.topjohnwu.magisk.ui.deny
import android.view.View
import android.view.ViewGroup
import androidx.databinding.Bindable
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.startAnimations
import com.topjohnwu.magisk.databinding.DiffItem
import com.topjohnwu.magisk.databinding.ObservableRvItem
import com.topjohnwu.magisk.databinding.addOnPropertyChangedCallback
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.superuser.Shell
import kotlin.math.roundToInt
class DenyListRvItem(
val info: AppProcessInfo
) : ObservableRvItem(), DiffItem<DenyListRvItem>, Comparable<DenyListRvItem> {
override val layoutRes get() = R.layout.item_hide_md2
val processes = info.processes.map { ProcessRvItem(it) }
@get:Bindable
var isExpanded = false
set(value) = set(value, field, { field = it }, BR.expanded)
var itemsChecked = 0
set(value) = set(value, field, { field = it }, BR.checkedPercent)
val isChecked get() = itemsChecked != 0
@get:Bindable
val checkedPercent get() = (itemsChecked.toFloat() / processes.size * 100).roundToInt()
private var _state: Boolean? = false
set(value) = set(value, field, { field = it }, BR.state)
@get:Bindable
var state: Boolean?
get() = _state
set(value) = set(value, _state, { _state = it }, BR.state) {
if (value == true) {
processes
.filterNot { it.isEnabled }
.filter { isExpanded || it.defaultSelection }
.forEach { it.toggle() }
} else {
Shell.cmd("magisk --denylist rm ${info.packageName}").submit()
processes.filter { it.isEnabled }.forEach {
if (it.process.isIsolated) {
it.toggle()
} else {
it.isEnabled = !it.isEnabled
notifyPropertyChanged(BR.enabled)
}
}
}
}
init {
processes.forEach { it.addOnPropertyChangedCallback(BR.enabled) { recalculateChecked() } }
addOnPropertyChangedCallback(BR.expanded) { recalculateChecked() }
recalculateChecked()
}
fun toggleExpand(v: View) {
(v.parent as? ViewGroup)?.startAnimations()
isExpanded = !isExpanded
}
private fun recalculateChecked() {
itemsChecked = processes.count { it.isEnabled }
_state = if (isExpanded) {
when (itemsChecked) {
0 -> false
processes.size -> true
else -> null
}
} else {
val defaultProcesses = processes.filter { it.defaultSelection }
when (defaultProcesses.count { it.isEnabled }) {
0 -> false
defaultProcesses.size -> true
else -> null
}
}
}
override fun compareTo(other: DenyListRvItem) = comparator.compare(this, other)
companion object {
private val comparator = compareBy<DenyListRvItem>(
{ it.itemsChecked == 0 },
{ it.info }
)
}
}
class ProcessRvItem(
val process: ProcessInfo
) : ObservableRvItem(), DiffItem<ProcessRvItem> {
override val layoutRes get() = R.layout.item_hide_process_md2
val displayName = if (process.isIsolated) "(isolated) ${process.name}" else process.name
@get:Bindable
var isEnabled
get() = process.isEnabled
set(value) = set(value, process.isEnabled, { process.isEnabled = it }, BR.enabled) {
val arg = if (it) "add" else "rm"
val (name, pkg) = process
Shell.cmd("magisk --denylist $arg $pkg \'$name\'").submit()
}
fun toggle() {
isEnabled = !isEnabled
}
val defaultSelection get() =
process.isIsolated || process.isAppZygote || process.name == process.packageName
override fun itemSameAs(other: ProcessRvItem) =
process.name == other.process.name && process.packageName == other.process.packageName
override fun contentSameAs(other: ProcessRvItem) =
process.isEnabled == other.process.isEnabled
}

View File

@@ -0,0 +1,322 @@
package com.topjohnwu.magisk.ui.deny
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.topjohnwu.magisk.ui.component.ListPopupDefaults.MenuPositionProvider
import com.topjohnwu.magisk.ui.util.rememberDrawablePainter
import androidx.compose.ui.state.ToggleableState
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Checkbox
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.DropdownImpl
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.LinearProgressIndicator
import top.yukonga.miuix.kmp.basic.ListPopupColumn
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperListPopup
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Back
import top.yukonga.miuix.kmp.icon.extended.Sort
import top.yukonga.miuix.kmp.icon.extended.Tune
import top.yukonga.miuix.kmp.theme.MiuixTheme
import com.topjohnwu.magisk.core.R as CoreR
@Composable
fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) {
val loading by viewModel.loading.collectAsState()
val apps by viewModel.filteredApps.collectAsState()
val query by viewModel.query.collectAsState()
val showSystem by viewModel.showSystem.collectAsState()
val showOS by viewModel.showOS.collectAsState()
val sortBy by viewModel.sortBy.collectAsState()
val sortReverse by viewModel.sortReverse.collectAsState()
val showSortMenu = remember { mutableStateOf(false) }
val showFilterMenu = remember { mutableStateOf(false) }
val scrollBehavior = MiuixScrollBehavior()
Scaffold(
topBar = {
TopAppBar(
title = stringResource(CoreR.string.denylist),
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = onBack
) {
Icon(
imageVector = MiuixIcons.Back,
contentDescription = null,
tint = MiuixTheme.colorScheme.onBackground
)
}
},
actions = {
Box {
IconButton(
onClick = { showSortMenu.value = true },
holdDownState = showSortMenu.value,
) {
Icon(
imageVector = MiuixIcons.Sort,
contentDescription = stringResource(CoreR.string.menu_sort),
)
}
SuperListPopup(
show = showSortMenu,
popupPositionProvider = MenuPositionProvider,
alignment = PopupPositionProvider.Align.End,
onDismissRequest = { showSortMenu.value = false }
) {
ListPopupColumn {
val sortOptions = listOf(
CoreR.string.sort_by_name to SortBy.NAME,
CoreR.string.sort_by_package_name to SortBy.PACKAGE_NAME,
CoreR.string.sort_by_install_time to SortBy.INSTALL_TIME,
CoreR.string.sort_by_update_time to SortBy.UPDATE_TIME,
)
val totalSize = sortOptions.size + 1
sortOptions.forEachIndexed { index, (resId, sort) ->
DropdownImpl(
text = stringResource(resId),
optionSize = totalSize,
isSelected = sortBy == sort,
index = index,
onSelectedIndexChange = {
viewModel.setSortBy(sort)
showSortMenu.value = false
}
)
}
DropdownImpl(
text = stringResource(CoreR.string.sort_reverse),
optionSize = totalSize,
isSelected = sortReverse,
index = sortOptions.size,
onSelectedIndexChange = {
viewModel.toggleSortReverse()
showSortMenu.value = false
}
)
}
}
}
Box {
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = { showFilterMenu.value = true },
holdDownState = showFilterMenu.value,
) {
Icon(
imageVector = MiuixIcons.Tune,
contentDescription = stringResource(CoreR.string.hide_filter_hint),
)
}
SuperListPopup(
show = showFilterMenu,
popupPositionProvider = MenuPositionProvider,
alignment = PopupPositionProvider.Align.End,
onDismissRequest = { showFilterMenu.value = false }
) {
ListPopupColumn {
DropdownImpl(
text = stringResource(CoreR.string.show_system_app),
optionSize = 2,
isSelected = showSystem,
index = 0,
onSelectedIndexChange = {
viewModel.setShowSystem(!showSystem)
showFilterMenu.value = false
}
)
DropdownImpl(
text = stringResource(CoreR.string.show_os_app),
optionSize = 2,
isSelected = showOS,
index = 1,
onSelectedIndexChange = {
if (!showOS && !showSystem) {
viewModel.setShowSystem(true)
}
viewModel.setShowOS(!showOS)
showFilterMenu.value = false
}
)
}
}
}
},
scrollBehavior = scrollBehavior
)
},
popupHost = { }
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
SearchInput(
query = query,
onQueryChange = viewModel::setQuery,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
)
if (loading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(CoreR.string.loading),
style = MiuixTheme.textStyles.headline2
)
Spacer(Modifier.height(16.dp))
CircularProgressIndicator()
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = apps,
key = { it.info.packageName }
) { app ->
DenyAppCard(app)
}
}
}
}
}
}
@Composable
private fun SearchInput(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
top.yukonga.miuix.kmp.basic.TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier,
label = stringResource(CoreR.string.hide_filter_hint)
)
}
@Composable
private fun DenyAppCard(app: DenyAppState) {
Card(modifier = Modifier.fillMaxWidth()) {
Column {
if (app.checkedPercent > 0f) {
LinearProgressIndicator(
progress = app.checkedPercent,
modifier = Modifier.fillMaxWidth()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { app.isExpanded = !app.isExpanded }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = rememberDrawablePainter(app.info.iconImage),
contentDescription = app.info.label,
modifier = Modifier.size(40.dp)
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = app.info.label,
style = MiuixTheme.textStyles.body1,
)
Text(
text = app.info.packageName,
style = MiuixTheme.textStyles.body2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
)
}
Spacer(Modifier.width(8.dp))
Checkbox(
state = when {
app.itemsChecked == 0 -> ToggleableState.Off
app.checkedPercent < 1f -> ToggleableState.Indeterminate
else -> ToggleableState.On
},
onClick = { app.toggleAll() }
)
}
AnimatedVisibility(visible = app.isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 52.dp)
) {
app.processes.forEach { proc ->
ProcessRow(proc)
}
}
}
}
}
}
@Composable
private fun ProcessRow(proc: DenyProcessState) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { proc.toggle() }
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = proc.displayName,
style = MiuixTheme.textStyles.body2,
color = if (proc.isEnabled) MiuixTheme.colorScheme.onSurface
else MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
Checkbox(
state = ToggleableState(proc.isEnabled),
onClick = { proc.toggle() }
)
}
}

View File

@@ -2,54 +2,95 @@ package com.topjohnwu.magisk.ui.deny
import android.annotation.SuppressLint
import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES
import androidx.databinding.Bindable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewModelScope
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.ktx.concurrentMap
import com.topjohnwu.magisk.databinding.bindExtra
import com.topjohnwu.magisk.databinding.filterList
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.toCollection
import kotlinx.coroutines.withContext
enum class SortBy { NAME, PACKAGE_NAME, INSTALL_TIME, UPDATE_TIME }
class DenyListViewModel : AsyncLoadViewModel() {
var isShowSystem = false
set(value) {
field = value
doQuery(query)
private val _loading = MutableStateFlow(true)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private val _allApps = MutableStateFlow<List<DenyAppState>>(emptyList())
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _showSystem = MutableStateFlow(false)
val showSystem: StateFlow<Boolean> = _showSystem.asStateFlow()
private val _showOS = MutableStateFlow(false)
val showOS: StateFlow<Boolean> = _showOS.asStateFlow()
private val _sortBy = MutableStateFlow(SortBy.NAME)
val sortBy: StateFlow<SortBy> = _sortBy.asStateFlow()
private val _sortReverse = MutableStateFlow(false)
val sortReverse: StateFlow<Boolean> = _sortReverse.asStateFlow()
val filteredApps: StateFlow<List<DenyAppState>> = combine(
_allApps, _query, _showSystem, _showOS, _sortBy, _sortReverse
) { args ->
@Suppress("UNCHECKED_CAST")
val apps = args[0] as List<DenyAppState>
val q = args[1] as String
val showSys = args[2] as Boolean
val showOS = args[3] as Boolean
val sort = args[4] as SortBy
val reverse = args[5] as Boolean
val filtered = apps.filter { app ->
val passFilter = app.isChecked ||
((showSys || !app.info.isSystemApp()) &&
((showSys && showOS) || app.info.isApp()))
val passQuery = q.isBlank() ||
app.info.label.contains(q, true) ||
app.info.packageName.contains(q, true) ||
app.processes.any { it.process.name.contains(q, true) }
passFilter && passQuery
}
var isShowOS = false
set(value) {
field = value
doQuery(query)
val secondary: Comparator<DenyAppState> = when (sort) {
SortBy.NAME -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.info.label }
SortBy.PACKAGE_NAME -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.info.packageName }
SortBy.INSTALL_TIME -> compareByDescending { it.info.firstInstallTime }
SortBy.UPDATE_TIME -> compareByDescending { it.info.lastUpdateTime }
}
val comparator = compareBy<DenyAppState> { it.itemsChecked == 0 }
.then(if (reverse) secondary.reversed() else secondary)
filtered.sortedWith(comparator)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
var query = ""
set(value) {
field = value
doQuery(value)
}
val items = filterList<DenyListRvItem>(viewModelScope)
val extraBindings = bindExtra {
it.put(BR.viewModel, this)
fun setQuery(q: String) { _query.value = q }
fun setShowSystem(v: Boolean) {
_showSystem.value = v
if (!v) _showOS.value = false
}
@get:Bindable
var loading = true
private set(value) = set(value, field, { field = it }, BR.loading)
fun setShowOS(v: Boolean) { _showOS.value = v }
fun setSortBy(s: SortBy) { _sortBy.value = s }
fun toggleSortReverse() { _sortReverse.value = !_sortReverse.value }
@SuppressLint("InlinedApi")
override suspend fun doLoadWork() {
loading = true
_loading.value = true
val apps = withContext(Dispatchers.Default) {
val pm = AppContext.packageManager
val denyList = Shell.cmd("magisk --denylist ls").exec().out
@@ -59,31 +100,63 @@ class DenyListViewModel : AsyncLoadViewModel() {
.filter { AppContext.packageName != it.packageName }
.concurrentMap { AppProcessInfo(it, pm, denyList) }
.filter { it.processes.isNotEmpty() }
.concurrentMap { DenyListRvItem(it) }
.concurrentMap { DenyAppState(it) }
.toCollection(ArrayList(size))
}
apps.sort()
apps.sortWith(compareBy(
{ it.processes.count { p -> p.isEnabled } == 0 },
{ it.info }
))
apps
}
items.set(apps)
doQuery(query)
}
private fun doQuery(s: String) {
items.filter {
fun filterSystem() = isShowSystem || !it.info.isSystemApp()
fun filterOS() = (isShowSystem && isShowOS) || it.info.isApp()
fun filterQuery(): Boolean {
fun inName() = it.info.label.contains(s, true)
fun inPackage() = it.info.packageName.contains(s, true)
fun inProcesses() = it.processes.any { p -> p.process.name.contains(s, true) }
return inName() || inPackage() || inProcesses()
}
(it.isChecked || (filterSystem() && filterOS())) && filterQuery()
}
loading = false
_allApps.value = apps
_loading.value = false
}
}
class DenyAppState(val info: AppProcessInfo) : Comparable<DenyAppState> {
val processes = info.processes.map { DenyProcessState(it) }
var isExpanded by mutableStateOf(false)
val itemsChecked: Int get() = processes.count { it.isEnabled }
val isChecked: Boolean get() = itemsChecked > 0
val checkedPercent: Float get() = if (processes.isEmpty()) 0f else itemsChecked.toFloat() / processes.size
fun toggleAll() {
if (isChecked) {
Shell.cmd("magisk --denylist rm ${info.packageName}").submit()
processes.filter { it.isEnabled }.forEach { proc ->
if (proc.process.isIsolated) {
proc.toggle()
} else {
proc.isEnabled = false
}
}
} else {
processes.filterNot { it.isEnabled }.forEach { it.toggle() }
}
}
override fun compareTo(other: DenyAppState) = comparator.compare(this, other)
companion object {
private val comparator = compareBy<DenyAppState>(
{ it.itemsChecked == 0 },
{ it.info }
)
}
}
class DenyProcessState(val process: ProcessInfo) {
var isEnabled by mutableStateOf(process.isEnabled)
val displayName: String =
if (process.isIsolated) "(isolated) ${process.name}*" else process.name
fun toggle() {
isEnabled = !isEnabled
val arg = if (isEnabled) "add" else "rm"
val (name, pkg) = process
Shell.cmd("magisk --denylist $arg $pkg \'$name\'").submit()
}
}

View File

@@ -1,38 +0,0 @@
package com.topjohnwu.magisk.ui.flash
import android.view.View
import android.widget.TextView
import androidx.core.view.updateLayoutParams
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.RecyclerView
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.databinding.DiffItem
import com.topjohnwu.magisk.databinding.ItemWrapper
import com.topjohnwu.magisk.databinding.RvItem
import com.topjohnwu.magisk.databinding.ViewAwareItem
import kotlin.math.max
class ConsoleItem(
override val item: String
) : RvItem(), ViewAwareItem, DiffItem<ConsoleItem>, ItemWrapper<String> {
override val layoutRes = R.layout.item_console_md2
private var parentWidth = -1
override fun onBind(binding: ViewDataBinding, recyclerView: RecyclerView) {
if (parentWidth < 0)
parentWidth = (recyclerView.parent as View).width
val view = binding.root as TextView
view.measure(0, 0)
// We want our recyclerView at least as wide as screen
val desiredWidth = max(view.measuredWidth, parentWidth)
view.updateLayoutParams { width = desiredWidth }
if (recyclerView.width < desiredWidth) {
recyclerView.requestLayout()
}
}
}

View File

@@ -1,149 +0,0 @@
package com.topjohnwu.magisk.ui.flash
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.Bundle
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import androidx.navigation.NavDeepLinkBuilder
import com.topjohnwu.magisk.MainDirections
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.cmp
import com.topjohnwu.magisk.databinding.FragmentFlashMd2Binding
import com.topjohnwu.magisk.ui.MainActivity
import com.topjohnwu.magisk.core.R as CoreR
class FlashFragment : BaseFragment<FragmentFlashMd2Binding>(), MenuProvider {
override val layoutRes = R.layout.fragment_flash_md2
override val viewModel by viewModel<FlashViewModel>()
override val snackbarView: View get() = binding.snackbarContainer
override val snackbarAnchorView: View?
get() = if (binding.restartBtn.isShown) binding.restartBtn else super.snackbarAnchorView
private var defaultOrientation = -1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.args = FlashFragmentArgs.fromBundle(requireArguments())
}
override fun onStart() {
super.onStart()
activity?.setTitle(CoreR.string.flash_screen_title)
viewModel.state.observe(this) {
activity?.supportActionBar?.setSubtitle(
when (it) {
FlashViewModel.State.FLASHING -> CoreR.string.flashing
FlashViewModel.State.SUCCESS -> CoreR.string.done
FlashViewModel.State.FAILED -> CoreR.string.failure
}
)
if (it == FlashViewModel.State.SUCCESS && viewModel.showReboot) {
binding.restartBtn.apply {
if (!this.isVisible) this.show()
if (!this.isFocused) this.requestFocus()
}
}
}
}
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_flash, menu)
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return viewModel.onMenuItemClicked(item)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
defaultOrientation = activity?.requestedOrientation ?: -1
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
if (savedInstanceState == null) {
viewModel.startFlashing()
}
}
@SuppressLint("WrongConstant")
override fun onDestroyView() {
if (defaultOrientation != -1) {
activity?.requestedOrientation = defaultOrientation
}
super.onDestroyView()
}
override fun onKeyEvent(event: KeyEvent): Boolean {
return when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP,
KeyEvent.KEYCODE_VOLUME_DOWN -> true
else -> false
}
}
override fun onBackPressed(): Boolean {
if (viewModel.flashing.value == true)
return true
return super.onBackPressed()
}
override fun onPreBind(binding: FragmentFlashMd2Binding) = Unit
companion object {
private fun createIntent(context: Context, args: FlashFragmentArgs) =
NavDeepLinkBuilder(context)
.setGraph(R.navigation.main)
.setComponentName(MainActivity::class.java.cmp(context.packageName))
.setDestination(R.id.flashFragment)
.setArguments(args.toBundle())
.createPendingIntent()
private fun flashType(isSecondSlot: Boolean) =
if (isSecondSlot) Const.Value.FLASH_INACTIVE_SLOT else Const.Value.FLASH_MAGISK
/* Flashing is understood as installing / flashing magisk itself */
fun flash(isSecondSlot: Boolean) = MainDirections.actionFlashFragment(
action = flashType(isSecondSlot)
)
/* Patching is understood as injecting img files with magisk */
fun patch(uri: Uri) = MainDirections.actionFlashFragment(
action = Const.Value.PATCH_FILE,
additionalData = uri
)
/* Uninstalling is understood as removing magisk entirely */
fun uninstall() = MainDirections.actionFlashFragment(
action = Const.Value.UNINSTALL
)
/* Installing is understood as flashing modules / zips */
fun installIntent(context: Context, file: Uri) = FlashFragmentArgs(
action = Const.Value.FLASH_ZIP,
additionalData = file,
).let { createIntent(context, it) }
fun install(file: Uri) = MainDirections.actionFlashFragment(
action = Const.Value.FLASH_ZIP,
additionalData = file,
)
}
}

View File

@@ -0,0 +1,134 @@
package com.topjohnwu.magisk.ui.flash
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.ui.terminal.TerminalScreen
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Back
import top.yukonga.miuix.kmp.theme.MiuixTheme
import com.topjohnwu.magisk.core.R as CoreR
@Composable
fun FlashScreen(viewModel: FlashViewModel, action: String, onBack: () -> Unit) {
val flashState by viewModel.flashState.collectAsState()
val showReboot by viewModel.showReboot.collectAsState()
val finished = flashState != FlashViewModel.State.FLASHING
val useTerminal = action == Const.Value.FLASH_ZIP
val statusText = when (flashState) {
FlashViewModel.State.FLASHING -> stringResource(CoreR.string.flashing)
FlashViewModel.State.SUCCESS -> stringResource(CoreR.string.done)
FlashViewModel.State.FAILED -> stringResource(CoreR.string.failure)
}
val scrollBehavior = MiuixScrollBehavior()
Scaffold(
topBar = {
SmallTopAppBar(
title = "${stringResource(CoreR.string.flash_screen_title)} - $statusText",
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = onBack
) {
Icon(
imageVector = MiuixIcons.Back,
contentDescription = null,
tint = MiuixTheme.colorScheme.onBackground
)
}
},
actions = {
if (finished) {
IconButton(
modifier = Modifier.padding(end = 4.dp),
onClick = { viewModel.saveLog() }
) {
Icon(
painter = painterResource(R.drawable.ic_save_md2),
contentDescription = stringResource(CoreR.string.menuSaveLog),
tint = MiuixTheme.colorScheme.onBackground
)
}
}
if (flashState == FlashViewModel.State.SUCCESS && showReboot) {
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = { viewModel.restartPressed() }
) {
Icon(
painter = painterResource(R.drawable.ic_restart),
contentDescription = stringResource(CoreR.string.reboot),
tint = MiuixTheme.colorScheme.onBackground
)
}
}
},
scrollBehavior = scrollBehavior
)
},
popupHost = { }
) { padding ->
if (useTerminal) {
TerminalScreen(
modifier = Modifier
.fillMaxSize()
.padding(padding),
onEmulatorCreated = { viewModel.onEmulatorCreated(it) },
)
} else {
val items = viewModel.consoleItems
val listState = rememberLazyListState()
LaunchedEffect(items.size) {
if (items.isNotEmpty()) {
listState.animateScrollToItem(items.size - 1)
}
}
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(padding)
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
itemsIndexed(items) { _, line ->
Text(
text = line,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 16.sp,
color = MiuixTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
package com.topjohnwu.magisk.ui.flash
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.cmp
import com.topjohnwu.magisk.ui.MainActivity
object FlashUtils {
const val INTENT_FLASH = "com.topjohnwu.magisk.intent.FLASH"
const val EXTRA_FLASH_ACTION = "flash_action"
const val EXTRA_FLASH_URI = "flash_uri"
fun installIntent(context: Context, file: Uri): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
component = MainActivity::class.java.cmp(context.packageName)
action = INTENT_FLASH
putExtra(EXTRA_FLASH_ACTION, Const.Value.FLASH_ZIP)
putExtra(EXTRA_FLASH_URI, file.toString())
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
return PendingIntent.getActivity(
context, file.hashCode(), intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
}

View File

@@ -1,30 +1,39 @@
package com.topjohnwu.magisk.ui.flash
import android.view.MenuItem
import androidx.databinding.Bindable
import androidx.databinding.ObservableArrayList
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.core.net.toFile
import androidx.lifecycle.viewModelScope
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.ktx.reboot
import com.topjohnwu.magisk.core.ktx.synchronized
import com.topjohnwu.magisk.core.ktx.timeFormatStandard
import com.topjohnwu.magisk.core.ktx.toTime
import com.topjohnwu.magisk.core.tasks.FlashZip
import com.topjohnwu.magisk.core.ktx.writeTo
import com.topjohnwu.magisk.core.tasks.MagiskInstaller
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.displayName
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.inputStream
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.terminal.TerminalEmulator
import com.topjohnwu.magisk.terminal.appendLineOnMain
import com.topjohnwu.magisk.terminal.runSuCommand
import com.topjohnwu.superuser.CallbackList
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
class FlashViewModel : BaseViewModel() {
@@ -32,89 +41,161 @@ class FlashViewModel : BaseViewModel() {
FLASHING, SUCCESS, FAILED
}
private val _state = MutableLiveData(State.FLASHING)
val state: LiveData<State> get() = _state
val flashing = state.map { it == State.FLASHING }
private val _flashState = MutableStateFlow(State.FLASHING)
val flashState: StateFlow<State> = _flashState.asStateFlow()
@get:Bindable
var showReboot = Info.isRooted
set(value) = set(value, field, { field = it }, BR.showReboot)
private val _showReboot = MutableStateFlow(Info.isRooted)
val showReboot: StateFlow<Boolean> = _showReboot.asStateFlow()
val items = ObservableArrayList<ConsoleItem>()
lateinit var args: FlashFragmentArgs
var flashAction: String = ""
var flashUri: Uri? = null
// --- TerminalScreen mode (FLASH_ZIP) ---
private var emulator: TerminalEmulator? = null
private val emulatorReady = CompletableDeferred<TerminalEmulator>()
fun onEmulatorCreated(emu: TerminalEmulator) {
emulator = emu
emulatorReady.complete(emu)
}
// --- LazyColumn mode (MagiskInstaller) ---
val consoleItems = mutableStateListOf<String>()
private val logItems = mutableListOf<String>().synchronized()
private val outItems = object : CallbackList<String>() {
override fun onAddElement(e: String?) {
e ?: return
items.add(ConsoleItem(e))
consoleItems.add(e)
logItems.add(e)
}
}
// --- Shared ---
fun startFlashing() {
val (action, uri) = args
val action = flashAction
val uri = flashUri
viewModelScope.launch {
val result = when (action) {
when (action) {
Const.Value.FLASH_ZIP -> {
uri ?: return@launch
FlashZip(uri, outItems, logItems).exec()
flashZip(uri)
}
Const.Value.UNINSTALL -> {
showReboot = false
MagiskInstaller.Uninstall(outItems, logItems).exec()
_showReboot.value = false
onResult(withContext(Dispatchers.IO) {
MagiskInstaller.Uninstall(outItems, logItems).exec()
})
}
Const.Value.FLASH_MAGISK -> {
if (Info.isEmulator)
MagiskInstaller.Emulator(outItems, logItems).exec()
else
MagiskInstaller.Direct(outItems, logItems).exec()
onResult(withContext(Dispatchers.IO) {
if (Info.isEmulator)
MagiskInstaller.Emulator(outItems, logItems).exec()
else
MagiskInstaller.Direct(outItems, logItems).exec()
})
}
Const.Value.FLASH_INACTIVE_SLOT -> {
showReboot = false
MagiskInstaller.SecondSlot(outItems, logItems).exec()
_showReboot.value = false
onResult(withContext(Dispatchers.IO) {
MagiskInstaller.SecondSlot(outItems, logItems).exec()
})
}
Const.Value.PATCH_FILE -> {
uri ?: return@launch
showReboot = false
MagiskInstaller.Patch(uri, outItems, logItems).exec()
}
else -> {
back()
return@launch
_showReboot.value = false
onResult(withContext(Dispatchers.IO) {
MagiskInstaller.Patch(uri, outItems, logItems).exec()
})
}
}
onResult(result)
}
}
private fun onResult(success: Boolean) {
_state.value = if (success) State.SUCCESS else State.FAILED
_flashState.value = if (success) State.SUCCESS else State.FAILED
}
fun onMenuItemClicked(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_save -> savePressed()
private suspend fun flashZip(uri: Uri) {
val emu = emulatorReady.await()
val installDir = File(AppContext.cacheDir, "flash")
val result = withContext(Dispatchers.IO) {
try {
installDir.deleteRecursively()
installDir.mkdirs()
val zipFile = if (uri.scheme == "file") {
uri.toFile()
} else {
File(installDir, "install.zip").also {
try {
uri.inputStream().writeTo(it)
} catch (e: IOException) {
val msg = if (e is FileNotFoundException) "Invalid Uri" else "Cannot copy to cache"
return@withContext msg to null
}
}
}
val binary = File(installDir, "update-binary")
AppContext.assets.open("module_installer.sh").use { it.writeTo(binary) }
val name = uri.displayName
null to Triple(installDir, zipFile, name)
} catch (e: IOException) {
Timber.e(e)
"Unable to extract files" to null
}
}
return true
val (error, prepResult) = result
if (prepResult == null) {
emu.appendLineOnMain("! ${error ?: "Installation failed"}")
_flashState.value = State.FAILED
return
}
val (dir, zipFile, displayName) = prepResult
val success = withContext(Dispatchers.IO) {
runSuCommand(
emu,
"echo '- Installing $displayName'; " +
"sh $dir/update-binary dummy 1 '${zipFile.absolutePath}'; " +
"EXIT=\$?; " +
"if [ \$EXIT -ne 0 ]; then echo '! Installation failed'; fi; " +
"exit \$EXIT"
)
}
Shell.cmd("cd /", "rm -rf $dir ${Const.TMPDIR}").submit()
_flashState.value = if (success) State.SUCCESS else State.FAILED
}
private fun savePressed() = withExternalRW {
fun saveLog() {
viewModelScope.launch(Dispatchers.IO) {
val name = "magisk_install_log_%s.log".format(
System.currentTimeMillis().toTime(timeFormatStandard)
)
val file = MediaStoreUtils.getFile(name)
file.uri.outputStream().bufferedWriter().use { writer ->
synchronized(logItems) {
logItems.forEach {
writer.write(it)
writer.newLine()
val transcript = emulator?.screen?.transcriptText
if (transcript != null) {
writer.write(transcript)
} else {
synchronized(logItems) {
logItems.forEach {
writer.write(it)
writer.newLine()
}
}
}
}
SnackbarEvent(file.toString()).publish()
showSnackbar(file.toString())
}
}

View File

@@ -1,127 +0,0 @@
package com.topjohnwu.magisk.ui.home
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.databinding.RvItem
import com.topjohnwu.magisk.core.R as CoreR
interface Dev {
val name: String
}
private interface JohnImpl : Dev {
override val name get() = "topjohnwu"
}
private interface VvbImpl : Dev {
override val name get() = "vvb2060"
}
private interface YUImpl : Dev {
override val name get() = "yujincheng08"
}
private interface RikkaImpl : Dev {
override val name get() = "RikkaW"
}
private interface CanyieImpl : Dev {
override val name get() = "canyie"
}
sealed class DeveloperItem : Dev {
abstract val items: List<IconLink>
val handle get() = "@${name}"
object John : DeveloperItem(), JohnImpl {
override val items =
listOf(
object : IconLink.Twitter(), JohnImpl {},
IconLink.Github.Project
)
}
object Vvb : DeveloperItem(), VvbImpl {
override val items =
listOf<IconLink>(
object : IconLink.Twitter(), VvbImpl {},
object : IconLink.Github.User(), VvbImpl {}
)
}
object YU : DeveloperItem(), YUImpl {
override val items =
listOf<IconLink>(
object : IconLink.Twitter() { override val name = "shanasaimoe" },
object : IconLink.Github.User(), YUImpl {},
object : IconLink.Sponsor(), YUImpl {}
)
}
object Rikka : DeveloperItem(), RikkaImpl {
override val items =
listOf<IconLink>(
object : IconLink.Twitter() { override val name = "rikkawww" },
object : IconLink.Github.User(), RikkaImpl {}
)
}
object Canyie : DeveloperItem(), CanyieImpl {
override val items =
listOf<IconLink>(
object : IconLink.Twitter() { override val name = "canyie2977" },
object : IconLink.Github.User(), CanyieImpl {}
)
}
}
sealed class IconLink : RvItem() {
abstract val icon: Int
abstract val title: Int
abstract val link: String
override val layoutRes get() = R.layout.item_icon_link
abstract class PayPal : IconLink(), Dev {
override val icon get() = CoreR.drawable.ic_paypal
override val title get() = CoreR.string.paypal
override val link get() = "https://paypal.me/$name"
object Project : PayPal() {
override val name: String get() = "magiskdonate"
}
}
object Patreon : IconLink() {
override val icon get() = CoreR.drawable.ic_patreon
override val title get() = CoreR.string.patreon
override val link get() = Const.Url.PATREON_URL
}
abstract class Twitter : IconLink(), Dev {
override val icon get() = CoreR.drawable.ic_twitter
override val title get() = CoreR.string.twitter
override val link get() = "https://twitter.com/$name"
}
abstract class Github : IconLink() {
override val icon get() = CoreR.drawable.ic_github
override val title get() = CoreR.string.github
abstract class User : Github(), Dev {
override val link get() = "https://github.com/$name"
}
object Project : Github() {
override val link get() = Const.Url.SOURCE_CODE_URL
}
}
abstract class Sponsor : IconLink(), Dev {
override val icon get() = CoreR.drawable.ic_favorite
override val title get() = CoreR.string.github
override val link get() = "https://github.com/sponsors/$name"
}
}

View File

@@ -1,90 +0,0 @@
package com.topjohnwu.magisk.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.MenuProvider
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.download.DownloadEngine
import com.topjohnwu.magisk.databinding.FragmentHomeMd2Binding
import com.topjohnwu.magisk.core.R as CoreR
import androidx.navigation.findNavController
import com.topjohnwu.magisk.arch.NavigationActivity
class HomeFragment : BaseFragment<FragmentHomeMd2Binding>(), MenuProvider {
override val layoutRes = R.layout.fragment_home_md2
override val viewModel by viewModel<HomeViewModel>()
override fun onStart() {
super.onStart()
activity?.setTitle(CoreR.string.section_home)
DownloadEngine.observeProgress(this, viewModel::onProgressUpdate)
}
private fun checkTitle(text: TextView, icon: ImageView) {
text.post {
if (text.layout?.getEllipsisCount(0) != 0) {
with (icon) {
layoutParams.width = 0
layoutParams.height = 0
requestLayout()
}
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
super.onCreateView(inflater, container, savedInstanceState)
// If titles are squished, hide icons
with(binding.homeMagiskWrapper) {
checkTitle(homeMagiskTitle, homeMagiskIcon)
}
with(binding.homeManagerWrapper) {
checkTitle(homeManagerTitle, homeManagerIcon)
}
return binding.root
}
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_home_md2, menu)
if (!Info.isRooted)
menu.removeItem(R.id.action_reboot)
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_settings ->
activity?.let {
NavigationActivity.navigate(
HomeFragmentDirections.actionHomeFragmentToSettingsFragment(),
it.findNavController(R.id.main_nav_host),
it.contentResolver,
)
}
R.id.action_reboot -> activity?.let { RebootMenu.inflate(it).show() }
else -> return super.onOptionsItemSelected(item)
}
return true
}
override fun onResume() {
super.onResume()
viewModel.stateManagerProgress = 0
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,11 @@
package com.topjohnwu.magisk.ui.home
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.net.toUri
import androidx.databinding.Bindable
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.ActivityExecutor
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
import com.topjohnwu.magisk.arch.ContextExecutor
import com.topjohnwu.magisk.arch.UIActivity
import com.topjohnwu.magisk.arch.ViewEvent
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.BuildConfig
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Info
@@ -21,14 +14,11 @@ import com.topjohnwu.magisk.core.download.Subject.App
import com.topjohnwu.magisk.core.ktx.await
import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.core.repository.NetworkService
import com.topjohnwu.magisk.databinding.bindExtra
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.dialog.EnvFixDialog
import com.topjohnwu.magisk.dialog.ManagerInstallDialog
import com.topjohnwu.magisk.dialog.UninstallDialog
import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.utils.asText
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlin.math.roundToInt
import com.topjohnwu.magisk.core.R as CoreR
@@ -40,14 +30,19 @@ class HomeViewModel(
LOADING, INVALID, OUTDATED, UP_TO_DATE
}
val magiskTitleBarrierIds =
intArrayOf(R.id.home_magisk_icon, R.id.home_magisk_title, R.id.home_magisk_button)
val appTitleBarrierIds =
intArrayOf(R.id.home_manager_icon, R.id.home_manager_title, R.id.home_manager_button)
data class UiState(
val isNoticeVisible: Boolean = Config.safetyNotice,
val appState: State = State.LOADING,
val managerRemoteVersion: String = "",
val managerProgress: Int = 0,
val showUninstall: Boolean = false,
val showManagerInstall: Boolean = false,
val showHideRestore: Boolean = false,
val envFixCode: Int = 0,
)
@get:Bindable
var isNoticeVisible = Config.safetyNotice
set(value) = set(value, field, { field = it }, BR.noticeVisible)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
val magiskState
get() = when {
@@ -57,94 +52,103 @@ class HomeViewModel(
else -> State.UP_TO_DATE
}
@get:Bindable
var appState = State.LOADING
set(value) = set(value, field, { field = it }, BR.appState)
val magiskInstalledVersion
val magiskInstalledVersion: String
get() = Info.env.run {
if (isActive)
("$versionString ($versionCode)" + if (isDebug) " (D)" else "").asText()
"$versionString ($versionCode)" + if (isDebug) " (D)" else ""
else
CoreR.string.not_available.asText()
""
}
@get:Bindable
var managerRemoteVersion = CoreR.string.loading.asText()
set(value) = set(value, field, { field = it }, BR.managerRemoteVersion)
val managerInstalledVersion
val managerInstalledVersion: String
get() = "${BuildConfig.APP_VERSION_NAME} (${BuildConfig.APP_VERSION_CODE})" +
if (BuildConfig.DEBUG) " (D)" else ""
@get:Bindable
var stateManagerProgress = 0
set(value) = set(value, field, { field = it }, BR.stateManagerProgress)
val extraBindings = bindExtra {
it.put(BR.viewModel, this)
}
companion object {
private var checkedEnv = false
}
override suspend fun doLoadWork() {
appState = State.LOADING
_uiState.update { it.copy(appState = State.LOADING) }
Info.fetchUpdate(svc)?.apply {
appState = when {
BuildConfig.APP_VERSION_CODE < versionCode -> State.OUTDATED
else -> State.UP_TO_DATE
}
val isDebug = Config.updateChannel == Config.Value.DEBUG_CHANNEL
managerRemoteVersion =
("$version (${versionCode})" + if (isDebug) " (D)" else "").asText()
_uiState.update {
it.copy(
appState = if (BuildConfig.APP_VERSION_CODE < versionCode) State.OUTDATED else State.UP_TO_DATE,
managerRemoteVersion = "$version ($versionCode)" + if (isDebug) " (D)" else ""
)
}
} ?: run {
appState = State.INVALID
managerRemoteVersion = CoreR.string.not_available.asText()
_uiState.update { it.copy(appState = State.INVALID, managerRemoteVersion = "") }
}
ensureEnv()
}
override fun onNetworkChanged(network: Boolean) = startLoading()
private val networkObserver: (Boolean) -> Unit = { startLoading() }
init {
Info.isConnected.observeForever(networkObserver)
}
override fun onCleared() {
super.onCleared()
Info.isConnected.removeObserver(networkObserver)
}
fun onProgressUpdate(progress: Float, subject: Subject) {
if (subject is App)
stateManagerProgress = progress.times(100f).roundToInt()
_uiState.update { it.copy(managerProgress = progress.times(100f).roundToInt()) }
}
fun onLinkPressed(link: String) = object : ViewEvent(), ContextExecutor {
override fun invoke(context: Context) {
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
context.toast(CoreR.string.open_link_failed_toast, Toast.LENGTH_SHORT)
}
}
}.publish()
fun resetProgress() {
_uiState.update { it.copy(managerProgress = 0) }
}
fun onDeletePressed() = UninstallDialog().show()
fun onManagerPressed() = when (appState) {
State.LOADING -> SnackbarEvent(CoreR.string.loading).publish()
State.INVALID -> SnackbarEvent(CoreR.string.no_connection).publish()
else -> withExternalRW {
withInstallPermission {
ManagerInstallDialog().show()
}
fun onLinkPressed(link: String) {
val intent = Intent(Intent.ACTION_VIEW, link.toUri())
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
AppContext.startActivity(intent)
} catch (e: ActivityNotFoundException) {
AppContext.toast(CoreR.string.open_link_failed_toast, Toast.LENGTH_SHORT)
}
}
fun onMagiskPressed() = withExternalRW {
HomeFragmentDirections.actionHomeFragmentToInstallFragment().navigate()
fun onDeletePressed() {
_uiState.update { it.copy(showUninstall = true) }
}
fun onUninstallConsumed() {
_uiState.update { it.copy(showUninstall = false) }
}
fun onManagerPressed() {
when (_uiState.value.appState) {
State.LOADING -> showSnackbar(CoreR.string.loading)
State.INVALID -> showSnackbar(CoreR.string.no_connection)
else -> _uiState.update { it.copy(showManagerInstall = true) }
}
}
fun onManagerInstallConsumed() {
_uiState.update { it.copy(showManagerInstall = false) }
}
fun onHideRestorePressed() {
_uiState.update { it.copy(showHideRestore = true) }
}
fun onHideRestoreConsumed() {
_uiState.update { it.copy(showHideRestore = false) }
}
fun onEnvFixConsumed() {
_uiState.update { it.copy(envFixCode = 0) }
}
fun hideNotice() {
Config.safetyNotice = false
isNoticeVisible = false
_uiState.update { it.copy(isNoticeVisible = false) }
}
private suspend fun ensureEnv() {
@@ -152,15 +156,8 @@ class HomeViewModel(
val cmd = "env_check ${Info.env.versionString} ${Info.env.versionCode}"
val code = Shell.cmd(cmd).await().code
if (code != 0) {
EnvFixDialog(this, code).show()
_uiState.update { it.copy(envFixCode = code) }
}
checkedEnv = true
}
val showTest = false
fun onTestPressed() = object : ViewEvent(), ActivityExecutor {
override fun invoke(activity: UIActivity<*>) {
/* Entry point to trigger test events within the app */
}
}.publish()
}

View File

@@ -1,52 +0,0 @@
package com.topjohnwu.magisk.ui.home
import android.app.Activity
import android.os.Build
import android.os.PowerManager
import android.view.ContextThemeWrapper
import android.view.MenuItem
import android.widget.PopupMenu
import androidx.core.content.getSystemService
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.ktx.reboot as systemReboot
object RebootMenu {
private fun reboot(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_reboot_normal -> systemReboot()
R.id.action_reboot_userspace -> systemReboot("userspace")
R.id.action_reboot_bootloader -> systemReboot("bootloader")
R.id.action_reboot_download -> systemReboot("download")
R.id.action_reboot_edl -> systemReboot("edl")
R.id.action_reboot_recovery -> systemReboot("recovery")
R.id.action_reboot_safe_mode -> {
val status = !item.isChecked
item.isChecked = status
Config.bootloop = if (status) 2 else 0
}
else -> Unit
}
return true
}
fun inflate(activity: Activity): PopupMenu {
val themeWrapper = ContextThemeWrapper(activity, R.style.Foundation_PopupMenu)
val menu = PopupMenu(themeWrapper, activity.findViewById(R.id.action_reboot))
activity.menuInflater.inflate(R.menu.menu_reboot, menu.menu)
menu.setOnMenuItemClickListener(RebootMenu::reboot)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
activity.getSystemService<PowerManager>()?.isRebootingUserspaceSupported == true) {
menu.menu.findItem(R.id.action_reboot_userspace).isVisible = true
}
if (Const.Version.atLeast_28_0()) {
menu.menu.findItem(R.id.action_reboot_safe_mode).isChecked = Config.bootloop >= 2
} else {
menu.menu.findItem(R.id.action_reboot_safe_mode).isVisible = false
}
return menu
}
}

View File

@@ -1,18 +0,0 @@
package com.topjohnwu.magisk.ui.install
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.databinding.FragmentInstallMd2Binding
import com.topjohnwu.magisk.core.R as CoreR
class InstallFragment : BaseFragment<FragmentInstallMd2Binding>() {
override val layoutRes = R.layout.fragment_install_md2
override val viewModel by viewModel<InstallViewModel>()
override fun onStart() {
super.onStart()
requireActivity().setTitle(CoreR.string.install)
}
}

View File

@@ -1,70 +1,48 @@
package com.topjohnwu.magisk.ui.install
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.text.Spanned
import android.text.SpannedString
import android.widget.Toast
import androidx.databinding.Bindable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.BuildConfig.APP_VERSION_CODE
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.base.ContentResultCallback
import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.core.repository.NetworkService
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.dialog.SecondSlotWarningDialog
import com.topjohnwu.magisk.events.GetContentEvent
import com.topjohnwu.magisk.ui.flash.FlashFragment
import io.noties.markwon.Markwon
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.ui.navigation.Route
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.io.File
import java.io.IOException
import com.topjohnwu.magisk.core.R as CoreR
class InstallViewModel(svc: NetworkService, markwon: Markwon) : BaseViewModel() {
class InstallViewModel(svc: NetworkService) : BaseViewModel() {
enum class Method { NONE, PATCH, DIRECT, INACTIVE_SLOT }
data class UiState(
val step: Int = 0,
val method: Method = Method.NONE,
val notes: String = "",
val patchUri: Uri? = null,
val requestFilePicker: Boolean = false,
val showSecondSlotWarning: Boolean = false,
)
val isRooted get() = Info.isRooted
val skipOptions = Info.isEmulator || (Info.isSAR && !Info.isFDE && Info.ramdisk)
val noSecondSlot = !isRooted || !Info.isAB || Info.isEmulator
@get:Bindable
var step = if (skipOptions) 1 else 0
set(value) = set(value, field, { field = it }, BR.step)
private var methodId = -1
@get:Bindable
var method
get() = methodId
set(value) = set(value, methodId, { methodId = it }, BR.method) {
when (it) {
R.id.method_patch -> {
GetContentEvent("*/*", UriCallback()).publish()
}
R.id.method_inactive_slot -> {
SecondSlotWarningDialog().show()
}
}
}
val data: LiveData<Uri?> get() = uri
@get:Bindable
var notes: Spanned = SpannedString("")
set(value) = set(value, field, { field = it }, BR.notes)
private val _uiState = MutableStateFlow(UiState(step = if (skipOptions) 1 else 0))
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
@@ -73,14 +51,14 @@ class InstallViewModel(svc: NetworkService, markwon: Markwon) : BaseViewModel()
val noteText = when {
noteFile.exists() -> noteFile.readText()
else -> {
val note = svc.fetchUpdate(APP_VERSION_CODE).note
val note = svc.fetchUpdate(APP_VERSION_CODE)?.note.orEmpty()
if (note.isEmpty()) return@launch
noteFile.writeText(note)
note
}
}
val spanned = markwon.toMarkdown(noteText)
withContext(Dispatchers.Main) {
notes = spanned
_uiState.update { it.copy(notes = noteText) }
}
} catch (e: IOException) {
Timber.e(e)
@@ -88,59 +66,63 @@ class InstallViewModel(svc: NetworkService, markwon: Markwon) : BaseViewModel()
}
}
fun install() {
fun nextStep() {
_uiState.update { it.copy(step = 1) }
}
fun selectMethod(method: Method) {
_uiState.update { it.copy(method = method) }
when (method) {
R.id.method_patch -> FlashFragment.patch(data.value!!).navigate(true)
R.id.method_direct -> FlashFragment.flash(false).navigate(true)
R.id.method_inactive_slot -> FlashFragment.flash(true).navigate(true)
else -> error("Unknown value")
Method.PATCH -> {
AppContext.toast(CoreR.string.patch_file_msg, Toast.LENGTH_LONG)
_uiState.update { it.copy(requestFilePicker = true) }
}
Method.INACTIVE_SLOT -> {
_uiState.update { it.copy(showSecondSlotWarning = true) }
}
else -> {}
}
}
override fun onSaveState(state: Bundle) {
state.putParcelable(
INSTALL_STATE_KEY, InstallState(
methodId,
step,
Config.keepVerity,
Config.keepEnc,
Config.recovery
)
)
fun onFilePickerConsumed() {
_uiState.update { it.copy(requestFilePicker = false) }
}
override fun onRestoreState(state: Bundle) {
state.getParcelable<InstallState>(INSTALL_STATE_KEY)?.let {
methodId = it.method
step = it.step
Config.keepVerity = it.keepVerity
Config.keepEnc = it.keepEnc
Config.recovery = it.recovery
fun onSecondSlotWarningConsumed() {
_uiState.update { it.copy(showSecondSlotWarning = false) }
}
fun onPatchFileSelected(uri: Uri) {
_uiState.update { it.copy(patchUri = uri) }
if (_uiState.value.method == Method.PATCH) {
install()
}
}
@Parcelize
class UriCallback : ContentResultCallback {
override fun onActivityLaunch() {
AppContext.toast(CoreR.string.patch_file_msg, Toast.LENGTH_LONG)
}
override fun onActivityResult(result: Uri) {
uri.value = result
fun install() {
when (_uiState.value.method) {
Method.PATCH -> navigateTo(Route.Flash(
action = Const.Value.PATCH_FILE,
additionalData = _uiState.value.patchUri!!.toString()
))
Method.DIRECT -> navigateTo(Route.Flash(
action = Const.Value.FLASH_MAGISK
))
Method.INACTIVE_SLOT -> navigateTo(Route.Flash(
action = Const.Value.FLASH_INACTIVE_SLOT
))
else -> error("Unknown method")
}
}
@Parcelize
class InstallState(
val method: Int,
val step: Int,
val keepVerity: Boolean,
val keepEnc: Boolean,
val recovery: Boolean,
) : Parcelable
val canInstall: Boolean
get() {
val state = _uiState.value
return when (state.method) {
Method.PATCH -> state.patchUri != null
Method.DIRECT, Method.INACTIVE_SLOT -> true
Method.NONE -> false
}
}
companion object {
private const val INSTALL_STATE_KEY = "install_state"
private val uri = MutableLiveData<Uri?>()
}
}

View File

@@ -1,97 +0,0 @@
package com.topjohnwu.magisk.ui.log
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.HorizontalScrollView
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.databinding.FragmentLogMd2Binding
import com.topjohnwu.magisk.ui.MainActivity
import com.topjohnwu.magisk.utils.AccessibilityUtils
import com.topjohnwu.magisk.utils.MotionRevealHelper
import rikka.recyclerview.addEdgeSpacing
import rikka.recyclerview.addItemSpacing
import rikka.recyclerview.fixEdgeEffect
import com.topjohnwu.magisk.core.R as CoreR
class LogFragment : BaseFragment<FragmentLogMd2Binding>(), MenuProvider {
override val layoutRes = R.layout.fragment_log_md2
override val viewModel by viewModel<LogViewModel>()
override val snackbarView: View?
get() = if (isMagiskLogVisible) binding.logFilterSuperuser.snackbarContainer
else super.snackbarView
override val snackbarAnchorView get() = binding.logFilterToggle
private var actionSave: MenuItem? = null
private var isMagiskLogVisible
get() = binding.logFilter.isVisible
set(value) {
MotionRevealHelper.withViews(binding.logFilter, binding.logFilterToggle, value)
actionSave?.isVisible = !value
with(activity as MainActivity) {
invalidateToolbar()
requestNavigationHidden(value)
setDisplayHomeAsUpEnabled(value)
}
}
override fun onStart() {
super.onStart()
activity?.setTitle(CoreR.string.logs)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.logFilterToggle.setOnClickListener {
isMagiskLogVisible = true
}
binding.logFilterSuperuser.logSuperuser.apply {
addEdgeSpacing(bottom = R.dimen.l1)
addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1)
fixEdgeEffect()
}
if (!AccessibilityUtils.isAnimationEnabled(requireContext().contentResolver)) {
val scrollView = view.findViewById<HorizontalScrollView>(R.id.log_scroll_magisk)
scrollView.setOverScrollMode(View.OVER_SCROLL_NEVER)
}
}
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_log_md2, menu)
actionSave = menu.findItem(R.id.action_save)?.also {
it.isVisible = !isMagiskLogVisible
}
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_save -> viewModel.saveMagiskLog()
R.id.action_clear ->
if (!isMagiskLogVisible) viewModel.clearMagiskLog()
else viewModel.clearLog()
}
return super.onOptionsItemSelected(item)
}
override fun onPreBind(binding: FragmentLogMd2Binding) = Unit
override fun onBackPressed(): Boolean {
if (binding.logFilter.isVisible) {
isMagiskLogVisible = false
return true
}
return super.onBackPressed()
}
}

View File

@@ -1,28 +0,0 @@
package com.topjohnwu.magisk.ui.log
import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.databinding.DiffItem
import com.topjohnwu.magisk.databinding.ItemWrapper
import com.topjohnwu.magisk.databinding.ObservableRvItem
import com.topjohnwu.magisk.databinding.ViewAwareItem
class LogRvItem(
override val item: String
) : ObservableRvItem(), DiffItem<LogRvItem>, ItemWrapper<String>, ViewAwareItem {
override val layoutRes = R.layout.item_log_textview
override fun onBind(binding: ViewDataBinding, recyclerView: RecyclerView) {
val view = binding.root as MaterialTextView
view.measure(0, 0)
val desiredWidth = view.measuredWidth
val layoutParams = view.layoutParams
layoutParams.width = desiredWidth
if (recyclerView.width < desiredWidth) {
recyclerView.requestLayout()
}
}
}

View File

@@ -0,0 +1,400 @@
package com.topjohnwu.magisk.ui.log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.topjohnwu.magisk.core.ktx.timeDateFormat
import com.topjohnwu.magisk.core.ktx.toTime
import com.topjohnwu.magisk.core.model.su.SuLog
import com.topjohnwu.magisk.ui.util.rememberDrawablePainter
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.TabRow
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Delete
import top.yukonga.miuix.kmp.icon.extended.Download
import com.topjohnwu.magisk.core.R as CoreR
@Composable
fun LogScreen(viewModel: LogViewModel) {
val uiState by viewModel.uiState.collectAsState()
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
val tabTitles = listOf(
stringResource(CoreR.string.superuser),
stringResource(CoreR.string.magisk)
)
val scrollBehavior = MiuixScrollBehavior()
Scaffold(
topBar = {
TopAppBar(
title = stringResource(CoreR.string.logs),
actions = {
if (selectedTab == 1) {
IconButton(onClick = { viewModel.saveMagiskLog() }) {
Icon(
imageVector = MiuixIcons.Download,
contentDescription = stringResource(CoreR.string.save_log),
)
}
}
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = {
if (selectedTab == 0) viewModel.clearLog()
else viewModel.clearMagiskLog()
}
) {
Icon(
imageVector = MiuixIcons.Delete,
contentDescription = stringResource(CoreR.string.clear_log),
)
}
},
scrollBehavior = scrollBehavior
)
},
popupHost = { }
) { padding ->
Column(modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
TabRow(
tabs = tabTitles,
selectedTabIndex = selectedTab,
onTabSelected = { selectedTab = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
)
if (uiState.loading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
when (selectedTab) {
0 -> SuLogTab(
logs = uiState.suLogs,
nestedScrollConnection = scrollBehavior.nestedScrollConnection
)
1 -> MagiskLogTab(
entries = uiState.magiskLogEntries,
nestedScrollConnection = scrollBehavior.nestedScrollConnection
)
}
}
}
}
}
@Composable
private fun SuLogTab(logs: List<SuLog>, nestedScrollConnection: NestedScrollConnection) {
Column(modifier = Modifier.fillMaxSize()) {
if (logs.isEmpty()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(CoreR.string.log_data_none),
style = MiuixTheme.textStyles.body1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.Center,
)
}
} else {
LazyColumn(
modifier = Modifier
.weight(1f)
.nestedScroll(nestedScrollConnection)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(logs, key = { it.id }) { log ->
SuLogCard(log)
}
}
}
}
}
@Composable
private fun SuLogCard(log: SuLog) {
val res = LocalContext.current.resources
val pm = LocalContext.current.packageManager
val icon = remember(log.packageName) {
runCatching {
pm.getApplicationInfo(log.packageName, 0).loadIcon(pm)
}.getOrDefault(pm.defaultActivityIcon)
}
val allowed = log.action >= 2
val uidPidText = buildString {
append("UID: ${log.toUid} PID: ${log.fromPid}")
if (log.target != -1) {
val target = if (log.target == 0) "magiskd" else log.target.toString()
append("$target")
}
}
val details = buildString {
if (log.context.isNotEmpty()) {
append(res.getString(CoreR.string.selinux_context, log.context))
}
if (log.gids.isNotEmpty()) {
if (isNotEmpty()) append("\n")
append(res.getString(CoreR.string.supp_group, log.gids))
}
if (log.command.isNotEmpty()) {
if (isNotEmpty()) append("\n")
append(log.command)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) {
Image(
painter = rememberDrawablePainter(icon),
contentDescription = log.appName,
modifier = Modifier.size(36.dp)
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = log.appName,
style = MiuixTheme.textStyles.body1,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = uidPidText,
style = MiuixTheme.textStyles.body2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(8.dp))
Column(horizontalAlignment = Alignment.End) {
Text(
text = log.time.toTime(timeDateFormat),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
maxLines = 1,
)
Spacer(Modifier.height(4.dp))
SuActionBadge(allowed)
}
}
if (details.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text(
text = details,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 16.sp,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
@Composable
private fun SuActionBadge(allowed: Boolean) {
val bg = if (allowed) MiuixTheme.colorScheme.primary else MiuixTheme.colorScheme.error
val fg = if (allowed) MiuixTheme.colorScheme.onPrimary else MiuixTheme.colorScheme.onError
val text = if (allowed) "Approved" else "Rejected"
Text(
text = text,
color = fg,
fontSize = 10.sp,
maxLines = 1,
modifier = Modifier
.background(bg, RoundedCornerShape(6.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
@Composable
private fun MagiskLogTab(
entries: List<MagiskLogEntry>,
nestedScrollConnection: NestedScrollConnection
) {
Column(modifier = Modifier.fillMaxSize()) {
if (entries.isEmpty()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(CoreR.string.log_data_magisk_none),
style = MiuixTheme.textStyles.body1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.Center,
)
}
} else {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = entries.size - 1)
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.nestedScroll(nestedScrollConnection)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(entries.size, key = { it }) { index ->
MagiskLogCard(entries[index])
}
}
}
}
}
@Composable
private fun MagiskLogCard(entry: MagiskLogEntry) {
var expanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
) {
Column(modifier = Modifier.padding(12.dp)) {
if (entry.isParsed) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.weight(1f)
) {
LogLevelBadge(entry.level)
Text(
text = entry.tag,
style = MiuixTheme.textStyles.body1,
fontWeight = FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(8.dp))
Text(
text = entry.timestamp,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
maxLines = 1,
)
}
Spacer(Modifier.height(4.dp))
}
Text(
text = entry.message,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 16.sp,
color = MiuixTheme.colorScheme.onSurface,
maxLines = if (expanded) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun LogLevelBadge(level: Char) {
val (bg, fg) = when (level) {
'V' -> Color(0xFF9E9E9E) to Color.White
'D' -> Color(0xFF2196F3) to Color.White
'I' -> Color(0xFF4CAF50) to Color.White
'W' -> Color(0xFFFFC107) to Color.Black
'E' -> Color(0xFFF44336) to Color.White
'F' -> Color(0xFF9C27B0) to Color.White
else -> Color(0xFF757575) to Color.White
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(bg)
.padding(horizontal = 5.dp, vertical = 1.dp),
contentAlignment = Alignment.Center
) {
Text(
text = level.toString(),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = fg,
)
}
}

View File

@@ -1,24 +1,24 @@
package com.topjohnwu.magisk.ui.log
import android.system.Os
import androidx.databinding.Bindable
import androidx.lifecycle.viewModelScope
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
import com.topjohnwu.magisk.core.BuildConfig
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.core.ktx.timeFormatStandard
import com.topjohnwu.magisk.core.ktx.toTime
import com.topjohnwu.magisk.core.model.su.SuLog
import com.topjohnwu.magisk.core.repository.LogRepository
import com.topjohnwu.magisk.core.su.SuEvents
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
import com.topjohnwu.magisk.databinding.bindExtra
import com.topjohnwu.magisk.databinding.diffList
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.view.TextItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.FileInputStream
@@ -26,46 +26,42 @@ import java.io.FileInputStream
class LogViewModel(
private val repo: LogRepository
) : AsyncLoadViewModel() {
@get:Bindable
var loading = true
private set(value) = set(value, field, { field = it }, BR.loading)
// --- empty view
val itemEmpty = TextItem(R.string.log_data_none)
val itemMagiskEmpty = TextItem(R.string.log_data_magisk_none)
// --- su log
val items = diffList<SuLogRvItem>()
val extraBindings = bindExtra {
it.put(BR.viewModel, this)
init {
@OptIn(kotlinx.coroutines.FlowPreview::class)
viewModelScope.launch {
SuEvents.logUpdated.debounce(500).collect { reload() }
}
}
// --- magisk log
val logs = diffList<LogRvItem>()
var magiskLogRaw = " "
data class UiState(
val loading: Boolean = true,
val magiskLog: String = "",
val magiskLogEntries: List<MagiskLogEntry> = emptyList(),
val suLogs: List<SuLog> = emptyList(),
)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
private var magiskLogRaw = ""
override suspend fun doLoadWork() {
loading = true
val (suLogs, suDiff) = withContext(Dispatchers.Default) {
_uiState.update { it.copy(loading = true) }
withContext(Dispatchers.Default) {
magiskLogRaw = repo.fetchMagiskLogs()
val newLogs = magiskLogRaw.split('\n').map { LogRvItem(it) }
logs.update(newLogs)
val suLogs = repo.fetchSuLogs().map { SuLogRvItem(it) }
suLogs to items.calculateDiff(suLogs)
val suLogs = repo.fetchSuLogs()
val entries = MagiskLogParser.parse(magiskLogRaw)
_uiState.update { it.copy(
loading = false,
magiskLog = magiskLogRaw,
magiskLogEntries = entries,
suLogs = suLogs,
) }
}
items.firstOrNull()?.isTop = false
items.lastOrNull()?.isBottom = false
items.update(suLogs, suDiff)
items.firstOrNull()?.isTop = true
items.lastOrNull()?.isBottom = true
loading = false
}
fun saveMagiskLog() = withExternalRW {
fun saveMagiskLog() {
viewModelScope.launch(Dispatchers.IO) {
val filename = "magisk_log_%s.log".format(
System.currentTimeMillis().toTime(timeFormatStandard))
@@ -97,18 +93,18 @@ class LogViewModel(
ProcessBuilder("logcat", "-d").start()
.inputStream.reader().use { it.copyTo(file) }
}
SnackbarEvent(logFile.toString()).publish()
showSnackbar(logFile.toString())
}
}
fun clearMagiskLog() = repo.clearMagiskLogs {
SnackbarEvent(R.string.logs_cleared).publish()
showSnackbar(R.string.logs_cleared)
startLoading()
}
fun clearLog() = viewModelScope.launch {
repo.clearLogs()
SnackbarEvent(R.string.logs_cleared).publish()
showSnackbar(R.string.logs_cleared)
startLoading()
}
}

View File

@@ -0,0 +1,56 @@
package com.topjohnwu.magisk.ui.log
data class MagiskLogEntry(
val timestamp: String = "",
val pid: Int = 0,
val tid: Int = 0,
val level: Char = 'I',
val tag: String = "",
val message: String = "",
val isParsed: Boolean = false,
)
object MagiskLogParser {
// Logcat format: "MM-DD HH:MM:SS.mmm PID TID LEVEL TAG : message"
private val logcatRegex = Regex(
"""(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3})\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+(.+?)\s*:\s+(.*)"""
)
fun parse(raw: String): List<MagiskLogEntry> {
if (raw.isBlank()) return emptyList()
val lines = raw.lines()
val result = mutableListOf<MagiskLogEntry>()
for (line in lines) {
if (line.isBlank()) continue
val match = logcatRegex.find(line)
if (match != null) {
result.add(
MagiskLogEntry(
timestamp = match.groupValues[1],
pid = match.groupValues[2].toIntOrNull() ?: 0,
tid = match.groupValues[3].toIntOrNull() ?: 0,
level = match.groupValues[4].firstOrNull() ?: 'I',
tag = match.groupValues[5].trim(),
message = match.groupValues[6],
isParsed = true,
)
)
} else if (result.isNotEmpty() && result.last().isParsed) {
// Continuation line — append to previous entry
val prev = result.last()
result[result.lastIndex] = prev.copy(
message = prev.message + "\n" + line.trimEnd()
)
} else {
result.add(
MagiskLogEntry(message = line.trimEnd())
)
}
}
return result
}
}

View File

@@ -1,54 +0,0 @@
package com.topjohnwu.magisk.ui.log
import androidx.databinding.Bindable
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.ktx.timeDateFormat
import com.topjohnwu.magisk.core.ktx.toTime
import com.topjohnwu.magisk.core.model.su.SuLog
import com.topjohnwu.magisk.databinding.DiffItem
import com.topjohnwu.magisk.databinding.ObservableRvItem
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.core.R as CoreR
class SuLogRvItem(val log: SuLog) : ObservableRvItem(), DiffItem<SuLogRvItem> {
override val layoutRes = R.layout.item_log_access_md2
val info = genInfo()
@get:Bindable
var isTop = false
set(value) = set(value, field, { field = it }, BR.top)
@get:Bindable
var isBottom = false
set(value) = set(value, field, { field = it }, BR.bottom)
override fun itemSameAs(other: SuLogRvItem) = log.appName == other.log.appName
private fun genInfo(): String {
val res = AppContext.resources
val sb = StringBuilder()
val date = log.time.toTime(timeDateFormat)
val toUid = res.getString(CoreR.string.target_uid, log.toUid)
val fromPid = res.getString(CoreR.string.pid, log.fromPid)
sb.append("$date\n$toUid $fromPid")
if (log.target != -1) {
val pid = if (log.target == 0) "magiskd" else log.target.toString()
val target = res.getString(CoreR.string.target_pid, pid)
sb.append(" $target")
}
if (log.context.isNotEmpty()) {
val context = res.getString(CoreR.string.selinux_context, log.context)
sb.append("\n$context")
}
if (log.gids.isNotEmpty()) {
val gids = res.getString(CoreR.string.supp_group, log.gids)
sb.append("\n$gids")
}
sb.append("\n${log.command}")
return sb.toString()
}
}

View File

@@ -1,108 +0,0 @@
package com.topjohnwu.magisk.ui.module
import android.annotation.SuppressLint
import android.content.pm.ActivityInfo
import android.os.Bundle
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.databinding.FragmentActionMd2Binding
import com.topjohnwu.magisk.core.R as CoreR
class ActionFragment : BaseFragment<FragmentActionMd2Binding>(), MenuProvider {
override val layoutRes = R.layout.fragment_action_md2
override val viewModel by viewModel<ActionViewModel>()
override val snackbarView: View get() = binding.snackbarContainer
private var defaultOrientation = -1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.args = ActionFragmentArgs.fromBundle(requireArguments())
}
override fun onStart() {
super.onStart()
activity?.setTitle(viewModel.args.name)
binding.closeBtn.setOnClickListener {
activity?.onBackPressed()
}
viewModel.state.observe(this) {
if (it != ActionViewModel.State.RUNNING) {
binding.closeBtn.apply {
if (!this.isVisible) this.show()
if (!this.isFocused) this.requestFocus()
}
}
if (it != ActionViewModel.State.SUCCESS) return@observe
view?.viewTreeObserver?.addOnWindowFocusChangeListener(
object : ViewTreeObserver.OnWindowFocusChangeListener {
override fun onWindowFocusChanged(hasFocus: Boolean) {
if (hasFocus) return
view?.viewTreeObserver?.removeOnWindowFocusChangeListener(this)
view?.context?.apply {
toast(
getString(CoreR.string.done_action, viewModel.args.name),
Toast.LENGTH_SHORT
)
}
viewModel.back()
}
}
)
}
}
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_flash, menu)
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return viewModel.onMenuItemClicked(item)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
defaultOrientation = activity?.requestedOrientation ?: -1
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
if (savedInstanceState == null) {
viewModel.startRunAction()
}
}
@SuppressLint("WrongConstant")
override fun onDestroyView() {
if (defaultOrientation != -1) {
activity?.requestedOrientation = defaultOrientation
}
super.onDestroyView()
}
override fun onKeyEvent(event: KeyEvent): Boolean {
return when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> true
else -> false
}
}
override fun onBackPressed(): Boolean {
if (viewModel.state.value == ActionViewModel.State.RUNNING) return true
return super.onBackPressed()
}
override fun onPreBind(binding: FragmentActionMd2Binding) = Unit
}

View File

@@ -0,0 +1,72 @@
package com.topjohnwu.magisk.ui.module
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.topjohnwu.magisk.R
import com.topjohnwu.magisk.ui.terminal.TerminalScreen
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Back
import top.yukonga.miuix.kmp.theme.MiuixTheme
import com.topjohnwu.magisk.core.R as CoreR
@Composable
fun ActionScreen(viewModel: ActionViewModel, actionName: String, onBack: () -> Unit) {
val actionState by viewModel.actionState.collectAsState()
val finished = actionState != ActionViewModel.State.RUNNING
val scrollBehavior = MiuixScrollBehavior()
Scaffold(
topBar = {
SmallTopAppBar(
title = actionName,
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = onBack
) {
Icon(
imageVector = MiuixIcons.Back,
contentDescription = null,
tint = MiuixTheme.colorScheme.onBackground
)
}
},
actions = {
if (finished) {
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = { viewModel.saveLog() }
) {
Icon(
painter = painterResource(R.drawable.ic_save_md2),
contentDescription = stringResource(CoreR.string.menuSaveLog),
tint = MiuixTheme.colorScheme.onBackground
)
}
}
},
scrollBehavior = scrollBehavior
)
},
popupHost = { }
) { padding ->
TerminalScreen(
modifier = Modifier
.fillMaxSize()
.padding(padding),
onEmulatorCreated = { viewModel.onEmulatorCreated(it) },
)
}
}

View File

@@ -1,26 +1,20 @@
package com.topjohnwu.magisk.ui.module
import android.view.MenuItem
import androidx.databinding.ObservableArrayList
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.core.ktx.synchronized
import com.topjohnwu.magisk.core.ktx.timeFormatStandard
import com.topjohnwu.magisk.core.ktx.toTime
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.ui.flash.ConsoleItem
import com.topjohnwu.superuser.CallbackList
import com.topjohnwu.superuser.Shell
import com.topjohnwu.magisk.terminal.TerminalEmulator
import com.topjohnwu.magisk.terminal.runSuCommand
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.IOException
class ActionViewModel : BaseViewModel() {
@@ -28,61 +22,49 @@ class ActionViewModel : BaseViewModel() {
RUNNING, SUCCESS, FAILED
}
private val _state = MutableLiveData(State.RUNNING)
val state: LiveData<State> get() = _state
private val _actionState = MutableStateFlow(State.RUNNING)
val actionState: StateFlow<State> = _actionState.asStateFlow()
val items = ObservableArrayList<ConsoleItem>()
lateinit var args: ActionFragmentArgs
var actionId: String = ""
var actionName: String = ""
private val logItems = mutableListOf<String>().synchronized()
private val outItems = object : CallbackList<String>() {
override fun onAddElement(e: String?) {
e ?: return
items.add(ConsoleItem(e))
logItems.add(e)
}
private var emulator: TerminalEmulator? = null
private val emulatorReady = CompletableDeferred<TerminalEmulator>()
fun onEmulatorCreated(emu: TerminalEmulator) {
emulator = emu
emulatorReady.complete(emu)
}
fun startRunAction() = viewModelScope.launch {
onResult(withContext(Dispatchers.IO) {
try {
Shell.cmd("run_action \'${args.id}\'")
.to(outItems, logItems)
.exec().isSuccess
} catch (e: IOException) {
Timber.e(e)
false
fun startRunAction() {
viewModelScope.launch {
val emu = emulatorReady.await()
val success = withContext(Dispatchers.IO) {
runSuCommand(
emu,
"cd /data/adb/modules/$actionId && sh ./action.sh"
)
}
})
}
private fun onResult(success: Boolean) {
_state.value = if (success) State.SUCCESS else State.FAILED
}
fun onMenuItemClicked(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_save -> savePressed()
_actionState.value = if (success) State.SUCCESS else State.FAILED
}
return true
}
private fun savePressed() = withExternalRW {
fun saveLog() {
viewModelScope.launch(Dispatchers.IO) {
val name = "%s_action_log_%s.log".format(
args.name,
actionName,
System.currentTimeMillis().toTime(timeFormatStandard)
)
val file = MediaStoreUtils.getFile(name)
file.uri.outputStream().bufferedWriter().use { writer ->
synchronized(logItems) {
logItems.forEach {
writer.write(it)
writer.newLine()
}
val transcript = emulator?.screen?.transcriptText
if (transcript != null) {
writer.write(transcript)
}
}
SnackbarEvent(file.toString()).publish()
showSnackbar(file.toString())
}
}
}

View File

@@ -1,45 +0,0 @@
package com.topjohnwu.magisk.ui.module
import android.os.Bundle
import android.view.View
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.displayName
import com.topjohnwu.magisk.databinding.FragmentModuleMd2Binding
import rikka.recyclerview.addEdgeSpacing
import rikka.recyclerview.addInvalidateItemDecorationsObserver
import rikka.recyclerview.addItemSpacing
import rikka.recyclerview.fixEdgeEffect
import com.topjohnwu.magisk.core.R as CoreR
class ModuleFragment : BaseFragment<FragmentModuleMd2Binding>() {
override val layoutRes = R.layout.fragment_module_md2
override val viewModel by viewModel<ModuleViewModel>()
override fun onStart() {
super.onStart()
activity?.title = resources.getString(CoreR.string.modules)
viewModel.data.observe(this) {
it ?: return@observe
val displayName = runCatching { it.displayName }.getOrNull() ?: return@observe
viewModel.requestInstallLocalModule(it, displayName)
viewModel.data.value = null
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.moduleList.apply {
addEdgeSpacing(top = R.dimen.l_50, bottom = R.dimen.l1)
addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1)
fixEdgeEffect()
post { addInvalidateItemDecorationsObserver() }
}
}
override fun onPreBind(binding: FragmentModuleMd2Binding) = Unit
}

View File

@@ -1,78 +0,0 @@
package com.topjohnwu.magisk.ui.module
import androidx.databinding.Bindable
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.model.module.LocalModule
import com.topjohnwu.magisk.databinding.DiffItem
import com.topjohnwu.magisk.databinding.ItemWrapper
import com.topjohnwu.magisk.databinding.ObservableRvItem
import com.topjohnwu.magisk.databinding.RvItem
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.utils.TextHolder
import com.topjohnwu.magisk.utils.asText
import com.topjohnwu.magisk.core.R as CoreR
object InstallModule : RvItem(), DiffItem<InstallModule> {
override val layoutRes = R.layout.item_module_download
}
class LocalModuleRvItem(
override val item: LocalModule
) : ObservableRvItem(), DiffItem<LocalModuleRvItem>, ItemWrapper<LocalModule> {
override val layoutRes = R.layout.item_module_md2
val showNotice: Boolean
val showAction: Boolean
val noticeText: TextHolder
init {
val isZygisk = item.isZygisk
val isRiru = item.isRiru
val zygiskUnloaded = isZygisk && item.zygiskUnloaded
showNotice = zygiskUnloaded ||
(Info.isZygiskEnabled && isRiru) ||
(!Info.isZygiskEnabled && isZygisk)
showAction = item.hasAction && !showNotice
noticeText =
when {
zygiskUnloaded -> CoreR.string.zygisk_module_unloaded.asText()
isRiru -> CoreR.string.suspend_text_riru.asText(CoreR.string.zygisk.asText())
else -> CoreR.string.suspend_text_zygisk.asText(CoreR.string.zygisk.asText())
}
}
@get:Bindable
var isEnabled = item.enable
set(value) = set(value, field, { field = it }, BR.enabled, BR.updateReady) {
item.enable = value
}
@get:Bindable
var isRemoved = item.remove
set(value) = set(value, field, { field = it }, BR.removed, BR.updateReady) {
item.remove = value
}
@get:Bindable
val showUpdate get() = item.updateInfo != null
@get:Bindable
val updateReady get() = item.outdated && !isRemoved && isEnabled
val isUpdated = item.updated
fun fetchedUpdateInfo() {
notifyPropertyChanged(BR.showUpdate)
notifyPropertyChanged(BR.updateReady)
}
fun delete() {
isRemoved = !isRemoved
}
override fun itemSameAs(other: LocalModuleRvItem): Boolean = item.id == other.item.id
}

View File

@@ -0,0 +1,449 @@
package com.topjohnwu.magisk.ui.module
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.di.ServiceLocator
import com.topjohnwu.magisk.core.download.DownloadEngine
import com.topjohnwu.magisk.core.model.module.OnlineModule
import com.topjohnwu.magisk.ui.MainActivity
import com.topjohnwu.magisk.ui.component.ConfirmResult
import com.topjohnwu.magisk.ui.component.MarkdownTextAsync
import com.topjohnwu.magisk.ui.component.rememberConfirmDialog
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.FloatingActionButton
import top.yukonga.miuix.kmp.basic.HorizontalDivider
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Switch
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Add
import top.yukonga.miuix.kmp.icon.extended.Delete
import top.yukonga.miuix.kmp.icon.extended.Play
import top.yukonga.miuix.kmp.icon.extended.Undo
import top.yukonga.miuix.kmp.icon.extended.UploadCloud
import top.yukonga.miuix.kmp.theme.MiuixTheme
import com.topjohnwu.magisk.core.R as CoreR
@Composable
fun ModuleScreen(viewModel: ModuleViewModel) {
val uiState by viewModel.uiState.collectAsState()
val scrollBehavior = MiuixScrollBehavior()
val colorScheme = MiuixTheme.colorScheme
val context = LocalContext.current
val scope = rememberCoroutineScope()
val activity = context as MainActivity
var pendingZipUri by remember { mutableStateOf<Uri?>(null) }
var pendingZipName by remember { mutableStateOf("") }
val localInstallDialog = rememberConfirmDialog()
val confirmInstallTitle = stringResource(CoreR.string.confirm_install_title)
var pendingOnlineModule by remember { mutableStateOf<OnlineModule?>(null) }
val showOnlineDialog = rememberSaveable { mutableStateOf(false) }
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri != null) {
val displayName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && idx >= 0) cursor.getString(idx) else null
} ?: uri.lastPathSegment ?: "module.zip"
pendingZipUri = uri
pendingZipName = displayName
scope.launch {
val result = localInstallDialog.awaitConfirm(
title = confirmInstallTitle,
content = context.getString(CoreR.string.confirm_install, displayName),
)
if (result == ConfirmResult.Confirmed) {
viewModel.confirmLocalInstall(uri)
}
pendingZipUri = null
}
}
}
if (showOnlineDialog.value && pendingOnlineModule != null) {
OnlineModuleDialog(
item = pendingOnlineModule!!,
showDialog = showOnlineDialog,
onDownload = { install ->
showOnlineDialog.value = false
DownloadEngine.startWithActivity(
activity, activity.extension,
OnlineModuleSubject(pendingOnlineModule!!, install)
)
pendingOnlineModule = null
},
onDismiss = {
showOnlineDialog.value = false
pendingOnlineModule = null
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = stringResource(CoreR.string.modules),
scrollBehavior = scrollBehavior
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { filePicker.launch("application/zip") },
shadowElevation = 0.dp,
modifier = Modifier
.padding(bottom = 88.dp, end = 20.dp)
.border(0.05.dp, colorScheme.outline.copy(alpha = 0.5f), CircleShape),
content = {
Icon(
imageVector = MiuixIcons.Add,
contentDescription = stringResource(CoreR.string.module_action_install_external),
modifier = Modifier.size(28.dp),
tint = colorScheme.onPrimary
)
},
)
},
popupHost = { }
) { padding ->
if (uiState.loading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
return@Scaffold
}
if (uiState.modules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(CoreR.string.module_empty),
style = MiuixTheme.textStyles.body1,
color = colorScheme.onSurfaceVariantSummary
)
}
return@Scaffold
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(padding)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(bottom = 160.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { Spacer(Modifier.height(4.dp)) }
items(uiState.modules, key = { it.module.id }) { item ->
ModuleCard(
item = item,
viewModel = viewModel,
onUpdateClick = { onlineModule ->
if (onlineModule != null && Info.isConnected.value == true) {
pendingOnlineModule = onlineModule
showOnlineDialog.value = true
}
}
)
}
item { Spacer(Modifier.height(4.dp)) }
}
}
}
@Composable
private fun ModuleCard(item: ModuleItem, viewModel: ModuleViewModel, onUpdateClick: (OnlineModule?) -> Unit) {
val infoAlpha = if (!item.isRemoved && item.isEnabled && !item.showNotice) 1f else 0.5f
val strikeThrough = if (item.isRemoved) TextDecoration.LineThrough else TextDecoration.None
val colorScheme = MiuixTheme.colorScheme
val actionIconTint = colorScheme.onSurface.copy(alpha = 0.8f)
val actionBg = colorScheme.secondaryContainer.copy(alpha = 0.8f)
val updateBg = colorScheme.tertiaryContainer.copy(alpha = 0.6f)
val updateTint = colorScheme.onTertiaryContainer.copy(alpha = 0.8f)
val removeBg = colorScheme.errorContainer.copy(alpha = 0.6f)
val removeTint = colorScheme.onErrorContainer.copy(alpha = 0.8f)
var expanded by rememberSaveable(item.module.id) { mutableStateOf(false) }
val hasDescription = item.module.description.isNotBlank()
Card(
modifier = Modifier.fillMaxWidth(),
insideMargin = PaddingValues(16.dp),
onClick = { if (hasDescription) expanded = !expanded }
) {
Column(modifier = Modifier.alpha(infoAlpha)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = item.module.name,
style = MiuixTheme.textStyles.body1,
textDecoration = strikeThrough,
)
Text(
text = stringResource(
CoreR.string.module_version_author,
item.module.version,
item.module.author
),
style = MiuixTheme.textStyles.body2,
color = colorScheme.onSurfaceVariantSummary,
textDecoration = strikeThrough,
)
}
Switch(
checked = item.isEnabled,
onCheckedChange = { viewModel.toggleEnabled(item) }
)
}
if (hasDescription) {
Box(
modifier = Modifier
.padding(top = 2.dp)
.animateContentSize()
) {
Text(
text = item.module.description,
style = MiuixTheme.textStyles.body2,
color = colorScheme.onSurfaceVariantSummary,
textDecoration = strikeThrough,
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
maxLines = if (expanded) Int.MAX_VALUE else 4,
)
}
}
if (item.showNotice) {
Spacer(Modifier.height(4.dp))
Text(
text = item.noticeText,
style = MiuixTheme.textStyles.body2,
color = colorScheme.primary,
)
}
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 0.5.dp,
color = colorScheme.outline.copy(alpha = 0.5f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
AnimatedVisibility(
visible = item.isEnabled && !item.isRemoved,
enter = fadeIn(),
exit = fadeOut()
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (item.showAction) {
IconButton(
backgroundColor = actionBg,
minHeight = 35.dp,
minWidth = 35.dp,
onClick = { viewModel.runAction(item.module.id, item.module.name) },
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = MiuixIcons.Play,
tint = actionIconTint,
contentDescription = stringResource(CoreR.string.module_action)
)
Text(
text = stringResource(CoreR.string.module_action),
color = actionIconTint,
style = MiuixTheme.textStyles.body2,
)
}
}
}
}
}
Spacer(Modifier.weight(1f))
AnimatedVisibility(
visible = item.showUpdate && item.updateReady,
enter = fadeIn(),
exit = fadeOut()
) {
IconButton(
modifier = Modifier.padding(end = 8.dp),
backgroundColor = updateBg,
minHeight = 35.dp,
minWidth = 35.dp,
onClick = { onUpdateClick(item.module.updateInfo) },
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = MiuixIcons.UploadCloud,
tint = updateTint,
contentDescription = stringResource(CoreR.string.update),
)
Text(
text = stringResource(CoreR.string.update),
color = updateTint,
style = MiuixTheme.textStyles.body2,
)
}
}
}
IconButton(
backgroundColor = if (item.isRemoved) actionBg else removeBg,
minHeight = 35.dp,
minWidth = 35.dp,
onClick = { viewModel.toggleRemove(item) },
enabled = !item.isUpdated
) {
val tint = if (item.isRemoved) actionIconTint else removeTint
Row(
modifier = Modifier.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = if (item.isRemoved) MiuixIcons.Undo else MiuixIcons.Delete,
tint = tint,
contentDescription = null
)
Text(
text = stringResource(
if (item.isRemoved) CoreR.string.module_state_restore
else CoreR.string.module_state_remove
),
color = tint,
style = MiuixTheme.textStyles.body2,
)
}
}
}
}
}
@Composable
private fun OnlineModuleDialog(
item: OnlineModule,
showDialog: MutableState<Boolean>,
onDownload: (install: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val svc = ServiceLocator.networkService
val title = stringResource(
CoreR.string.repo_install_title,
item.name, item.version, item.versionCode
)
SuperDialog(
show = showDialog,
title = title,
onDismissRequest = onDismiss,
) {
MarkdownTextAsync {
val str = svc.fetchString(item.changelog)
if (str.length > 1000) str.substring(0, 1000) else str
}
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
text = stringResource(android.R.string.cancel),
onClick = onDismiss,
)
Spacer(Modifier.weight(1f))
TextButton(
text = stringResource(CoreR.string.download),
onClick = { onDownload(false) },
)
Spacer(Modifier.width(8.dp))
TextButton(
text = stringResource(CoreR.string.install),
onClick = { onDownload(true) },
colors = ButtonDefaults.textButtonColorsPrimary()
)
}
}
}

View File

@@ -1,108 +1,126 @@
package com.topjohnwu.magisk.ui.module
import android.content.Context
import android.net.Uri
import androidx.databinding.Bindable
import androidx.lifecycle.MutableLiveData
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.MainDirections
import com.topjohnwu.magisk.R
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.base.ContentResultCallback
import com.topjohnwu.magisk.core.download.Subject
import com.topjohnwu.magisk.core.model.module.LocalModule
import com.topjohnwu.magisk.core.model.module.OnlineModule
import com.topjohnwu.magisk.databinding.MergeObservableList
import com.topjohnwu.magisk.databinding.RvItem
import com.topjohnwu.magisk.databinding.bindExtra
import com.topjohnwu.magisk.databinding.diffList
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.dialog.LocalModuleInstallDialog
import com.topjohnwu.magisk.dialog.OnlineModuleInstallDialog
import com.topjohnwu.magisk.events.GetContentEvent
import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.ui.flash.FlashUtils
import com.topjohnwu.magisk.view.Notifications
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import com.topjohnwu.magisk.ui.navigation.Route
import com.topjohnwu.magisk.core.R as CoreR
class ModuleItem(val module: LocalModule) {
val showNotice: Boolean
val showAction: Boolean
val noticeText: String
init {
val isZygisk = module.isZygisk
val isRiru = module.isRiru
val zygiskUnloaded = isZygisk && module.zygiskUnloaded
showNotice = zygiskUnloaded ||
(Info.isZygiskEnabled && isRiru) ||
(!Info.isZygiskEnabled && isZygisk)
showAction = module.hasAction && !showNotice
noticeText = when {
zygiskUnloaded -> "Zygisk module not loaded due to incompatibility"
isRiru -> "Module suspended because Zygisk is enabled"
else -> "Module suspended because Zygisk isn't enabled"
}
}
var isEnabled by mutableStateOf(module.enable)
var isRemoved by mutableStateOf(module.remove)
var showUpdate by mutableStateOf(module.updateInfo != null)
val isUpdated = module.updated
val updateReady get() = module.outdated && !isRemoved && isEnabled
}
@Parcelize
class OnlineModuleSubject(
override val module: OnlineModule,
override val autoLaunch: Boolean,
override val notifyId: Int = Notifications.nextId()
) : Subject.Module() {
override fun pendingIntent(context: Context) = FlashUtils.installIntent(context, file)
}
class ModuleViewModel : AsyncLoadViewModel() {
val bottomBarBarrierIds = intArrayOf(R.id.module_update, R.id.module_remove)
data class UiState(
val loading: Boolean = true,
val modules: List<ModuleItem> = emptyList(),
)
private val itemsInstalled = diffList<LocalModuleRvItem>()
val items = MergeObservableList<RvItem>()
val extraBindings = bindExtra {
it.put(BR.viewModel, this)
}
val data get() = uri
@get:Bindable
var loading = true
private set(value) = set(value, field, { field = it }, BR.loading)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
override suspend fun doLoadWork() {
loading = true
_uiState.update { it.copy(loading = true) }
val moduleLoaded = Info.env.isActive &&
withContext(Dispatchers.IO) { LocalModule.loaded() }
withContext(Dispatchers.IO) { LocalModule.loaded() }
if (moduleLoaded) {
loadInstalled()
if (items.isEmpty()) {
items.insertItem(InstallModule)
.insertList(itemsInstalled)
val modules = withContext(Dispatchers.Default) {
LocalModule.installed().map { ModuleItem(it) }
}
_uiState.update { it.copy(loading = false, modules = modules) }
loadUpdateInfo()
} else {
_uiState.update { it.copy(loading = false) }
}
loading = false
loadUpdateInfo()
}
override fun onNetworkChanged(network: Boolean) = startLoading()
private val networkObserver: (Boolean) -> Unit = { startLoading() }
private suspend fun loadInstalled() {
withContext(Dispatchers.Default) {
val installed = LocalModule.installed().map { LocalModuleRvItem(it) }
itemsInstalled.update(installed)
}
init {
Info.isConnected.observeForever(networkObserver)
}
override fun onCleared() {
super.onCleared()
Info.isConnected.removeObserver(networkObserver)
}
private suspend fun loadUpdateInfo() {
withContext(Dispatchers.IO) {
itemsInstalled.forEach {
if (it.item.fetch())
it.fetchedUpdateInfo()
_uiState.value.modules.forEach { item ->
if (item.module.fetch()) {
item.showUpdate = item.module.updateInfo != null
}
}
}
}
fun downloadPressed(item: OnlineModule?) =
if (item != null && Info.isConnected.value == true) {
withExternalRW { OnlineModuleInstallDialog(item).show() }
} else {
SnackbarEvent(CoreR.string.no_connection).publish()
}
fun installPressed() = withExternalRW {
GetContentEvent("application/zip", UriCallback()).publish()
}
fun requestInstallLocalModule(uri: Uri, displayName: String) {
LocalModuleInstallDialog(this, uri, displayName).show()
}
@Parcelize
class UriCallback : ContentResultCallback {
override fun onActivityResult(result: Uri) {
uri.value = result
}
fun confirmLocalInstall(uri: Uri) {
navigateTo(Route.Flash(Const.Value.FLASH_ZIP, uri.toString()))
}
fun runAction(id: String, name: String) {
MainDirections.actionActionFragment(id, name).navigate()
navigateTo(Route.Action(id, name))
}
companion object {
private val uri = MutableLiveData<Uri?>()
fun toggleEnabled(item: ModuleItem) {
item.isEnabled = !item.isEnabled
item.module.enable = item.isEnabled
}
fun toggleRemove(item: ModuleItem) {
item.isRemoved = !item.isRemoved
item.module.remove = item.isRemoved
}
}

View File

@@ -0,0 +1,14 @@
package com.topjohnwu.magisk.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.topjohnwu.magisk.arch.BaseViewModel
@Composable
fun CollectNavEvents(viewModel: BaseViewModel, navigator: Navigator) {
LaunchedEffect(viewModel) {
viewModel.navEvents.collect { route ->
navigator.push(route)
}
}
}

View File

@@ -0,0 +1,72 @@
package com.topjohnwu.magisk.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.navigation3.runtime.NavKey
class Navigator(initialKey: NavKey) {
val backStack: SnapshotStateList<NavKey> = mutableStateListOf(initialKey)
fun push(key: NavKey) {
backStack.add(key)
}
fun replace(key: NavKey) {
if (backStack.isNotEmpty()) {
backStack[backStack.lastIndex] = key
} else {
backStack.add(key)
}
}
fun replaceAll(keys: List<NavKey>) {
if (keys.isEmpty()) return
if (backStack.isNotEmpty()) {
backStack.clear()
backStack.addAll(keys)
}
}
fun pop() {
backStack.removeLastOrNull()
}
fun popUntil(predicate: (NavKey) -> Boolean) {
while (backStack.isNotEmpty() && !predicate(backStack.last())) {
backStack.removeAt(backStack.lastIndex)
}
}
fun current(): NavKey? = backStack.lastOrNull()
fun backStackSize(): Int = backStack.size
companion object {
val Saver: Saver<Navigator, Any> = listSaver(
save = { navigator -> navigator.backStack.toList() },
restore = { savedList ->
val initialKey = savedList.firstOrNull() ?: Route.Main
Navigator(initialKey).also {
it.backStack.clear()
it.backStack.addAll(savedList)
}
}
)
}
}
@Composable
fun rememberNavigator(startRoute: NavKey): Navigator {
return rememberSaveable(startRoute, saver = Navigator.Saver) {
Navigator(startRoute)
}
}
val LocalNavigator = staticCompositionLocalOf<Navigator> {
error("LocalNavigator not provided")
}

View File

@@ -0,0 +1,36 @@
package com.topjohnwu.magisk.ui.navigation
import android.net.Uri
import android.os.Parcelable
import androidx.navigation3.runtime.NavKey
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
sealed interface Route : NavKey, Parcelable {
@Parcelize
@Serializable
data object Main : Route
@Parcelize
@Serializable
data object DenyList : Route
@Parcelize
@Serializable
data class Flash(
val action: String,
val additionalData: String? = null,
) : Route
@Parcelize
@Serializable
data class SuperuserDetail(val uid: Int) : Route
@Parcelize
@Serializable
data class Action(
val id: String,
val name: String,
) : Route
}

View File

@@ -1,145 +0,0 @@
package com.topjohnwu.magisk.ui.settings
import android.content.Context
import android.content.res.Resources
import android.view.View
import androidx.databinding.Bindable
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.ktx.activity
import com.topjohnwu.magisk.databinding.ObservableRvItem
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.utils.TextHolder
import com.topjohnwu.magisk.view.MagiskDialog
sealed class BaseSettingsItem : ObservableRvItem() {
interface Handler {
fun onItemPressed(view: View, item: BaseSettingsItem, andThen: () -> Unit)
fun onItemAction(view: View, item: BaseSettingsItem)
}
override val layoutRes get() = R.layout.item_settings
open val icon: Int get() = 0
open val title: TextHolder get() = TextHolder.EMPTY
@get:Bindable
open val description: TextHolder get() = TextHolder.EMPTY
@get:Bindable
var isEnabled = true
set(value) = set(value, field, { field = it }, BR.enabled, BR.description)
open fun onPressed(view: View, handler: Handler) {
handler.onItemPressed(view, this) {
handler.onItemAction(view, this)
}
}
open fun refresh() {}
// Only for toggle
open val showSwitch get() = false
@get:Bindable
open val isChecked get() = false
fun onToggle(view: View, handler: Handler, checked: Boolean) =
set(checked, isChecked, { onPressed(view, handler) })
abstract class Value<T> : BaseSettingsItem() {
/**
* Represents last agreed-upon value by the validation process and the user for current
* child. Be very aware that this shouldn't be **set** unless both sides agreed that _that_
* is the new value.
* */
abstract var value: T
protected set
}
abstract class Toggle : Value<Boolean>() {
override val showSwitch get() = true
override val isChecked get() = value
override fun onPressed(view: View, handler: Handler) {
// Make sure the checked state is synced
notifyPropertyChanged(BR.checked)
handler.onItemPressed(view, this) {
value = !value
notifyPropertyChanged(BR.checked)
handler.onItemAction(view, this)
}
}
}
abstract class Input : Value<String>() {
@get:Bindable
abstract val inputResult: String?
override fun onPressed(view: View, handler: Handler) {
handler.onItemPressed(view, this) {
MagiskDialog(view.activity).apply {
setTitle(title.getText(view.resources))
setView(getView(view.context))
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = android.R.string.ok
onClick {
inputResult?.let { result ->
doNotDismiss = false
value = result
handler.onItemAction(view, this@Input)
return@onClick
}
doNotDismiss = true
}
}
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = android.R.string.cancel
}
}.show()
}
}
abstract fun getView(context: Context): View
}
abstract class Selector : Value<Int>() {
open val entryRes get() = -1
open val descriptionRes get() = entryRes
open fun entries(res: Resources) = res.getArrayOrEmpty(entryRes)
open fun descriptions(res: Resources) = res.getArrayOrEmpty(descriptionRes)
override val description = object : TextHolder() {
override fun getText(resources: Resources): CharSequence {
return descriptions(resources).getOrElse(value) { "" }
}
}
private fun Resources.getArrayOrEmpty(id: Int): Array<String> =
runCatching { getStringArray(id) }.getOrDefault(emptyArray())
override fun onPressed(view: View, handler: Handler) {
handler.onItemPressed(view, this) {
MagiskDialog(view.activity).apply {
setTitle(title.getText(view.resources))
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = android.R.string.cancel
}
setListItems(entries(view.resources)) {
if (value != it) {
value = it
notifyPropertyChanged(BR.description)
handler.onItemAction(view, this@Selector)
}
}
}.show()
}
}
}
abstract class Blank : BaseSettingsItem()
abstract class Section : BaseSettingsItem() {
override val layoutRes = R.layout.item_settings_section
}
}

View File

@@ -1,40 +0,0 @@
package com.topjohnwu.magisk.ui.settings
import android.os.Bundle
import android.view.View
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.databinding.FragmentSettingsMd2Binding
import rikka.recyclerview.addEdgeSpacing
import rikka.recyclerview.addItemSpacing
import rikka.recyclerview.fixEdgeEffect
import com.topjohnwu.magisk.core.R as CoreR
class SettingsFragment : BaseFragment<FragmentSettingsMd2Binding>() {
override val layoutRes = R.layout.fragment_settings_md2
override val viewModel by viewModel<SettingsViewModel>()
override val snackbarView: View get() = binding.snackbarContainer
override fun onStart() {
super.onStart()
activity?.title = resources.getString(CoreR.string.settings)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.settingsList.apply {
addEdgeSpacing(bottom = R.dimen.l1)
addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1)
fixEdgeEffect()
}
}
override fun onResume() {
super.onResume()
viewModel.items.forEach { it.refresh() }
}
}

View File

@@ -1,333 +0,0 @@
package com.topjohnwu.magisk.ui.settings
import android.content.Context
import android.content.res.Resources
import android.os.Build
import android.view.LayoutInflater
import android.view.View
import androidx.databinding.Bindable
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.BuildConfig
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.ktx.activity
import com.topjohnwu.magisk.core.tasks.AppMigration
import com.topjohnwu.magisk.core.utils.LocaleSetting
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
import com.topjohnwu.magisk.databinding.DialogSettingsAppNameBinding
import com.topjohnwu.magisk.databinding.DialogSettingsDownloadPathBinding
import com.topjohnwu.magisk.databinding.DialogSettingsUpdateChannelBinding
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.utils.TextHolder
import com.topjohnwu.magisk.utils.asText
import com.topjohnwu.magisk.view.MagiskDialog
import com.topjohnwu.superuser.Shell
import com.topjohnwu.magisk.core.R as CoreR
// --- Customization
object Customization : BaseSettingsItem.Section() {
override val title = CoreR.string.settings_customization.asText()
}
object Language : BaseSettingsItem.Selector() {
private val names: Array<String> get() = LocaleSetting.available.names
private val tags: Array<String> get() = LocaleSetting.available.tags
override var value
get() = tags.indexOf(Config.locale)
set(value) {
Config.locale = tags[value]
}
override val title = CoreR.string.language.asText()
override fun entries(res: Resources) = names
override fun descriptions(res: Resources) = names
}
object LanguageSystem : BaseSettingsItem.Blank() {
override val title = CoreR.string.language.asText()
override val description: TextHolder
get() {
val locale = LocaleSetting.instance.appLocale
return locale?.getDisplayName(locale)?.asText() ?: CoreR.string.system_default.asText()
}
}
object Theme : BaseSettingsItem.Blank() {
override val icon = R.drawable.ic_paint
override val title = CoreR.string.section_theme.asText()
}
// --- App
object AppSettings : BaseSettingsItem.Section() {
override val title = CoreR.string.home_app_title.asText()
}
object Hide : BaseSettingsItem.Input() {
override val title = CoreR.string.settings_hide_app_title.asText()
override val description = CoreR.string.settings_hide_app_summary.asText()
override var value = ""
override val inputResult
get() = if (isError) null else result
@get:Bindable
var result = "Settings"
set(value) = set(value, field, { field = it }, BR.result, BR.error)
val maxLength
get() = AppMigration.MAX_LABEL_LENGTH
@get:Bindable
val isError
get() = result.length > maxLength || result.isBlank()
override fun getView(context: Context) = DialogSettingsAppNameBinding
.inflate(LayoutInflater.from(context)).also { it.data = this }.root
}
object Restore : BaseSettingsItem.Blank() {
override val title = CoreR.string.settings_restore_app_title.asText()
override val description = CoreR.string.settings_restore_app_summary.asText()
override fun onPressed(view: View, handler: Handler) {
handler.onItemPressed(view, this) {
MagiskDialog(view.activity).apply {
setTitle(CoreR.string.settings_restore_app_title)
setMessage(CoreR.string.restore_app_confirmation)
setButton(MagiskDialog.ButtonType.POSITIVE) {
text = android.R.string.ok
onClick {
handler.onItemAction(view, this@Restore)
}
}
setButton(MagiskDialog.ButtonType.NEGATIVE) {
text = android.R.string.cancel
}
setCancelable(true)
show()
}
}
}
}
object AddShortcut : BaseSettingsItem.Blank() {
override val title = CoreR.string.add_shortcut_title.asText()
override val description = CoreR.string.setting_add_shortcut_summary.asText()
}
object DownloadPath : BaseSettingsItem.Input() {
override var value
get() = Config.downloadDir
set(value) {
Config.downloadDir = value
notifyPropertyChanged(BR.description)
}
override val title = CoreR.string.settings_download_path_title.asText()
override val description get() = MediaStoreUtils.fullPath(value).asText()
override var inputResult: String = value
set(value) = set(value, field, { field = it }, BR.inputResult, BR.path)
@get:Bindable
val path get() = MediaStoreUtils.fullPath(inputResult)
override fun getView(context: Context) = DialogSettingsDownloadPathBinding
.inflate(LayoutInflater.from(context)).also { it.data = this }.root
}
object UpdateChannel : BaseSettingsItem.Selector() {
override var value
get() = Config.updateChannel
set(value) {
Config.updateChannel = value
Info.resetUpdate()
}
override val title = CoreR.string.settings_update_channel_title.asText()
override val entryRes = CoreR.array.update_channel
}
object UpdateChannelUrl : BaseSettingsItem.Input() {
override val title = CoreR.string.settings_update_custom.asText()
override val description get() = value.asText()
override var value
get() = Config.customChannelUrl
set(value) {
Config.customChannelUrl = value
Info.resetUpdate()
notifyPropertyChanged(BR.description)
}
override var inputResult: String = value
set(value) = set(value, field, { field = it }, BR.inputResult)
override fun refresh() {
isEnabled = UpdateChannel.value == Config.Value.CUSTOM_CHANNEL
}
override fun getView(context: Context) = DialogSettingsUpdateChannelBinding
.inflate(LayoutInflater.from(context)).also { it.data = this }.root
}
object UpdateChecker : BaseSettingsItem.Toggle() {
override val title = CoreR.string.settings_check_update_title.asText()
override val description = CoreR.string.settings_check_update_summary.asText()
override var value by Config::checkUpdate
}
object DoHToggle : BaseSettingsItem.Toggle() {
override val title = CoreR.string.settings_doh_title.asText()
override val description = CoreR.string.settings_doh_description.asText()
override var value by Config::doh
}
object SystemlessHosts : BaseSettingsItem.Blank() {
override val title = CoreR.string.settings_hosts_title.asText()
override val description = CoreR.string.settings_hosts_summary.asText()
}
object RandNameToggle : BaseSettingsItem.Toggle() {
override val title = CoreR.string.settings_random_name_title.asText()
override val description = CoreR.string.settings_random_name_description.asText()
override var value by Config::randName
}
// --- Magisk
object Magisk : BaseSettingsItem.Section() {
override val title = CoreR.string.magisk.asText()
}
object Zygisk : BaseSettingsItem.Toggle() {
override val title = CoreR.string.zygisk.asText()
override val description get() =
if (mismatch) CoreR.string.reboot_apply_change.asText()
else CoreR.string.settings_zygisk_summary.asText()
override var value
get() = Config.zygisk
set(value) {
Config.zygisk = value
notifyPropertyChanged(BR.description)
}
val mismatch get() = value != Info.isZygiskEnabled
}
object DenyList : BaseSettingsItem.Toggle() {
override val title = CoreR.string.settings_denylist_title.asText()
override val description get() = CoreR.string.settings_denylist_summary.asText()
override var value = Config.denyList
set(value) {
field = value
val cmd = if (value) "enable" else "disable"
Shell.cmd("magisk --denylist $cmd").submit { result ->
if (result.isSuccess) {
Config.denyList = value
} else {
field = !value
notifyPropertyChanged(BR.checked)
}
}
}
}
object DenyListConfig : BaseSettingsItem.Blank() {
override val title = CoreR.string.settings_denylist_config_title.asText()
override val description = CoreR.string.settings_denylist_config_summary.asText()
}
// --- Superuser
object Tapjack : BaseSettingsItem.Toggle() {
override val title = CoreR.string.settings_su_tapjack_title.asText()
override val description = CoreR.string.settings_su_tapjack_summary.asText()
override var value by Config::suTapjack
}
object Authentication : BaseSettingsItem.Toggle() {
override val title = CoreR.string.settings_su_auth_title.asText()
override var description = CoreR.string.settings_su_auth_summary.asText()
override var value by Config::suAuth
override fun refresh() {
isEnabled = Info.isDeviceSecure
if (!isEnabled) {
description = CoreR.string.settings_su_auth_insecure.asText()
}
}
}
object Superuser : BaseSettingsItem.Section() {
override val title = CoreR.string.superuser.asText()
}
object AccessMode : BaseSettingsItem.Selector() {
override val title = CoreR.string.superuser_access.asText()
override val entryRes = CoreR.array.su_access
override var value by Config::rootMode
}
object MultiuserMode : BaseSettingsItem.Selector() {
override val title = CoreR.string.multiuser_mode.asText()
override val entryRes = CoreR.array.multiuser_mode
override val descriptionRes = CoreR.array.multiuser_summary
override var value by Config::suMultiuserMode
override fun refresh() {
isEnabled = Const.USER_ID == 0
}
}
object MountNamespaceMode : BaseSettingsItem.Selector() {
override val title = CoreR.string.mount_namespace_mode.asText()
override val entryRes = CoreR.array.namespace
override val descriptionRes = CoreR.array.namespace_summary
override var value by Config::suMntNamespaceMode
}
object AutomaticResponse : BaseSettingsItem.Selector() {
override val title = CoreR.string.auto_response.asText()
override val entryRes = CoreR.array.auto_response
override var value by Config::suAutoResponse
}
object RequestTimeout : BaseSettingsItem.Selector() {
override val title = CoreR.string.request_timeout.asText()
override val entryRes = CoreR.array.request_timeout
private val entryValues = listOf(10, 15, 20, 30, 45, 60)
override var value = entryValues.indexOfFirst { it == Config.suDefaultTimeout }
set(value) {
field = value
Config.suDefaultTimeout = entryValues[value]
}
}
object SUNotification : BaseSettingsItem.Selector() {
override val title = CoreR.string.superuser_notification.asText()
override val entryRes = CoreR.array.su_notification
override var value by Config::suNotification
}
object Reauthenticate : BaseSettingsItem.Toggle() {
override val title = CoreR.string.settings_su_reauth_title.asText()
override val description = CoreR.string.settings_su_reauth_summary.asText()
override var value by Config::suReAuth
override fun refresh() {
isEnabled = Build.VERSION.SDK_INT < Build.VERSION_CODES.O
}
}
object Restrict : BaseSettingsItem.Toggle() {
override val title = CoreR.string.settings_su_restrict_title.asText()
override val description = CoreR.string.settings_su_restrict_summary.asText()
override var value by Config::suRestrict
}

View File

@@ -0,0 +1,625 @@
package com.topjohnwu.magisk.ui.settings
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.core.content.pm.ShortcutManagerCompat
import com.topjohnwu.magisk.core.BuildConfig
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.isRunningAsStub
import com.topjohnwu.magisk.core.tasks.AppMigration
import com.topjohnwu.magisk.core.utils.LocaleSetting
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
import com.topjohnwu.magisk.ui.component.rememberLoadingDialog
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTitle
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TextButton
import com.topjohnwu.magisk.ui.theme.ThemeState
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
import com.topjohnwu.magisk.core.R as CoreR
@Composable
fun SettingsScreen(viewModel: SettingsViewModel) {
val scrollBehavior = MiuixScrollBehavior()
Scaffold(
topBar = {
TopAppBar(
title = stringResource(CoreR.string.settings),
scrollBehavior = scrollBehavior
)
},
popupHost = { }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 12.dp)
.padding(bottom = 88.dp)
) {
CustomizationSection(viewModel)
Spacer(Modifier.height(12.dp))
AppSettingsSection(viewModel)
if (Info.env.isActive) {
Spacer(Modifier.height(12.dp))
MagiskSection(viewModel)
}
if (Info.showSuperUser) {
Spacer(Modifier.height(12.dp))
SuperuserSection(viewModel)
}
}
}
}
// --- Customization ---
@Composable
private fun CustomizationSection(viewModel: SettingsViewModel) {
val context = LocalContext.current
SmallTitle(text = stringResource(CoreR.string.settings_customization))
Card(modifier = Modifier.fillMaxWidth()) {
if (LocaleSetting.useLocaleManager) {
val locale = LocaleSetting.instance.appLocale
val summary = locale?.getDisplayName(locale) ?: stringResource(CoreR.string.system_default)
SuperArrow(
title = stringResource(CoreR.string.language),
summary = summary,
onClick = {
context.startActivity(LocaleSetting.localeSettingsIntent)
}
)
} else {
val names = remember { LocaleSetting.available.names }
val tags = remember { LocaleSetting.available.tags }
var selectedIndex by remember {
mutableIntStateOf(tags.indexOf(Config.locale).coerceAtLeast(0))
}
SuperDropdown(
title = stringResource(CoreR.string.language),
items = names.toList(),
selectedIndex = selectedIndex,
onSelectedIndexChange = { index ->
selectedIndex = index
Config.locale = tags[index]
}
)
}
// Color Mode
val resources = context.resources
val colorModeEntries = remember {
resources.getStringArray(CoreR.array.color_mode).toList()
}
var colorMode by remember { mutableIntStateOf(Config.colorMode) }
SuperDropdown(
title = stringResource(CoreR.string.settings_color_mode),
items = colorModeEntries,
selectedIndex = colorMode,
onSelectedIndexChange = { index ->
colorMode = index
Config.colorMode = index
ThemeState.colorMode = index
}
)
if (isRunningAsStub && ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
SuperArrow(
title = stringResource(CoreR.string.add_shortcut_title),
summary = stringResource(CoreR.string.setting_add_shortcut_summary),
onClick = { viewModel.requestAddShortcut() }
)
}
}
}
// --- App Settings ---
@Composable
private fun AppSettingsSection(viewModel: SettingsViewModel) {
val context = LocalContext.current
val resources = context.resources
val hidden = context.packageName != BuildConfig.APP_PACKAGE_NAME
SmallTitle(text = stringResource(CoreR.string.home_app_title))
Card(modifier = Modifier.fillMaxWidth()) {
// Update Channel
val updateChannelEntries = remember {
resources.getStringArray(CoreR.array.update_channel).toList()
}
var updateChannel by remember {
mutableIntStateOf(Config.updateChannel.coerceIn(0, updateChannelEntries.size - 1))
}
var showUrlDialog by remember { mutableStateOf(false) }
SuperDropdown(
title = stringResource(CoreR.string.settings_update_channel_title),
items = updateChannelEntries,
selectedIndex = updateChannel,
onSelectedIndexChange = { index ->
updateChannel = index
Config.updateChannel = index
Info.resetUpdate()
if (index == Config.Value.CUSTOM_CHANNEL && Config.customChannelUrl.isBlank()) {
showUrlDialog = true
}
}
)
// Update Channel URL (for custom channel)
if (updateChannel == Config.Value.CUSTOM_CHANNEL) {
UpdateChannelUrlDialog(
show = showUrlDialog,
onDismiss = { showUrlDialog = false }
)
SuperArrow(
title = stringResource(CoreR.string.settings_update_custom),
summary = Config.customChannelUrl.ifBlank { null },
onClick = { showUrlDialog = true }
)
}
// DoH Toggle
var doh by remember { mutableStateOf(Config.doh) }
SuperSwitch(
title = stringResource(CoreR.string.settings_doh_title),
summary = stringResource(CoreR.string.settings_doh_description),
checked = doh,
onCheckedChange = {
doh = it
Config.doh = it
}
)
// Update Checker
var checkUpdate by remember { mutableStateOf(Config.checkUpdate) }
SuperSwitch(
title = stringResource(CoreR.string.settings_check_update_title),
summary = stringResource(CoreR.string.settings_check_update_summary),
checked = checkUpdate,
onCheckedChange = { newValue ->
checkUpdate = newValue
Config.checkUpdate = newValue
}
)
// Download Path
var showDownloadDialog by remember { mutableStateOf(false) }
DownloadPathDialog(
show = showDownloadDialog,
onDismiss = { showDownloadDialog = false }
)
SuperArrow(
title = stringResource(CoreR.string.settings_download_path_title),
summary = MediaStoreUtils.fullPath(Config.downloadDir),
onClick = {
showDownloadDialog = true
}
)
// Random Package Name
var randName by remember { mutableStateOf(Config.randName) }
SuperSwitch(
title = stringResource(CoreR.string.settings_random_name_title),
summary = stringResource(CoreR.string.settings_random_name_description),
checked = randName,
onCheckedChange = {
randName = it
Config.randName = it
}
)
}
}
// --- Magisk ---
@Composable
private fun MagiskSection(viewModel: SettingsViewModel) {
SmallTitle(text = stringResource(CoreR.string.magisk))
Card(modifier = Modifier.fillMaxWidth()) {
// Systemless Hosts
SuperArrow(
title = stringResource(CoreR.string.settings_hosts_title),
summary = stringResource(CoreR.string.settings_hosts_summary),
onClick = { viewModel.createHosts() }
)
if (Const.Version.atLeast_24_0()) {
// Zygisk
var zygisk by remember { mutableStateOf(Config.zygisk) }
SuperSwitch(
title = stringResource(CoreR.string.zygisk),
summary = stringResource(
if (zygisk != Info.isZygiskEnabled) CoreR.string.reboot_apply_change
else CoreR.string.settings_zygisk_summary
),
checked = zygisk,
onCheckedChange = {
zygisk = it
Config.zygisk = it
viewModel.notifyZygiskChange()
}
)
// DenyList
val denyListEnabled by viewModel.denyListEnabled.collectAsState()
SuperSwitch(
title = stringResource(CoreR.string.settings_denylist_title),
summary = stringResource(CoreR.string.settings_denylist_summary),
checked = denyListEnabled,
onCheckedChange = { viewModel.toggleDenyList(it) }
)
// DenyList Config
SuperArrow(
title = stringResource(CoreR.string.settings_denylist_config_title),
summary = stringResource(CoreR.string.settings_denylist_config_summary),
onClick = { viewModel.navigateToDenyList() }
)
}
}
}
// --- Superuser ---
@Composable
private fun SuperuserSection(viewModel: SettingsViewModel) {
val context = LocalContext.current
val resources = context.resources
SmallTitle(text = stringResource(CoreR.string.superuser))
Card(modifier = Modifier.fillMaxWidth()) {
// Tapjack (SDK < S)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
var tapjack by remember { mutableStateOf(Config.suTapjack) }
SuperSwitch(
title = stringResource(CoreR.string.settings_su_tapjack_title),
summary = stringResource(CoreR.string.settings_su_tapjack_summary),
checked = tapjack,
onCheckedChange = {
tapjack = it
Config.suTapjack = it
}
)
}
// Authentication
var suAuth by remember { mutableStateOf(Config.suAuth) }
SuperSwitch(
title = stringResource(CoreR.string.settings_su_auth_title),
summary = stringResource(
if (Info.isDeviceSecure) CoreR.string.settings_su_auth_summary
else CoreR.string.settings_su_auth_insecure
),
checked = suAuth,
enabled = Info.isDeviceSecure,
onCheckedChange = { newValue ->
viewModel.withAuth {
suAuth = newValue
Config.suAuth = newValue
}
}
)
// Access Mode
val accessEntries = remember {
resources.getStringArray(CoreR.array.su_access).toList()
}
var accessMode by remember { mutableIntStateOf(Config.rootMode) }
SuperDropdown(
title = stringResource(CoreR.string.superuser_access),
items = accessEntries,
selectedIndex = accessMode,
onSelectedIndexChange = {
accessMode = it
Config.rootMode = it
}
)
// Multiuser Mode
val multiuserEntries = remember {
resources.getStringArray(CoreR.array.multiuser_mode).toList()
}
val multiuserDescriptions = remember {
resources.getStringArray(CoreR.array.multiuser_summary).toList()
}
var multiuserMode by remember { mutableIntStateOf(Config.suMultiuserMode) }
SuperDropdown(
title = stringResource(CoreR.string.multiuser_mode),
summary = multiuserDescriptions.getOrElse(multiuserMode) { "" },
items = multiuserEntries,
selectedIndex = multiuserMode,
enabled = Const.USER_ID == 0,
onSelectedIndexChange = {
multiuserMode = it
Config.suMultiuserMode = it
}
)
// Mount Namespace Mode
val namespaceEntries = remember {
resources.getStringArray(CoreR.array.namespace).toList()
}
val namespaceDescriptions = remember {
resources.getStringArray(CoreR.array.namespace_summary).toList()
}
var mntNamespaceMode by remember { mutableIntStateOf(Config.suMntNamespaceMode) }
SuperDropdown(
title = stringResource(CoreR.string.mount_namespace_mode),
summary = namespaceDescriptions.getOrElse(mntNamespaceMode) { "" },
items = namespaceEntries,
selectedIndex = mntNamespaceMode,
onSelectedIndexChange = {
mntNamespaceMode = it
Config.suMntNamespaceMode = it
}
)
// Automatic Response
val autoResponseEntries = remember {
resources.getStringArray(CoreR.array.auto_response).toList()
}
var autoResponse by remember { mutableIntStateOf(Config.suAutoResponse) }
SuperDropdown(
title = stringResource(CoreR.string.auto_response),
items = autoResponseEntries,
selectedIndex = autoResponse,
onSelectedIndexChange = { newIndex ->
val doIt = {
autoResponse = newIndex
Config.suAutoResponse = newIndex
}
if (Config.suAuth) viewModel.withAuth(doIt) else doIt()
}
)
// Request Timeout
val timeoutEntries = remember {
resources.getStringArray(CoreR.array.request_timeout).toList()
}
val timeoutValues = remember { listOf(10, 15, 20, 30, 45, 60) }
var timeoutIndex by remember {
mutableIntStateOf(timeoutValues.indexOf(Config.suDefaultTimeout).coerceAtLeast(0))
}
SuperDropdown(
title = stringResource(CoreR.string.request_timeout),
items = timeoutEntries,
selectedIndex = timeoutIndex,
onSelectedIndexChange = {
timeoutIndex = it
Config.suDefaultTimeout = timeoutValues[it]
}
)
// SU Notification
val notifEntries = remember {
resources.getStringArray(CoreR.array.su_notification).toList()
}
var suNotification by remember { mutableIntStateOf(Config.suNotification) }
SuperDropdown(
title = stringResource(CoreR.string.superuser_notification),
items = notifEntries,
selectedIndex = suNotification,
onSelectedIndexChange = {
suNotification = it
Config.suNotification = it
}
)
// Reauthenticate (SDK < O)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
var reAuth by remember { mutableStateOf(Config.suReAuth) }
SuperSwitch(
title = stringResource(CoreR.string.settings_su_reauth_title),
summary = stringResource(CoreR.string.settings_su_reauth_summary),
checked = reAuth,
onCheckedChange = {
reAuth = it
Config.suReAuth = it
}
)
}
// Restrict (version >= 30.1)
if (Const.Version.atLeast_30_1()) {
var restrict by remember { mutableStateOf(Config.suRestrict) }
SuperSwitch(
title = stringResource(CoreR.string.settings_su_restrict_title),
summary = stringResource(CoreR.string.settings_su_restrict_summary),
checked = restrict,
onCheckedChange = {
restrict = it
Config.suRestrict = it
}
)
}
}
}
// --- Dialogs ---
@Composable
private fun UpdateChannelUrlDialog(show: Boolean, onDismiss: () -> Unit) {
val showState = rememberSaveable { mutableStateOf(show) }
showState.value = show
var url by rememberSaveable { mutableStateOf(Config.customChannelUrl) }
SuperDialog(
show = showState,
onDismissRequest = onDismiss,
insideMargin = DpSize(24.dp, 24.dp)
) {
Column(modifier = Modifier.padding(top = 8.dp)) {
TextField(
value = url,
onValueChange = { url = it },
modifier = Modifier.fillMaxWidth(),
label = stringResource(CoreR.string.settings_update_custom_msg)
)
Spacer(Modifier.height(16.dp))
TextButton(
text = stringResource(android.R.string.ok),
onClick = {
Config.customChannelUrl = url
Info.resetUpdate()
onDismiss()
},
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun DownloadPathDialog(show: Boolean, onDismiss: () -> Unit) {
val showState = rememberSaveable { mutableStateOf(show) }
showState.value = show
var path by rememberSaveable { mutableStateOf(Config.downloadDir) }
SuperDialog(
show = showState,
onDismissRequest = onDismiss,
insideMargin = DpSize(24.dp, 24.dp)
) {
Column(modifier = Modifier.padding(top = 8.dp)) {
top.yukonga.miuix.kmp.basic.Text(
text = stringResource(CoreR.string.settings_download_path_message, MediaStoreUtils.fullPath(path)),
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = path,
onValueChange = { path = it },
modifier = Modifier.fillMaxWidth(),
label = stringResource(CoreR.string.settings_download_path_title)
)
Spacer(Modifier.height(16.dp))
TextButton(
text = stringResource(android.R.string.ok),
onClick = {
Config.downloadDir = path
onDismiss()
},
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun HideAppDialog(show: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
val showState = rememberSaveable { mutableStateOf(show) }
showState.value = show
var appName by rememberSaveable { mutableStateOf("Settings") }
val isError = appName.length > AppMigration.MAX_LABEL_LENGTH || appName.isBlank()
SuperDialog(
show = showState,
title = stringResource(CoreR.string.settings_hide_app_title),
onDismissRequest = onDismiss,
insideMargin = DpSize(24.dp, 24.dp)
) {
Column(modifier = Modifier.padding(top = 8.dp)) {
TextField(
value = appName,
onValueChange = { appName = it },
modifier = Modifier.fillMaxWidth(),
label = stringResource(CoreR.string.settings_app_name_hint),
)
Spacer(Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(
text = stringResource(android.R.string.cancel),
onClick = onDismiss,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(20.dp))
TextButton(
text = stringResource(android.R.string.ok),
onClick = { if (!isError) onConfirm(appName) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary()
)
}
}
}
}
@Composable
private fun RestoreDialog(show: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit) {
val showState = rememberSaveable { mutableStateOf(show) }
showState.value = show
SuperDialog(
show = showState,
title = stringResource(CoreR.string.settings_restore_app_title),
onDismissRequest = onDismiss,
insideMargin = DpSize(24.dp, 24.dp)
) {
Column(modifier = Modifier.padding(top = 8.dp)) {
top.yukonga.miuix.kmp.basic.Text(
text = stringResource(CoreR.string.restore_app_confirmation),
color = MiuixTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(
text = stringResource(android.R.string.cancel),
onClick = onDismiss,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(20.dp))
TextButton(
text = stringResource(android.R.string.ok),
onClick = onConfirm,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary()
)
}
}
}
}

View File

@@ -1,138 +1,83 @@
package com.topjohnwu.magisk.ui.settings
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.view.View
import android.content.Context
import android.widget.Toast
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.lifecycle.viewModelScope
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.BuildConfig
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.core.isRunningAsStub
import com.topjohnwu.magisk.core.ktx.activity
import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.core.tasks.AppMigration
import com.topjohnwu.magisk.core.utils.LocaleSetting
import com.topjohnwu.magisk.core.utils.RootUtils
import com.topjohnwu.magisk.databinding.bindExtra
import com.topjohnwu.magisk.events.AddHomeIconEvent
import com.topjohnwu.magisk.events.AuthEvent
import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.view.Shortcuts
import com.topjohnwu.magisk.ui.navigation.Route
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SettingsViewModel : BaseViewModel(), BaseSettingsItem.Handler {
class SettingsViewModel : BaseViewModel() {
val items = createItems()
val extraBindings = bindExtra {
it.put(BR.handler, this)
private val _denyListEnabled = MutableStateFlow(Config.denyList)
val denyListEnabled: StateFlow<Boolean> = _denyListEnabled.asStateFlow()
val zygiskMismatch get() = Config.zygisk != Info.isZygiskEnabled
var authenticate: (onSuccess: () -> Unit) -> Unit = { it() }
fun navigateToDenyList() {
navigateTo(Route.DenyList)
}
private fun createItems(): List<BaseSettingsItem> {
val context = AppContext
val hidden = context.packageName != BuildConfig.APP_PACKAGE_NAME
// Customization
val list = mutableListOf(
Customization,
Theme, if (LocaleSetting.useLocaleManager) LanguageSystem else Language
)
if (isRunningAsStub && ShortcutManagerCompat.isRequestPinShortcutSupported(context))
list.add(AddShortcut)
// Manager
list.addAll(listOf(
AppSettings,
UpdateChannel, UpdateChannelUrl, DoHToggle, UpdateChecker, DownloadPath, RandNameToggle
))
if (Info.env.isActive && Const.USER_ID == 0) {
if (hidden) list.add(Restore) else list.add(Hide)
}
// Magisk
if (Info.env.isActive) {
list.addAll(listOf(
Magisk,
SystemlessHosts
))
if (Const.Version.atLeast_24_0()) {
list.addAll(listOf(Zygisk, DenyList, DenyListConfig))
}
}
// Superuser
if (Info.showSuperUser) {
list.addAll(listOf(
Superuser,
Tapjack, Authentication, AccessMode, MultiuserMode, MountNamespaceMode,
AutomaticResponse, RequestTimeout, SUNotification
))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// Re-authenticate is not feasible on 8.0+
list.add(Reauthenticate)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Can hide overlay windows on 12.0+
list.remove(Tapjack)
}
if (Const.Version.atLeast_30_1()) {
list.add(Restrict)
}
}
return list
fun requestAddShortcut() {
Shortcuts.addHomeIcon(AppContext)
}
override fun onItemPressed(view: View, item: BaseSettingsItem, doAction: () -> Unit) {
when (item) {
DownloadPath -> withExternalRW(doAction)
UpdateChecker -> withPostNotificationPermission(doAction)
Authentication -> AuthEvent(doAction).publish()
AutomaticResponse -> if (Config.suAuth) AuthEvent(doAction).publish() else doAction()
else -> doAction()
suspend fun hideApp(context: Context, name: String): Boolean {
val success = withContext(Dispatchers.IO) {
AppMigration.patchAndHide(context, name)
}
}
override fun onItemAction(view: View, item: BaseSettingsItem) {
when (item) {
Theme -> SettingsFragmentDirections.actionSettingsFragmentToThemeFragment().navigate()
LanguageSystem -> launchAppLocaleSettings(view.activity)
AddShortcut -> AddHomeIconEvent().publish()
SystemlessHosts -> createHosts()
DenyListConfig -> SettingsFragmentDirections.actionSettingsFragmentToDenyFragment().navigate()
UpdateChannel -> openUrlIfNecessary(view)
is Hide -> viewModelScope.launch { AppMigration.hide(view.activity, item.value) }
Restore -> viewModelScope.launch { AppMigration.restore(view.activity) }
Zygisk -> if (Zygisk.mismatch) SnackbarEvent(R.string.reboot_apply_change).publish()
else -> Unit
if (!success) {
context.toast(R.string.failure, Toast.LENGTH_LONG)
}
return success
}
private fun launchAppLocaleSettings(activity: Activity) {
val intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS)
intent.data = Uri.fromParts("package", activity.packageName, null)
activity.startActivity(intent)
}
private fun openUrlIfNecessary(view: View) {
UpdateChannelUrl.refresh()
if (UpdateChannelUrl.isEnabled && UpdateChannelUrl.value.isBlank()) {
UpdateChannelUrl.onPressed(view, this)
suspend fun restoreApp(context: Context): Boolean {
val success = AppMigration.restoreApp(context)
if (!success) {
context.toast(R.string.failure, Toast.LENGTH_LONG)
}
return success
}
private fun createHosts() {
fun createHosts() {
viewModelScope.launch {
RootUtils.addSystemlessHosts()
AppContext.toast(R.string.settings_hosts_toast, Toast.LENGTH_SHORT)
}
}
fun toggleDenyList(enabled: Boolean) {
_denyListEnabled.value = enabled
val cmd = if (enabled) "enable" else "disable"
Shell.cmd("magisk --denylist $cmd").submit { result ->
if (result.isSuccess) {
Config.denyList = enabled
} else {
_denyListEnabled.value = !enabled
}
}
}
fun withAuth(action: () -> Unit) = authenticate(action)
fun notifyZygiskChange() {
if (zygiskMismatch) showSnackbar(R.string.reboot_apply_change)
}
}

View File

@@ -1,102 +0,0 @@
package com.topjohnwu.magisk.ui.superuser
import android.graphics.drawable.Drawable
import androidx.databinding.Bindable
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.model.su.SuPolicy
import com.topjohnwu.magisk.databinding.DiffItem
import com.topjohnwu.magisk.databinding.ItemWrapper
import com.topjohnwu.magisk.databinding.ObservableRvItem
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.core.R as CoreR
class PolicyRvItem(
private val viewModel: SuperuserViewModel,
override val item: SuPolicy,
val packageName: String,
private val isSharedUid: Boolean,
val icon: Drawable,
val appName: String
) : ObservableRvItem(), DiffItem<PolicyRvItem>, ItemWrapper<SuPolicy> {
override val layoutRes = R.layout.item_policy_md2
val title get() = if (isSharedUid) "[SharedUID] $appName" else appName
private inline fun <reified T> setImpl(new: T, old: T, setter: (T) -> Unit) {
if (old != new) {
setter(new)
}
}
@get:Bindable
var isExpanded = false
set(value) = set(value, field, { field = it }, BR.expanded)
val showSlider = Config.suRestrict || item.policy == SuPolicy.RESTRICT
@get:Bindable
var isEnabled
get() = item.policy >= SuPolicy.ALLOW
set(value) = setImpl(value, isEnabled) {
notifyPropertyChanged(BR.enabled)
viewModel.updatePolicy(this, if (it) SuPolicy.ALLOW else SuPolicy.DENY)
}
@get:Bindable
var sliderValue
get() = item.policy
set(value) = setImpl(value, sliderValue) {
notifyPropertyChanged(BR.sliderValue)
notifyPropertyChanged(BR.enabled)
viewModel.updatePolicy(this, it)
}
val sliderValueToPolicyString: (Float) -> Int = { value ->
when (value.toInt()) {
1 -> CoreR.string.deny
2 -> CoreR.string.restrict
3 -> CoreR.string.grant
else -> CoreR.string.deny
}
}
@get:Bindable
var shouldNotify
get() = item.notification
private set(value) = setImpl(value, shouldNotify) {
item.notification = it
viewModel.updateNotify(this)
}
@get:Bindable
var shouldLog
get() = item.logging
private set(value) = setImpl(value, shouldLog) {
item.logging = it
viewModel.updateLogging(this)
}
fun toggleExpand() {
isExpanded = !isExpanded
}
fun toggleNotify() {
shouldNotify = !shouldNotify
}
fun toggleLog() {
shouldLog = !shouldLog
}
fun revoke() {
viewModel.deletePressed(this)
}
override fun itemSameAs(other: PolicyRvItem) = packageName == other.packageName
override fun contentSameAs(other: PolicyRvItem) = item.policy == other.item.policy
}

View File

@@ -0,0 +1,221 @@
package com.topjohnwu.magisk.ui.superuser
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.topjohnwu.magisk.ui.component.ConfirmResult
import com.topjohnwu.magisk.ui.component.rememberConfirmDialog
import com.topjohnwu.magisk.ui.util.rememberDrawablePainter
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Switch
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Back
import top.yukonga.miuix.kmp.theme.MiuixTheme
import com.topjohnwu.magisk.core.R as CoreR
@Composable
fun SuperuserDetailScreen(
uid: Int,
viewModel: SuperuserViewModel,
onBack: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsState()
val items = uiState.policies.filter { it.policy.uid == uid }
val item = items.firstOrNull()
val scrollBehavior = MiuixScrollBehavior()
val scope = rememberCoroutineScope()
val revokeDialog = rememberConfirmDialog()
val revokeTitle = stringResource(CoreR.string.su_revoke_title)
val revokeMsg = item?.let { stringResource(CoreR.string.su_revoke_msg, it.appName) } ?: ""
LaunchedEffect(Unit) { viewModel.refreshSuRestrict() }
Scaffold(
topBar = {
TopAppBar(
title = stringResource(CoreR.string.superuser_setting),
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = onBack
) {
Icon(
imageVector = MiuixIcons.Back,
contentDescription = stringResource(CoreR.string.back),
)
}
},
scrollBehavior = scrollBehavior
)
},
popupHost = { }
) { padding ->
if (item == null) return@Scaffold
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(padding)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
Spacer(Modifier.height(4.dp))
}
item {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = rememberDrawablePainter(item.icon),
contentDescription = item.appName,
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.width(16.dp))
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.title,
style = MiuixTheme.textStyles.headline2,
modifier = Modifier.weight(1f, fill = false),
)
if (item.isSharedUid) {
Spacer(Modifier.width(6.dp))
SharedUidBadge()
}
}
Text(
text = item.packageName,
style = MiuixTheme.textStyles.body2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
)
Text(
text = "UID: ${item.policy.uid}",
style = MiuixTheme.textStyles.body2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
)
}
}
}
}
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column {
if (uiState.suRestrict || item.isRestricted) {
SwitchRow(
title = stringResource(CoreR.string.settings_su_restrict_title),
checked = item.isRestricted,
onCheckedChange = { viewModel.toggleRestrict(item) }
)
}
SwitchRow(
title = stringResource(CoreR.string.superuser_toggle_notification),
checked = item.notification,
onCheckedChange = { viewModel.updateNotify(item) }
)
SwitchRow(
title = stringResource(CoreR.string.logs),
checked = item.logging,
onCheckedChange = { viewModel.updateLogging(item) }
)
}
}
}
item {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (viewModel.requiresAuth) {
viewModel.authenticate { viewModel.performDelete(item, onBack) }
} else {
scope.launch {
val result = revokeDialog.awaitConfirm(
title = revokeTitle,
content = revokeMsg,
)
if (result == ConfirmResult.Confirmed) {
viewModel.performDelete(item, onBack)
}
}
}
}
) {
RevokeRow()
}
}
}
}
}
@Composable
private fun SwitchRow(
title: String,
checked: Boolean,
onCheckedChange: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MiuixTheme.textStyles.body1,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
Switch(
checked = checked,
onCheckedChange = { onCheckedChange() }
)
}
}
@Composable
private fun RevokeRow() {
Text(
text = stringResource(CoreR.string.superuser_toggle_revoke),
style = MiuixTheme.textStyles.body1,
color = MiuixTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
)
}

View File

@@ -1,36 +0,0 @@
package com.topjohnwu.magisk.ui.superuser
import android.os.Bundle
import android.view.View
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.databinding.FragmentSuperuserMd2Binding
import rikka.recyclerview.addEdgeSpacing
import rikka.recyclerview.addItemSpacing
import rikka.recyclerview.fixEdgeEffect
import com.topjohnwu.magisk.core.R as CoreR
class SuperuserFragment : BaseFragment<FragmentSuperuserMd2Binding>() {
override val layoutRes = R.layout.fragment_superuser_md2
override val viewModel by viewModel<SuperuserViewModel>()
override fun onStart() {
super.onStart()
activity?.title = resources.getString(CoreR.string.superuser)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.superuserList.apply {
addEdgeSpacing(top = R.dimen.l_50, bottom = R.dimen.l1)
addItemSpacing(R.dimen.l1, R.dimen.l_50, R.dimen.l1)
fixEdgeEffect()
}
}
override fun onPreBind(binding: FragmentSuperuserMd2Binding) {}
}

View File

@@ -0,0 +1,195 @@
package com.topjohnwu.magisk.ui.superuser
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.topjohnwu.magisk.ui.navigation.LocalNavigator
import com.topjohnwu.magisk.ui.navigation.Route
import com.topjohnwu.magisk.ui.util.rememberDrawablePainter
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Switch
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.theme.MiuixTheme
import com.topjohnwu.magisk.core.R as CoreR
@Composable
fun SuperuserScreen(viewModel: SuperuserViewModel) {
val uiState by viewModel.uiState.collectAsState()
val scrollBehavior = MiuixScrollBehavior()
val navigator = LocalNavigator.current
Scaffold(
topBar = {
TopAppBar(
title = stringResource(CoreR.string.superuser),
scrollBehavior = scrollBehavior
)
},
popupHost = { }
) { padding ->
if (uiState.loading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
return@Scaffold
}
if (uiState.policies.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(CoreR.string.superuser_policy_none),
style = MiuixTheme.textStyles.body1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
)
}
return@Scaffold
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(padding)
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { Spacer(Modifier.height(4.dp)) }
items(uiState.policies, key = { "${it.policy.uid}_${it.packageName}" }) { item ->
PolicyCard(
item = item,
onToggle = { viewModel.togglePolicy(item) },
onDetail = { navigator.push(Route.SuperuserDetail(item.policy.uid)) },
)
}
item { Spacer(Modifier.height(4.dp)) }
}
}
}
@Composable
private fun PolicyCard(
item: PolicyItem,
onToggle: () -> Unit,
onDetail: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.alpha(if (item.isEnabled) 1f else 0.5f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.weight(1f)
.clickable(onClick = onDetail)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = rememberDrawablePainter(item.icon),
contentDescription = item.appName,
modifier = Modifier.size(40.dp)
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.title,
style = MiuixTheme.textStyles.body1,
modifier = Modifier.weight(1f, fill = false),
)
if (item.isSharedUid) {
Spacer(Modifier.width(6.dp))
SharedUidBadge()
}
}
Text(
text = item.packageName,
style = MiuixTheme.textStyles.body2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
)
}
}
Box(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 12.dp)
.width(0.5.dp)
.background(MiuixTheme.colorScheme.dividerLine)
)
Box(
modifier = Modifier
.clickable(onClick = onToggle)
.padding(horizontal = 20.dp, vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Switch(
checked = item.isEnabled,
onCheckedChange = { onToggle() }
)
}
}
}
}
@Composable
internal fun SharedUidBadge(modifier: Modifier = Modifier) {
Text(
text = "SharedUID",
color = MiuixTheme.colorScheme.onPrimary,
fontSize = 10.sp,
maxLines = 1,
modifier = modifier
.background(MiuixTheme.colorScheme.primary, RoundedCornerShape(6.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}

View File

@@ -3,11 +3,13 @@ package com.topjohnwu.magisk.ui.superuser
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES
import android.graphics.drawable.Drawable
import android.os.Process
import androidx.databinding.Bindable
import androidx.databinding.ObservableArrayList
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewModelScope
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.arch.AsyncLoadViewModel
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.Config
@@ -16,52 +18,69 @@ import com.topjohnwu.magisk.core.R
import com.topjohnwu.magisk.core.data.magiskdb.PolicyDao
import com.topjohnwu.magisk.core.ktx.getLabel
import com.topjohnwu.magisk.core.model.su.SuPolicy
import com.topjohnwu.magisk.databinding.MergeObservableList
import com.topjohnwu.magisk.databinding.RvItem
import com.topjohnwu.magisk.databinding.bindExtra
import com.topjohnwu.magisk.databinding.diffList
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.dialog.SuperuserRevokeDialog
import com.topjohnwu.magisk.events.AuthEvent
import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.magisk.utils.asText
import com.topjohnwu.magisk.view.TextItem
import com.topjohnwu.magisk.core.su.SuEvents
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Locale
class PolicyItem(
val policy: SuPolicy,
val packageName: String,
val isSharedUid: Boolean,
val icon: Drawable,
val appName: String,
) {
val title get() = appName
val showSlider = Config.suRestrict || policy.policy == SuPolicy.RESTRICT
var isExpanded by mutableStateOf(false)
var policyValue by mutableIntStateOf(policy.policy)
var notification by mutableStateOf(policy.notification)
var logging by mutableStateOf(policy.logging)
val isEnabled get() = policyValue >= SuPolicy.ALLOW
val isRestricted get() = policyValue == SuPolicy.RESTRICT
}
class SuperuserViewModel(
private val db: PolicyDao
) : AsyncLoadViewModel() {
private val itemNoData = TextItem(R.string.superuser_policy_none)
var authenticate: (onSuccess: () -> Unit) -> Unit = { it() }
private val itemsHelpers = ObservableArrayList<TextItem>()
private val itemsPolicies = diffList<PolicyRvItem>()
val items = MergeObservableList<RvItem>()
.insertList(itemsHelpers)
.insertList(itemsPolicies)
val extraBindings = bindExtra {
it.put(BR.listener, this)
init {
@OptIn(kotlinx.coroutines.FlowPreview::class)
viewModelScope.launch {
SuEvents.policyChanged.debounce(500).collect { reload() }
}
}
@get:Bindable
var loading = true
private set(value) = set(value, field, { field = it }, BR.loading)
data class UiState(
val loading: Boolean = true,
val policies: List<PolicyItem> = emptyList(),
val suRestrict: Boolean = Config.suRestrict,
)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
@SuppressLint("InlinedApi")
override suspend fun doLoadWork() {
if (!Info.showSuperUser) {
loading = false
_uiState.update { it.copy(loading = false) }
return
}
loading = true
_uiState.update { it.copy(loading = true) }
withContext(Dispatchers.IO) {
db.deleteOutdated()
db.delete(AppContext.applicationInfo.uid)
val policies = ArrayList<PolicyRvItem>()
val policies = ArrayList<PolicyItem>()
val pm = AppContext.packageManager
for (policy in db.fetchAll()) {
val pkgs =
@@ -74,14 +93,14 @@ class SuperuserViewModel(
val map = pkgs.mapNotNull { pkg ->
try {
val info = pm.getPackageInfo(pkg, MATCH_UNINSTALLED_PACKAGES)
PolicyRvItem(
this@SuperuserViewModel, policy,
info.packageName,
info.sharedUserId != null,
info.applicationInfo?.loadIcon(pm) ?: pm.defaultActivityIcon,
info.applicationInfo?.getLabel(pm) ?: info.packageName
PolicyItem(
policy = policy,
packageName = info.packageName,
isSharedUid = info.sharedUserId != null,
icon = info.applicationInfo?.loadIcon(pm) ?: pm.defaultActivityIcon,
appName = info.applicationInfo?.getLabel(pm) ?: info.packageName
)
} catch (e: PackageManager.NameNotFoundException) {
} catch (_: PackageManager.NameNotFoundException) {
null
}
}
@@ -95,86 +114,80 @@ class SuperuserViewModel(
{ it.appName.lowercase(Locale.ROOT) },
{ it.packageName }
))
itemsPolicies.update(policies)
}
if (itemsPolicies.isNotEmpty())
itemsHelpers.clear()
else if (itemsHelpers.isEmpty())
itemsHelpers.add(itemNoData)
loading = false
}
// ---
fun deletePressed(item: PolicyRvItem) {
fun updateState() = viewModelScope.launch {
db.delete(item.item.uid)
val list = ArrayList(itemsPolicies)
list.removeAll { it.item.uid == item.item.uid }
itemsPolicies.update(list)
if (list.isEmpty() && itemsHelpers.isEmpty()) {
itemsHelpers.add(itemNoData)
}
}
if (Config.suAuth) {
AuthEvent { updateState() }.publish()
} else {
SuperuserRevokeDialog(item.title) { updateState() }.show()
_uiState.update { it.copy(loading = false, policies = policies, suRestrict = Config.suRestrict) }
}
}
fun updateNotify(item: PolicyRvItem) {
fun refreshSuRestrict() {
_uiState.update { it.copy(suRestrict = Config.suRestrict) }
}
val requiresAuth get() = Config.suAuth
fun performDelete(item: PolicyItem, onDeleted: () -> Unit = {}) {
viewModelScope.launch {
db.update(item.item)
val res = when {
item.item.notification -> R.string.su_snack_notif_on
else -> R.string.su_snack_notif_off
db.delete(item.policy.uid)
_uiState.update { state ->
state.copy(policies = state.policies.filter { it.policy.uid != item.policy.uid })
}
itemsPolicies.forEach {
if (it.item.uid == item.item.uid) {
it.notifyPropertyChanged(BR.shouldNotify)
}
}
SnackbarEvent(res.asText(item.appName)).publish()
onDeleted()
}
}
fun updateLogging(item: PolicyRvItem) {
fun updateNotify(item: PolicyItem) {
item.notification = !item.notification
item.policy.notification = item.notification
viewModelScope.launch {
db.update(item.item)
val res = when {
item.item.logging -> R.string.su_snack_log_on
else -> R.string.su_snack_log_off
}
itemsPolicies.forEach {
if (it.item.uid == item.item.uid) {
it.notifyPropertyChanged(BR.shouldLog)
}
}
SnackbarEvent(res.asText(item.appName)).publish()
db.update(item.policy)
_uiState.value.policies
.filter { it.policy.uid == item.policy.uid }
.forEach { it.notification = item.notification }
val res = if (item.notification) R.string.su_snack_notif_on else R.string.su_snack_notif_off
showSnackbar(AppContext.getString(res, item.appName))
}
}
fun updatePolicy(item: PolicyRvItem, policy: Int) {
val items = itemsPolicies.filter { it.item.uid == item.item.uid }
fun updateLogging(item: PolicyItem) {
item.logging = !item.logging
item.policy.logging = item.logging
viewModelScope.launch {
db.update(item.policy)
_uiState.value.policies
.filter { it.policy.uid == item.policy.uid }
.forEach { it.logging = item.logging }
val res = if (item.logging) R.string.su_snack_log_on else R.string.su_snack_log_off
showSnackbar(AppContext.getString(res, item.appName))
}
}
fun updatePolicy(item: PolicyItem, newPolicy: Int) {
fun updateState() {
viewModelScope.launch {
val res = if (policy >= SuPolicy.ALLOW) R.string.su_snack_grant else R.string.su_snack_deny
item.item.policy = policy
db.update(item.item)
items.forEach {
it.notifyPropertyChanged(BR.enabled)
it.notifyPropertyChanged(BR.sliderValue)
}
SnackbarEvent(res.asText(item.appName)).publish()
item.policy.policy = newPolicy
item.policyValue = newPolicy
db.update(item.policy)
_uiState.value.policies
.filter { it.policy.uid == item.policy.uid }
.forEach { it.policyValue = newPolicy }
val res = if (newPolicy >= SuPolicy.ALLOW) R.string.su_snack_grant else R.string.su_snack_deny
showSnackbar(AppContext.getString(res, item.appName))
}
}
if (Config.suAuth) {
AuthEvent { updateState() }.publish()
authenticate { updateState() }
} else {
updateState()
}
}
fun togglePolicy(item: PolicyItem) {
val newPolicy = if (item.isEnabled) SuPolicy.DENY else SuPolicy.ALLOW
updatePolicy(item, newPolicy)
}
fun toggleRestrict(item: PolicyItem) {
val newPolicy = if (item.isRestricted) SuPolicy.ALLOW else SuPolicy.RESTRICT
updatePolicy(item, newPolicy)
}
}

View File

@@ -1,31 +1,58 @@
package com.topjohnwu.magisk.ui.surequest
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.res.Resources
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.view.accessibility.AccessibilityNodeProvider
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.UIActivity
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.arch.VMFactory
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.base.ActivityExtension
import com.topjohnwu.magisk.core.base.UntrackedActivity
import com.topjohnwu.magisk.core.su.SuCallbackHandler
import com.topjohnwu.magisk.core.su.SuCallbackHandler.REQUEST
import com.topjohnwu.magisk.databinding.ActivityRequestBinding
import com.topjohnwu.magisk.core.wrap
import com.topjohnwu.magisk.ui.theme.MagiskTheme
import com.topjohnwu.magisk.ui.theme.Theme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.utils.MiuixPopupUtils.Companion.MiuixPopupHost
open class SuRequestActivity : UIActivity<ActivityRequestBinding>(), UntrackedActivity {
open class SuRequestActivity : AppCompatActivity(), UntrackedActivity {
override val layoutRes: Int = R.layout.activity_request
override val viewModel: SuRequestViewModel by viewModel()
private val extension = ActivityExtension(this)
private val viewModel: SuRequestViewModel by lazy {
ViewModelProvider(this, VMFactory)[SuRequestViewModel::class.java]
}
init {
AppCompatDelegate.setDefaultNightMode(Config.darkTheme)
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base.wrap())
}
override fun onCreate(savedInstanceState: Bundle?) {
extension.onCreate(savedInstanceState)
supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -36,6 +63,11 @@ open class SuRequestActivity : UIActivity<ActivityRequestBinding>(), UntrackedAc
setTheme(Theme.selected.themeRes)
super.onCreate(savedInstanceState)
viewModel.finishActivity = { finish() }
viewModel.authenticate = { onSuccess ->
extension.withAuthentication { if (it) onSuccess() }
}
if (intent.action == Intent.ACTION_VIEW) {
val action = intent.getStringExtra("action")
if (action == REQUEST) {
@@ -51,6 +83,24 @@ open class SuRequestActivity : UIActivity<ActivityRequestBinding>(), UntrackedAc
} else {
finish()
}
if (viewModel.useTapjackProtection) {
window.decorView.rootView.accessibilityDelegate = EmptyAccessibilityDelegate
}
setContent {
MagiskTheme {
Box(modifier = Modifier.fillMaxSize()) {
SuRequestScreen(viewModel = viewModel)
MiuixPopupHost()
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
extension.onSaveInstanceState(outState)
}
override fun getTheme(): Resources.Theme {
@@ -59,6 +109,7 @@ open class SuRequestActivity : UIActivity<ActivityRequestBinding>(), UntrackedAc
return theme
}
@Deprecated("Use OnBackPressedDispatcher")
override fun onBackPressed() {
viewModel.denyPressed()
}
@@ -66,4 +117,17 @@ open class SuRequestActivity : UIActivity<ActivityRequestBinding>(), UntrackedAc
override fun finish() {
super.finishAndRemoveTask()
}
private object EmptyAccessibilityDelegate : View.AccessibilityDelegate() {
override fun sendAccessibilityEvent(host: View, eventType: Int) {}
override fun performAccessibilityAction(host: View, action: Int, args: Bundle?) = true
override fun sendAccessibilityEventUnchecked(host: View, event: AccessibilityEvent) {}
override fun dispatchPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) = true
override fun onPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) {}
override fun onInitializeAccessibilityEvent(host: View, event: AccessibilityEvent) {}
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {}
override fun addExtraDataToAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo, extraDataKey: String, arguments: Bundle?) {}
override fun onRequestSendAccessibilityEvent(host: ViewGroup, child: View, event: AccessibilityEvent): Boolean = false
override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProvider? = null
}
}

View File

@@ -0,0 +1,208 @@
package com.topjohnwu.magisk.ui.surequest
import android.view.MotionEvent
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.topjohnwu.magisk.ui.superuser.SharedUidBadge
import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.core.R as CoreR
import com.topjohnwu.magisk.ui.util.rememberDrawablePainter
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.Slider
import top.yukonga.miuix.kmp.basic.SliderDefaults
import top.yukonga.miuix.kmp.theme.MiuixTheme
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SuRequestScreen(viewModel: SuRequestViewModel) {
if (!viewModel.showUi) return
val context = LocalContext.current
val icon = viewModel.icon
val title = viewModel.title
val packageName = viewModel.packageName
val grantEnabled = viewModel.grantEnabled
val denyCountdown = viewModel.denyCountdown
val selectedPosition = viewModel.selectedItemPosition
val timeoutEntries = stringArrayResource(CoreR.array.allow_timeout).toList()
// Slider order: Once(1), 10min(2), 20min(3), 30min(4), 60min(5), Forever(0)
val sliderToIndex = intArrayOf(1, 2, 3, 4, 5, 0)
val indexToSlider = remember {
IntArray(sliderToIndex.size).also { arr ->
sliderToIndex.forEachIndexed { slider, orig -> arr[orig] = slider }
}
}
val sliderValue = indexToSlider[selectedPosition].toFloat()
val sliderLabel by remember(sliderValue) {
derivedStateOf { timeoutEntries[sliderToIndex[sliderValue.toInt()]] }
}
val denyText = if (denyCountdown > 0) {
"${stringResource(CoreR.string.deny)} ($denyCountdown)"
} else {
stringResource(CoreR.string.deny)
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.widthIn(min = 320.dp, max = 420.dp)
.padding(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp)
) {
if (icon != null) {
Image(
painter = rememberDrawablePainter(icon),
contentDescription = null,
modifier = Modifier.size(40.dp)
)
Spacer(Modifier.width(12.dp))
}
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = title,
style = MiuixTheme.textStyles.body1,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (viewModel.isSharedUid) {
Spacer(Modifier.width(6.dp))
SharedUidBadge()
}
}
Text(
text = packageName,
style = MiuixTheme.textStyles.body2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(CoreR.string.su_request_title),
style = MiuixTheme.textStyles.body2,
color = MiuixTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
Text(
text = "Permission timeout: $sliderLabel",
style = MiuixTheme.textStyles.body2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.fillMaxWidth().padding(start = 8.dp),
)
Spacer(Modifier.height(8.dp))
Slider(
value = sliderValue,
onValueChange = { value ->
viewModel.spinnerTouched()
val pos = value.toInt().coerceIn(0, sliderToIndex.lastIndex)
viewModel.selectedItemPosition = sliderToIndex[pos]
},
valueRange = 0f..5f,
steps = 4,
showKeyPoints = true,
height = 20.dp,
hapticEffect = SliderDefaults.SliderHapticEffect.Step,
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(
text = denyText,
onClick = { viewModel.denyPressed() },
modifier = Modifier.weight(1f),
cornerRadius = 12.dp,
minHeight = 40.dp,
insideMargin = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
)
TextButton(
text = stringResource(CoreR.string.grant),
enabled = grantEnabled,
colors = ButtonDefaults.textButtonColorsPrimary(),
onClick = { viewModel.grantPressed() },
cornerRadius = 12.dp,
minHeight = 40.dp,
insideMargin = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
modifier = Modifier
.weight(1f)
.then(
if (viewModel.useTapjackProtection) {
Modifier.pointerInteropFilter { event ->
if (event.flags and MotionEvent.FLAG_WINDOW_IS_OBSCURED != 0 ||
event.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED != 0
) {
if (event.action == MotionEvent.ACTION_UP) {
context.toast(
CoreR.string.touch_filtered_warning,
Toast.LENGTH_SHORT
)
}
true
} else {
false
}
}
} else Modifier
)
)
}
}
}
}
}

View File

@@ -1,22 +1,17 @@
package com.topjohnwu.magisk.ui.surequest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.SharedPreferences
import android.content.res.Resources
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.os.CountDownTimer
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.view.accessibility.AccessibilityNodeProvider
import android.widget.Toast
import androidx.databinding.Bindable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.viewModelScope
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.core.AppContext
import com.topjohnwu.magisk.core.Config
@@ -27,11 +22,6 @@ import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.core.model.su.SuPolicy.Companion.ALLOW
import com.topjohnwu.magisk.core.model.su.SuPolicy.Companion.DENY
import com.topjohnwu.magisk.core.su.SuRequestHandler
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.events.AuthEvent
import com.topjohnwu.magisk.events.DieEvent
import com.topjohnwu.magisk.events.ShowUIEvent
import com.topjohnwu.magisk.utils.TextHolder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit.SECONDS
@@ -41,33 +31,20 @@ class SuRequestViewModel(
private val timeoutPrefs: SharedPreferences
) : BaseViewModel() {
lateinit var icon: Drawable
lateinit var title: String
lateinit var packageName: String
var authenticate: (onSuccess: () -> Unit) -> Unit = { it() }
var finishActivity: () -> Unit = {}
@get:Bindable
val denyText = DenyText()
var icon by mutableStateOf<Drawable?>(null)
var title by mutableStateOf("")
var packageName by mutableStateOf("")
var isSharedUid by mutableStateOf(false)
@get:Bindable
var selectedItemPosition = 0
set(value) = set(value, field, { field = it }, BR.selectedItemPosition)
var selectedItemPosition by mutableIntStateOf(0)
var grantEnabled by mutableStateOf(false)
var denyCountdown by mutableIntStateOf(0)
@get:Bindable
var grantEnabled = false
set(value) = set(value, field, { field = it }, BR.grantEnabled)
@SuppressLint("ClickableViewAccessibility")
val grantTouchListener = View.OnTouchListener { _: View, event: MotionEvent ->
// Filter obscured touches by consuming them.
if (event.flags and MotionEvent.FLAG_WINDOW_IS_OBSCURED != 0
|| event.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED != 0) {
if (event.action == MotionEvent.ACTION_UP) {
AppContext.toast(R.string.touch_filtered_warning, Toast.LENGTH_SHORT)
}
return@OnTouchListener Config.suTapjack
}
false
}
var showUi by mutableStateOf(false)
var useTapjackProtection by mutableStateOf(false)
private val handler = SuRequestHandler(AppContext.packageManager, policyDB)
private val millis = SECONDS.toMillis(Config.suDefaultTimeout.toLong())
@@ -77,7 +54,7 @@ class SuRequestViewModel(
fun grantPressed() {
cancelTimer()
if (Config.suAuth) {
AuthEvent { respond(ALLOW) }.publish()
authenticate { respond(ALLOW) }
} else {
respond(ALLOW)
}
@@ -87,9 +64,8 @@ class SuRequestViewModel(
respond(DENY)
}
fun spinnerTouched(): Boolean {
fun spinnerTouched() {
cancelTimer()
return false
}
fun handleRequest(intent: Intent) {
@@ -97,7 +73,7 @@ class SuRequestViewModel(
if (handler.start(intent))
showDialog()
else
DieEvent().publish()
finishActivity()
}
}
@@ -106,35 +82,26 @@ class SuRequestViewModel(
val info = handler.pkgInfo
val app = info.applicationInfo
isSharedUid = info.sharedUserId != null
if (app == null) {
// The request is not coming from an app process, and the UID is a
// shared UID. We have no way to know where this request comes from.
icon = pm.defaultActivityIcon
title = "[SharedUID] ${info.sharedUserId}"
title = info.sharedUserId.toString()
packageName = info.sharedUserId.toString()
} else {
val prefix = if (info.sharedUserId == null) "" else "[SharedUID] "
icon = app.loadIcon(pm)
title = "$prefix${app.getLabel(pm)}"
title = app.getLabel(pm)
packageName = info.packageName
}
selectedItemPosition = timeoutPrefs.getInt(packageName, 0)
// Set timer
timer.start()
// Actually show the UI
ShowUIEvent(if (Config.suTapjack) EmptyAccessibilityDelegate else null).publish()
useTapjackProtection = Config.suTapjack
showUi = true
initialized = true
}
private fun respond(action: Int) {
if (!initialized) {
// ignore the response until showDialog done
return
}
if (!initialized) return
timer.cancel()
val pos = selectedItemPosition
@@ -142,14 +109,13 @@ class SuRequestViewModel(
viewModelScope.launch {
handler.respond(action, Config.Value.TIMEOUT_LIST[pos])
// Kill activity after response
DieEvent().publish()
finishActivity()
}
}
private fun cancelTimer() {
timer.cancel()
denyText.seconds = 0
denyCountdown = 0
}
private inner class SuTimer(
@@ -161,39 +127,13 @@ class SuRequestViewModel(
if (!grantEnabled && remains <= millis - 1000) {
grantEnabled = true
}
denyText.seconds = (remains / 1000).toInt() + 1
denyCountdown = (remains / 1000).toInt() + 1
}
override fun onFinish() {
denyText.seconds = 0
denyCountdown = 0
respond(DENY)
}
}
inner class DenyText : TextHolder() {
var seconds = 0
set(value) = set(value, field, { field = it }, BR.denyText)
override fun getText(resources: Resources): CharSequence {
return if (seconds != 0)
"${resources.getString(R.string.deny)} ($seconds)"
else
resources.getString(R.string.deny)
}
}
// Invisible for accessibility services
object EmptyAccessibilityDelegate : View.AccessibilityDelegate() {
override fun sendAccessibilityEvent(host: View, eventType: Int) {}
override fun performAccessibilityAction(host: View, action: Int, args: Bundle?) = true
override fun sendAccessibilityEventUnchecked(host: View, event: AccessibilityEvent) {}
override fun dispatchPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) = true
override fun onPopulateAccessibilityEvent(host: View, event: AccessibilityEvent) {}
override fun onInitializeAccessibilityEvent(host: View, event: AccessibilityEvent) {}
override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {}
override fun addExtraDataToAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo, extraDataKey: String, arguments: Bundle?) {}
override fun onRequestSendAccessibilityEvent(host: ViewGroup, child: View, event: AccessibilityEvent): Boolean = false
override fun getAccessibilityNodeProvider(host: View): AccessibilityNodeProvider? = null
}
}

View File

@@ -0,0 +1,283 @@
package com.topjohnwu.magisk.ui.terminal
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.Typeface
import com.topjohnwu.magisk.terminal.TerminalBuffer
import com.topjohnwu.magisk.terminal.TerminalEmulator
import com.topjohnwu.magisk.terminal.TerminalRow
import com.topjohnwu.magisk.terminal.TextStyle
import com.topjohnwu.magisk.terminal.WcWidth
/**
* Renderer of a [TerminalEmulator] into a [Canvas].
*
* Saves font metrics, so needs to be recreated each time the typeface or font size changes.
*/
class TerminalRenderer(
textSize: Int,
typeface: Typeface,
) {
val textSize: Int = textSize
val typeface: Typeface = typeface
private val textPaint = Paint()
/** The width of a single mono spaced character obtained by [Paint.measureText] on a single 'X'. */
val fontWidth: Float
/** The [Paint.getFontSpacing]. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
val fontLineSpacing: Int
/** The [Paint.ascent]. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
private val fontAscent: Int
/** The [fontLineSpacing] + [fontAscent]. */
val fontLineSpacingAndAscent: Int
private val asciiMeasures = FloatArray(127)
init {
textPaint.typeface = typeface
textPaint.isAntiAlias = true
textPaint.textSize = textSize.toFloat()
fontLineSpacing = kotlin.math.ceil(textPaint.fontSpacing).toInt()
fontAscent = kotlin.math.ceil(textPaint.ascent()).toInt()
fontLineSpacingAndAscent = fontLineSpacing + fontAscent
fontWidth = textPaint.measureText("X")
val sb = StringBuilder(" ")
for (i in asciiMeasures.indices) {
sb[0] = i.toChar()
asciiMeasures[i] = textPaint.measureText(sb, 0, 1)
}
}
/**
* Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection.
*/
fun render(
mEmulator: TerminalEmulator,
canvas: Canvas,
topRow: Int,
selectionY1: Int,
selectionY2: Int,
selectionX1: Int,
selectionX2: Int,
) {
val reverseVideo = mEmulator.isReverseVideo
val endRow = topRow + mEmulator.mRows
val columns = mEmulator.mColumns
val cursorCol = mEmulator.cursorCol
val cursorRow = mEmulator.cursorRow
val cursorVisible = mEmulator.shouldCursorBeVisible()
val screen = mEmulator.screen
val palette = mEmulator.mColors.currentColors
val cursorShape = mEmulator.cursorStyle
if (reverseVideo) {
canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC)
}
var heightOffset = fontLineSpacingAndAscent.toFloat()
for (row in topRow until endRow) {
heightOffset += fontLineSpacing
val cursorX = if (row == cursorRow && cursorVisible) cursorCol else -1
var selx1 = -1
var selx2 = -1
if (row in selectionY1..selectionY2) {
if (row == selectionY1) selx1 = selectionX1
selx2 = if (row == selectionY2) selectionX2 else mEmulator.mColumns
}
val lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row))
val line = lineObject.text
val charsUsedInLine = lineObject.spaceUsed
var lastRunStyle = 0L
var lastRunInsideCursor = false
var lastRunInsideSelection = false
var lastRunStartColumn = -1
var lastRunStartIndex = 0
var lastRunFontWidthMismatch = false
var currentCharIndex = 0
var measuredWidthForRun = 0f
var column = 0
while (column < columns) {
val charAtIndex = line[currentCharIndex]
val charIsHighsurrogate = Character.isHighSurrogate(charAtIndex)
val charsForCodePoint = if (charIsHighsurrogate) 2 else 1
val codePoint = if (charIsHighsurrogate) {
Character.toCodePoint(charAtIndex, line[currentCharIndex + 1])
} else {
charAtIndex.code
}
val codePointWcWidth = WcWidth.width(codePoint)
val insideCursor = cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1)
val insideSelection = column >= selx1 && column <= selx2
val style = lineObject.getStyle(column)
// Check if the measured text width for this code point is not the same as that expected by wcwidth().
// This could happen for some fonts which are not truly monospace, or for more exotic characters such as
// smileys which android font renders as wide.
// If this is detected, we draw this code point scaled to match what wcwidth() expects.
val measuredCodePointWidth = if (codePoint < asciiMeasures.size) {
asciiMeasures[codePoint]
} else {
textPaint.measureText(line, currentCharIndex, charsForCodePoint)
}
val fontWidthMismatch = kotlin.math.abs(measuredCodePointWidth / fontWidth - codePointWcWidth.toFloat()) > 0.01f
if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection ||
fontWidthMismatch || lastRunFontWidthMismatch
) {
if (column != 0) {
val columnWidthSinceLastRun = column - lastRunStartColumn
val charsSinceLastRun = currentCharIndex - lastRunStartIndex
val cursorColor = if (lastRunInsideCursor) mEmulator.mColors.currentColors[TextStyle.COLOR_INDEX_CURSOR] else 0
var invertCursorTextColor = false
if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) {
invertCursorTextColor = true
}
drawTextRun(
canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun,
lastRunStartIndex, charsSinceLastRun, measuredWidthForRun,
cursorColor, cursorShape, lastRunStyle,
reverseVideo || invertCursorTextColor || lastRunInsideSelection,
)
}
measuredWidthForRun = 0f
lastRunStyle = style
lastRunInsideCursor = insideCursor
lastRunInsideSelection = insideSelection
lastRunStartColumn = column
lastRunStartIndex = currentCharIndex
lastRunFontWidthMismatch = fontWidthMismatch
}
measuredWidthForRun += measuredCodePointWidth
column += codePointWcWidth
currentCharIndex += charsForCodePoint
while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) {
// Eat combining chars so that they are treated as part of the last non-combining code point,
// instead of e.g. being considered inside the cursor in the next run.
currentCharIndex += if (Character.isHighSurrogate(line[currentCharIndex])) 2 else 1
}
}
val columnWidthSinceLastRun = columns - lastRunStartColumn
val charsSinceLastRun = currentCharIndex - lastRunStartIndex
val cursorColor = if (lastRunInsideCursor) mEmulator.mColors.currentColors[TextStyle.COLOR_INDEX_CURSOR] else 0
var invertCursorTextColor = false
if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) {
invertCursorTextColor = true
}
drawTextRun(
canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun,
lastRunStartIndex, charsSinceLastRun, measuredWidthForRun,
cursorColor, cursorShape, lastRunStyle,
reverseVideo || invertCursorTextColor || lastRunInsideSelection,
)
}
}
private fun drawTextRun(
canvas: Canvas,
text: CharArray,
palette: IntArray,
y: Float,
startColumn: Int,
runWidthColumns: Int,
startCharIndex: Int,
runWidthChars: Int,
mes: Float,
cursor: Int,
cursorStyle: Int,
textStyle: Long,
reverseVideo: Boolean,
) {
var foreColor = TextStyle.decodeForeColor(textStyle)
val effect = TextStyle.decodeEffect(textStyle)
var backColor = TextStyle.decodeBackColor(textStyle)
val bold = (effect and (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0
val underline = (effect and TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0
val italic = (effect and TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0
val strikeThrough = (effect and TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0
val dim = (effect and TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0
if ((foreColor and 0xff000000.toInt()) != 0xff000000.toInt()) {
// Let bold have bright colors if applicable (one of the first 8):
if (bold && foreColor in 0..7) foreColor += 8
foreColor = palette[foreColor]
}
if ((backColor and 0xff000000.toInt()) != 0xff000000.toInt()) {
backColor = palette[backColor]
}
// Reverse video here if _one and only one_ of the reverse flags are set:
val reverseVideoHere = reverseVideo xor ((effect and TextStyle.CHARACTER_ATTRIBUTE_INVERSE) != 0)
if (reverseVideoHere) {
val tmp = foreColor
foreColor = backColor
backColor = tmp
}
var left = startColumn * fontWidth
var right = left + runWidthColumns * fontWidth
var adjustedMes = mes / fontWidth
var savedMatrix = false
if (kotlin.math.abs(adjustedMes - runWidthColumns) > 0.01) {
canvas.save()
canvas.scale(runWidthColumns / adjustedMes, 1f)
left *= adjustedMes / runWidthColumns
right *= adjustedMes / runWidthColumns
savedMatrix = true
}
if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) {
// Only draw non-default background.
textPaint.color = backColor
canvas.drawRect(left, y - fontLineSpacingAndAscent + fontAscent, right, y, textPaint)
}
if (cursor != 0) {
textPaint.color = cursor
var cursorHeight = (fontLineSpacingAndAscent - fontAscent).toFloat()
if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4f
else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4f
canvas.drawRect(left, y - cursorHeight, right, y, textPaint)
}
if ((effect and TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) {
if (dim) {
var red = 0xFF and (foreColor shr 16)
var green = 0xFF and (foreColor shr 8)
var blue = 0xFF and foreColor
// Dim color handling used by libvte which in turn took it from xterm
// (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267):
red = red * 2 / 3
green = green * 2 / 3
blue = blue * 2 / 3
foreColor = -0x1000000 or (red shl 16) or (green shl 8) or blue
}
textPaint.isFakeBoldText = bold
textPaint.isUnderlineText = underline
textPaint.textSkewX = if (italic) -0.35f else 0f
textPaint.isStrikeThruText = strikeThrough
textPaint.color = foreColor
// The text alignment is the default Paint.Align.LEFT.
canvas.drawTextRun(
text, startCharIndex, runWidthChars, startCharIndex, runWidthChars,
left, y - fontLineSpacingAndAscent, false, textPaint,
)
}
if (savedMatrix) canvas.restore()
}
}

View File

@@ -0,0 +1,94 @@
package com.topjohnwu.magisk.ui.terminal
import android.graphics.Typeface
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.draw.drawBehind
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.sp
import com.topjohnwu.magisk.terminal.TerminalEmulator
import kotlin.math.max
@Composable
fun TerminalScreen(
modifier: Modifier = Modifier,
onEmulatorCreated: (TerminalEmulator) -> Unit = {},
) {
val density = LocalDensity.current
val renderer = remember {
val textSizePx = with(density) { 12.sp.toPx().toInt() }
TerminalRenderer(textSizePx, Typeface.MONOSPACE)
}
var emulator by remember { mutableStateOf<TerminalEmulator?>(null) }
var updateTick by remember { mutableIntStateOf(0) }
var topRow by remember { mutableIntStateOf(0) }
var scrolledToBottom by remember { mutableStateOf(true) }
BoxWithConstraints(modifier = modifier) {
val widthPx = constraints.maxWidth
val heightPx = constraints.maxHeight
val cols = max(4, (widthPx / renderer.fontWidth).toInt())
val rows = max(4, (heightPx - renderer.fontLineSpacingAndAscent) / renderer.fontLineSpacing)
val lineHeight = renderer.fontLineSpacing.toFloat()
LaunchedEffect(cols, rows) {
val emu = emulator
if (emu == null) {
val newEmu = TerminalEmulator(cols, rows, renderer.fontWidth.toInt(), renderer.fontLineSpacing, null)
newEmu.onScreenUpdate = {
if (scrolledToBottom) topRow = 0
updateTick++
}
emulator = newEmu
onEmulatorCreated(newEmu)
} else {
emu.resize(cols, rows, renderer.fontWidth.toInt(), renderer.fontLineSpacing)
}
}
Spacer(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.scrollable(
orientation = Orientation.Vertical,
state = rememberScrollableState { delta ->
val emu = emulator ?: return@rememberScrollableState 0f
val minTop = -emu.screen.activeTranscriptRows
val rowDelta = -(delta / lineHeight).toInt()
if (rowDelta != 0) {
val newTopRow = (topRow + rowDelta).coerceIn(minTop, 0)
topRow = newTopRow
scrolledToBottom = newTopRow >= 0
}
delta
}
)
.drawBehind {
@Suppress("UNUSED_EXPRESSION")
updateTick
val emu = emulator ?: return@drawBehind
drawIntoCanvas { canvas ->
renderer.render(emu, canvas.nativeCanvas, topRow, -1, -1, -1, -1)
}
}
)
}
}

View File

@@ -0,0 +1,39 @@
package com.topjohnwu.magisk.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import com.topjohnwu.magisk.core.Config
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import top.yukonga.miuix.kmp.theme.LocalContentColor
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
object ThemeState {
var colorMode by mutableIntStateOf(Config.colorMode)
}
@Composable
fun MagiskTheme(
content: @Composable () -> Unit
) {
val isDark = isSystemInDarkTheme()
val mode = ThemeState.colorMode
val controller = when (mode) {
1 -> ThemeController(ColorSchemeMode.Light)
2 -> ThemeController(ColorSchemeMode.Dark)
3 -> ThemeController(ColorSchemeMode.MonetSystem, isDark = isDark)
4 -> ThemeController(ColorSchemeMode.MonetLight)
5 -> ThemeController(ColorSchemeMode.MonetDark)
else -> ThemeController(ColorSchemeMode.System)
}
MiuixTheme(controller = controller) {
CompositionLocalProvider(
LocalContentColor provides MiuixTheme.colorScheme.onBackground,
content = content
)
}
}

View File

@@ -43,10 +43,6 @@ enum class Theme(
val isSelected get() = Config.themeOrdinal == ordinal
fun select() {
Config.themeOrdinal = ordinal
}
companion object {
val selected get() = values().getOrNull(Config.themeOrdinal) ?: Piplup
}

View File

@@ -1,68 +0,0 @@
package com.topjohnwu.magisk.ui.theme
import android.os.Bundle
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.databinding.FragmentThemeMd2Binding
import com.topjohnwu.magisk.databinding.ItemThemeBindingImpl
import com.topjohnwu.magisk.core.R as CoreR
class ThemeFragment : BaseFragment<FragmentThemeMd2Binding>() {
override val layoutRes = R.layout.fragment_theme_md2
override val viewModel by viewModel<ThemeViewModel>()
private fun <T> Array<T>.paired(): List<Pair<T, T?>> {
val iterator = iterator()
if (!iterator.hasNext()) return emptyList()
val result = mutableListOf<Pair<T, T?>>()
while (iterator.hasNext()) {
val a = iterator.next()
val b = if (iterator.hasNext()) iterator.next() else null
result.add(a to b)
}
return result
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
super.onCreateView(inflater, container, savedInstanceState)
for ((a, b) in Theme.values().paired()) {
val c = inflater.inflate(R.layout.item_theme_container, null, false)
val left = c.findViewById<FrameLayout>(R.id.left)
val right = c.findViewById<FrameLayout>(R.id.right)
for ((theme, view) in listOf(a to left, b to right)) {
theme ?: continue
val themed = ContextThemeWrapper(activity, theme.themeRes)
ItemThemeBindingImpl.inflate(LayoutInflater.from(themed), view, true).also {
it.setVariable(BR.viewModel, viewModel)
it.setVariable(BR.theme, theme)
it.lifecycleOwner = viewLifecycleOwner
}
}
binding.themeContainer.addView(c)
}
return binding.root
}
override fun onStart() {
super.onStart()
activity?.title = getString(CoreR.string.section_theme)
}
}

View File

@@ -1,23 +0,0 @@
package com.topjohnwu.magisk.ui.theme
import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.dialog.DarkThemeDialog
import com.topjohnwu.magisk.events.RecreateEvent
import com.topjohnwu.magisk.view.TappableHeadlineItem
class ThemeViewModel : BaseViewModel(), TappableHeadlineItem.Listener {
val themeHeadline = TappableHeadlineItem.ThemeMode
override fun onItemPressed(item: TappableHeadlineItem) = when (item) {
is TappableHeadlineItem.ThemeMode -> DarkThemeDialog().show()
}
fun saveTheme(theme: Theme) {
if (!theme.isSelected) {
Config.themeOrdinal = theme.ordinal
RecreateEvent().publish()
}
}
}

View File

@@ -0,0 +1,22 @@
package com.topjohnwu.magisk.ui.util
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
@Composable
fun rememberDrawablePainter(drawable: Drawable): Painter {
return remember(drawable) {
val w = drawable.intrinsicWidth.coerceAtLeast(1)
val h = drawable.intrinsicHeight.coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bitmap)
drawable.setBounds(0, 0, w, h)
drawable.draw(canvas)
BitmapPainter(bitmap.asImageBitmap())
}
}

View File

@@ -1,86 +0,0 @@
package com.topjohnwu.magisk.utils
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.view.View
import androidx.core.animation.addListener
import androidx.core.text.layoutDirection
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.marginBottom
import androidx.core.view.marginEnd
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.circularreveal.CircularRevealCompat
import com.google.android.material.circularreveal.CircularRevealWidget
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.topjohnwu.magisk.core.utils.LocaleSetting
import kotlin.math.hypot
object MotionRevealHelper {
fun <CV> withViews(
revealable: CV,
fab: FloatingActionButton,
expanded: Boolean
) where CV : CircularRevealWidget, CV : View {
revealable.revealInfo = revealable.createRevealInfo(!expanded)
val revealInfo = revealable.createRevealInfo(expanded)
val revealAnim = revealable.createRevealAnim(revealInfo)
val moveAnim = fab.createMoveAnim(revealInfo)
AnimatorSet().also {
if (expanded) {
it.play(revealAnim).after(moveAnim)
} else {
it.play(moveAnim).after(revealAnim)
}
}.start()
}
private fun <CV> CV.createRevealAnim(
revealInfo: CircularRevealWidget.RevealInfo
): Animator where CV : CircularRevealWidget, CV : View =
CircularRevealCompat.createCircularReveal(
this,
revealInfo.centerX,
revealInfo.centerY,
revealInfo.radius
).apply {
addListener(onStart = {
isVisible = true
}, onEnd = {
if (revealInfo.radius == 0f) {
isInvisible = true
}
})
}
private fun FloatingActionButton.createMoveAnim(
revealInfo: CircularRevealWidget.RevealInfo
): Animator = AnimatorSet().also {
it.interpolator = FastOutSlowInInterpolator()
it.addListener(onStart = { show() }, onEnd = { if (revealInfo.radius != 0f) hide() })
val rtlMod =
if (LocaleSetting.instance.currentLocale.layoutDirection == View.LAYOUT_DIRECTION_RTL)
1f else -1f
val maxX = revealInfo.centerX - marginEnd - measuredWidth / 2f
val targetX = if (revealInfo.radius == 0f) 0f else maxX * rtlMod
val moveX = ObjectAnimator.ofFloat(this, View.TRANSLATION_X, targetX)
val maxY = revealInfo.centerY - marginBottom - measuredHeight / 2f
val targetY = if (revealInfo.radius == 0f) 0f else -maxY
val moveY = ObjectAnimator.ofFloat(this, View.TRANSLATION_Y, targetY)
it.playTogether(moveX, moveY)
}
private fun View.createRevealInfo(expanded: Boolean): CircularRevealWidget.RevealInfo {
val cX = measuredWidth / 2f
val cY = measuredHeight / 2f - paddingBottom
return CircularRevealWidget.RevealInfo(cX, cY, if (expanded) hypot(cX, cY) else 0f)
}
}

View File

@@ -1,46 +0,0 @@
package com.topjohnwu.magisk.utils
import android.content.res.Resources
abstract class TextHolder {
open val isEmpty: Boolean get() = false
abstract fun getText(resources: Resources): CharSequence
// ---
class String(
private val value: CharSequence
) : TextHolder() {
override val isEmpty get() = value.isEmpty()
override fun getText(resources: Resources) = value
}
open class Resource(
protected val value: Int
) : TextHolder() {
override val isEmpty get() = value == 0
override fun getText(resources: Resources) = resources.getString(value)
}
class ResourceArgs(
value: Int,
private vararg val params: Any
) : Resource(value) {
override fun getText(resources: Resources): kotlin.String {
// Replace TextHolder with strings
val args = params.map { if (it is TextHolder) it.getText(resources) else it }
return resources.getString(value, *args.toTypedArray())
}
}
// ---
companion object {
val EMPTY = String("")
}
}
fun Int.asText(): TextHolder = TextHolder.Resource(this)
fun Int.asText(vararg params: Any): TextHolder = TextHolder.ResourceArgs(this, *params)
fun CharSequence.asText(): TextHolder = TextHolder.String(this)

View File

@@ -1,232 +0,0 @@
package com.topjohnwu.magisk.view
import android.app.Activity
import android.content.DialogInterface
import android.content.res.ColorStateList
import android.graphics.drawable.Drawable
import android.graphics.drawable.InsetDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.databinding.Bindable
import androidx.databinding.PropertyChangeRegistry
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.shape.MaterialShapeDrawable
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.UIActivity
import com.topjohnwu.magisk.databinding.DialogMagiskBaseBinding
import com.topjohnwu.magisk.databinding.DiffItem
import com.topjohnwu.magisk.databinding.ItemWrapper
import com.topjohnwu.magisk.databinding.ObservableHost
import com.topjohnwu.magisk.databinding.RvItem
import com.topjohnwu.magisk.databinding.bindExtra
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.databinding.setAdapter
import com.topjohnwu.magisk.view.MagiskDialog.DialogClickListener
typealias DialogButtonClickListener = (DialogInterface) -> Unit
class MagiskDialog(
context: Activity, theme: Int = 0
) : AppCompatDialog(context, theme) {
private val binding: DialogMagiskBaseBinding =
DialogMagiskBaseBinding.inflate(LayoutInflater.from(context))
private val data = Data()
val activity: UIActivity<*> get() = ownerActivity as UIActivity<*>
init {
binding.setVariable(BR.data, data)
setCancelable(true)
setOwnerActivity(context)
}
inner class Data : ObservableHost {
override var callbacks: PropertyChangeRegistry? = null
@get:Bindable
var icon: Drawable? = null
set(value) = set(value, field, { field = it }, BR.icon)
@get:Bindable
var title: CharSequence = ""
set(value) = set(value, field, { field = it }, BR.title)
@get:Bindable
var message: CharSequence = ""
set(value) = set(value, field, { field = it }, BR.message)
val buttonPositive = ButtonViewModel()
val buttonNeutral = ButtonViewModel()
val buttonNegative = ButtonViewModel()
}
enum class ButtonType {
POSITIVE, NEUTRAL, NEGATIVE
}
interface Button {
var icon: Int
var text: Any
var isEnabled: Boolean
var doNotDismiss: Boolean
fun onClick(listener: DialogButtonClickListener)
}
inner class ButtonViewModel : Button, ObservableHost {
override var callbacks: PropertyChangeRegistry? = null
@get:Bindable
override var icon = 0
set(value) = set(value, field, { field = it }, BR.icon, BR.gone)
@get:Bindable
var message: String = ""
set(value) = set(value, field, { field = it }, BR.message, BR.gone)
override var text: Any
get() = message
set(value) {
message = when (value) {
is Int -> context.getText(value)
else -> value
}.toString()
}
@get:Bindable
val gone get() = icon == 0 && message.isEmpty()
@get:Bindable
override var isEnabled = true
set(value) = set(value, field, { field = it }, BR.enabled)
override var doNotDismiss = false
private var onClickAction: DialogButtonClickListener = {}
override fun onClick(listener: DialogButtonClickListener) {
onClickAction = listener
}
fun clicked() {
onClickAction(this@MagiskDialog)
if (!doNotDismiss) {
dismiss()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
super.setContentView(binding.root)
val default = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, javaClass.canonicalName)
val surfaceColor = MaterialColors.getColor(context, R.attr.colorSurfaceSurfaceVariant, default)
val materialShapeDrawable = MaterialShapeDrawable(context, null, androidx.appcompat.R.attr.alertDialogStyle, com.google.android.material.R.style.MaterialAlertDialog_MaterialComponents)
materialShapeDrawable.initializeElevationOverlay(context)
materialShapeDrawable.fillColor = ColorStateList.valueOf(surfaceColor)
materialShapeDrawable.elevation = context.resources.getDimension(R.dimen.margin_generic)
materialShapeDrawable.setCornerSize(context.resources.getDimension(R.dimen.l_50))
val inset = context.resources.getDimensionPixelSize(com.google.android.material.R.dimen.appcompat_dialog_background_inset)
window?.apply {
setBackgroundDrawable(InsetDrawable(materialShapeDrawable, inset, inset, inset, inset))
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
}
override fun setTitle(@StringRes titleId: Int) { data.title = context.getString(titleId) }
override fun setTitle(title: CharSequence?) { data.title = title ?: "" }
fun setMessage(@StringRes msgId: Int, vararg args: Any) {
data.message = context.getString(msgId, *args)
}
fun setMessage(message: CharSequence) { data.message = message }
fun setIcon(@DrawableRes drawableRes: Int) {
data.icon = AppCompatResources.getDrawable(context, drawableRes)
}
fun setIcon(drawable: Drawable) { data.icon = drawable }
fun setButton(buttonType: ButtonType, builder: Button.() -> Unit) {
val button = when (buttonType) {
ButtonType.POSITIVE -> data.buttonPositive
ButtonType.NEUTRAL -> data.buttonNeutral
ButtonType.NEGATIVE -> data.buttonNegative
}
button.apply(builder)
}
class DialogItem(
override val item: CharSequence,
val position: Int
) : RvItem(), DiffItem<DialogItem>, ItemWrapper<CharSequence> {
override val layoutRes = R.layout.item_list_single_line
}
fun interface DialogClickListener {
fun onClick(position: Int)
}
fun setListItems(
list: Array<out CharSequence>,
listener: DialogClickListener
) = setView(
RecyclerView(context).also {
it.isNestedScrollingEnabled = false
it.layoutManager = LinearLayoutManager(context)
val items = list.mapIndexed { i, cs -> DialogItem(cs, i) }
val extraBindings = bindExtra { sa ->
sa.put(BR.listener, DialogClickListener { pos ->
listener.onClick(pos)
dismiss()
})
}
it.setAdapter(items, extraBindings)
}
)
fun setView(view: View) {
binding.dialogBaseContainer.removeAllViews()
binding.dialogBaseContainer.addView(
view,
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
fun resetButtons() {
ButtonType.values().forEach {
setButton(it) {
text = ""
icon = 0
isEnabled = true
doNotDismiss = false
onClick {}
}
}
}
// Prevent calling setContentView
@Deprecated("Please use setView(view)", level = DeprecationLevel.ERROR)
override fun setContentView(layoutResID: Int) {}
@Deprecated("Please use setView(view)", level = DeprecationLevel.ERROR)
override fun setContentView(view: View) {}
@Deprecated("Please use setView(view)", level = DeprecationLevel.ERROR)
override fun setContentView(view: View, params: ViewGroup.LayoutParams?) {}
}

View File

@@ -1,30 +0,0 @@
package com.topjohnwu.magisk.view
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.databinding.DiffItem
import com.topjohnwu.magisk.databinding.RvItem
import com.topjohnwu.magisk.core.R as CoreR
sealed class TappableHeadlineItem : RvItem(), DiffItem<TappableHeadlineItem> {
abstract val title: Int
abstract val icon: Int
override val layoutRes = R.layout.item_tappable_headline
// --- listener
interface Listener {
fun onItemPressed(item: TappableHeadlineItem)
}
// --- objects
object ThemeMode : TappableHeadlineItem() {
override val title = CoreR.string.settings_dark_mode_title
override val icon = R.drawable.ic_day_night
}
}

View File

@@ -1,10 +0,0 @@
package com.topjohnwu.magisk.view
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.databinding.DiffItem
import com.topjohnwu.magisk.databinding.ItemWrapper
import com.topjohnwu.magisk.databinding.RvItem
class TextItem(override val item: Int) : RvItem(), DiffItem<TextItem>, ItemWrapper<Int> {
override val layoutRes = R.layout.item_text
}

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