Compare commits

...

372 Commits

Author SHA1 Message Date
Charles Lombardo
917f948026 android: Enable overlay scale/opacity dialog 2023-06-01 17:30:27 -04:00
bunnei
af3fd58542 input_common: Fix virtual amiibos 2023-05-31 22:51:27 -07:00
bunnei
b671f7f00c android: audio_core: Avoid shutdown hang. 2023-05-31 18:21:10 -07:00
bunnei
04d1a0e193 android: ForegroundService: Handle null intent. 2023-05-31 18:17:21 -07:00
bunnei
68ddc23be8 android: ImportExportSavesFragment: Cleanup strings. 2023-05-31 18:16:54 -07:00
bunnei
c7dccf3f07 Merge pull request #10534 from PabloG02/lime-zip-saves
Import/Export Game Saves in Android
2023-05-31 17:52:30 -07:00
bunnei
ce4dfb49cf Update src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt 2023-05-31 15:59:49 -07:00
bunnei
ab16bb31bb android: Use ext-android-bin for external binaries. 2023-05-31 15:06:21 -07:00
PabloG02
a760819cbb Remove ?. 2023-05-31 23:58:01 +02:00
PabloG02
8470bd397e Check if folder exists before letting the user import/export saves 2023-05-31 23:37:18 +02:00
PabloG02
4a7ddccd55 Add save import/export in UI 2023-05-31 23:24:33 +02:00
Charles Lombardo
8e44cf2eba android: Fix FPS text getting cut off by rounded display corners 2023-05-31 02:59:11 -04:00
Charles Lombardo
b8a2713b8a android: Prevent deleting the settings file while a game is running 2023-05-31 02:23:23 -04:00
Charles Lombardo
ab45de13af android: Fix link text color for base theme dialog 2023-05-31 02:15:31 -04:00
bunnei
f5ed9547d1 android: Various fixes for CI. 2023-05-30 20:50:09 -07:00
bunnei
67eac44f61 android: externals: Update libadrenotools, use useLegacyPackaging. 2023-05-30 14:43:29 -07:00
Charles Lombardo
a47eb7e444 android: Re-enable service notification 2023-05-30 14:43:28 -07:00
Charles Lombardo
e92cb3cfe9 android: Ensure keys are loaded before populating games list 2023-05-30 14:43:28 -07:00
Charles Lombardo
0214cd6f10 android: Use dialog fragment for the reset settings dialog 2023-05-30 14:43:28 -07:00
Charles Lombardo
ca3348276c android: Upgrade AGP to 8.0.2 2023-05-30 14:43:28 -07:00
Charles Lombardo
6646a1c9f7 android: Show notification permission page during setup 2023-05-30 14:43:28 -07:00
Charles Lombardo
f01136b60b android: DIsable FPS counter by default 2023-05-30 14:43:28 -07:00
Charles Lombardo
712f087045 android: Improve searches with one character
The Jaccard algorithm is great for searches with 2 or more characters but nothing is returned for searches with one character. To get around this, just search with JaroWinkler for single character searches.
2023-05-30 14:43:28 -07:00
Charles Lombardo
fc13c5c58e android: Stop building x86 packages in APKs
This was really only meant for building the app to run in an emulator. If this is necessary, just add manually.
2023-05-30 14:43:28 -07:00
Charles Lombardo
4c282944b7 android: Add FPS toggle 2023-05-30 14:43:28 -07:00
liushuyu
e1c6b32b17 CI: use the verify pipeline to do releases 2023-05-30 14:43:28 -07:00
Charles Lombardo
c961dbdf4e android: Clean up app build.gradle
Removes the conflicting declaration of "version" and changes to versionCode that did nothing.
2023-05-30 14:43:27 -07:00
bunnei
4e6a9c8801 video_core: vk_rasterizer: Decrease draw dispatch count for Android. 2023-05-30 14:43:27 -07:00
bunnei
cc080fb8c1 android: config: Expose VSync as a configurable setting. 2023-05-30 14:43:27 -07:00
bunnei
2af4003a01 android: GPU: Enable async presentation, increase frames in flight. 2023-05-30 14:43:27 -07:00
Charles Lombardo
0c3f20feb3 android: Enable onBackInvokedCallback
For now this enables the ability to see the new Android 13 back gesture animations but later we can create custom animations that follow the back gesture.
2023-05-30 14:43:27 -07:00
Charles Lombardo
a22c9f6e72 android: Remove deprecated use of onBackPressed() 2023-05-30 14:43:27 -07:00
Charles Lombardo
ca8055dcbe android: Add option for touch overlay haptics
Disabled by default
2023-05-30 14:43:27 -07:00
Charles Lombardo
5ce0225c0a android: Improve missing game handling
Previously the app would crash if you selected a game that no longer existed. Now we show an error message and reload the games list to remove any invalid games from the list.
2023-05-30 14:43:27 -07:00
Charles Lombardo
88afeaefe3 android: Clean up dependencies
Additionally updates material and androidx core libraries
2023-05-30 14:43:26 -07:00
Charles Lombardo
269d797ab8 android: Delete java code style file 2023-05-30 14:43:26 -07:00
Charles Lombardo
95e46d0e27 android: Settings UI tweaks
New spacing and fonts for list items
2023-05-30 14:43:26 -07:00
Charles Lombardo
0c7ed5d8b5 android: Simplify setup in search and games fragments 2023-05-30 14:43:26 -07:00
Charles Lombardo
391605b007 android: Use collapsing toolbar layout in settings 2023-05-30 14:43:26 -07:00
Charles Lombardo
9cf8d25e25 android: Remove unnecessary JvmStatic/JvmField annotations 2023-05-30 14:43:26 -07:00
Charles Lombardo
4c9115af1a android: Fix navigation rail animation in rtl layout 2023-05-30 14:43:26 -07:00
Charles Lombardo
330ccc1c62 android: Use cutout insets on setup fragment 2023-05-30 14:43:25 -07:00
Charles Lombardo
8eb1ae4662 android: Button to reset all settings 2023-05-30 14:43:25 -07:00
Charles Lombardo
f126661ff6 android: Use proguard file in relWithDebInfo 2023-05-30 14:43:25 -07:00
Charles Lombardo
7f6f98b23a android: Fix background color within inset areas 2023-05-30 14:43:25 -07:00
Charles Lombardo
4c11a6f800 android: Shortcut to settings activity on reselection 2023-05-30 14:43:25 -07:00
Charles Lombardo
ae963e415b android: Expose custom RTC setting 2023-05-30 14:43:25 -07:00
Charles Lombardo
0170fb57af android: Reset setting on long press 2023-05-30 14:43:25 -07:00
Charles Lombardo
b8f86f4727 android: Fix issues with ea/main icons and version codes
Now all yuzu icon variants are taken care of and now we have a build variant that uses the versioning we need for the play store.
2023-05-30 14:43:24 -07:00
Charles Lombardo
952ff8b3c6 android: Move theme options out of advanced settings 2023-05-30 14:43:24 -07:00
Charles Lombardo
781e57fe03 android: Check if cached games are valid
Fixes bug when you close yuzu, delete games, and reopen to an instant crash.
2023-05-30 14:43:24 -07:00
german77
c8396d457e android: Invert rotation to match phone orientation 2023-05-30 14:43:24 -07:00
bunnei
fc755a885d android: vulkan_device: Skip BGR565 emulation on S8gen2. 2023-05-30 14:43:24 -07:00
bunnei
7fb35e615b android: config: Use default anisotropic filtering. 2023-05-30 14:43:24 -07:00
Charles Lombardo
af2808369b android: Remove top padding from in game menu items 2023-05-30 14:43:24 -07:00
Charles Lombardo
76513918e9 android: Use different icons for mainline/ea 2023-05-30 14:43:23 -07:00
Charles Lombardo
4a6790e0bc android: Add early access upgrade fragment
We now have a second build flavor that will determine whether the "Get Early Access" button appears.
2023-05-30 14:43:23 -07:00
bunnei
08c60d6621 android: vulkan_device: Only compile OverrideBcnFormats when used. 2023-05-30 14:43:23 -07:00
Liam
4b3075702a android: remove spurious warnings about BCn formats when patched with adrenotools 2023-05-30 14:43:23 -07:00
bunnei
31203e0927 android: video_core: Disable some problematic things on GPU Normal. 2023-05-30 14:43:23 -07:00
bunnei
7ee8e8c4d5 android: settings: Use mailbox vsync by default. 2023-05-30 14:43:23 -07:00
bunnei
e747f173de android: video_core: Disable problematic compute shaders.
- Fixes #104.
2023-05-30 14:43:23 -07:00
Charles Lombardo
dd63636ba8 android: Update progard to fix settings crash
R8 full mode was removing important classes from Wini that would cause a crash on saving settings. This keeps the relevant classes and suppresses warnings about irrelevant ones.
2023-05-30 14:43:22 -07:00
bunnei
adcfcf2cf1 android: vulkan: Recreate surface after suspension & adapt to async. presentation. 2023-05-30 14:43:22 -07:00
Charles Lombardo
11294796e9 android: Game data cache 2023-05-30 14:43:22 -07:00
Charles Lombardo
7481253fa4 android: Update to Kotlin 1.8.21 2023-05-30 14:43:22 -07:00
Charles Lombardo
4bdcb637bf android: Disable jetifier
We no longer depend on any legacy libraries that required this flag
2023-05-30 14:43:22 -07:00
Charles Lombardo
1da867b021 android: Update dependencies 2023-05-30 14:43:22 -07:00
Charles Lombardo
6ea2f5a6d0 android: Migrate to AGP 8.0.1 2023-05-30 14:43:22 -07:00
Charles Lombardo
40da8ad7c5 android: Enable non-transitive R classes
New default going forward for new android projects. Best to follow the new standard.
2023-05-30 14:43:21 -07:00
bunnei
eaca40477b android: config: Enable asynchronous presentation by default on Android. 2023-05-30 14:43:21 -07:00
bunnei
3927e0f073 video_core: Enable support_descriptor_aliasing on Turnip, disable storage atomic otherwise. 2023-05-30 14:43:21 -07:00
german77
c718535aa8 android: fix deadzone calculation 2023-05-30 14:43:21 -07:00
Charles Lombardo
cd800bb8ca android: Fix background color when starting emulation 2023-05-30 14:43:21 -07:00
Charles Lombardo
cae913557d android: Persistent scrollbars on home settings fragment 2023-05-30 14:43:21 -07:00
Charles Lombardo
6904c4f8ac android: Use short build hash 2023-05-30 14:43:21 -07:00
Charles Lombardo
abdbd41915 android: Use navigation bar shade view 2023-05-30 14:43:21 -07:00
Charles Lombardo
2abff72e5e android: About fragment 2023-05-30 14:43:20 -07:00
Charles Lombardo
58a5a3683a android: Use x-axis animation for navigation rail 2023-05-30 14:43:20 -07:00
Charles Lombardo
153df9a593 android: Sort games alphabetically by default 2023-05-30 14:43:20 -07:00
Charles Lombardo
e673248bda android: New icons for navigation bar 2023-05-30 14:43:20 -07:00
Charles Lombardo
30333328c4 android: New icons for home settings fragment 2023-05-30 14:43:20 -07:00
Charles Lombardo
f109d0dacb android: Add navigation rail 2023-05-30 14:43:20 -07:00
Charles Lombardo
076406578a android: Search Fragment 2023-05-30 14:43:20 -07:00
Charles Lombardo
6b89045329 android: Fix potential zip traversal exploit 2023-05-30 14:43:19 -07:00
german77
78ea363814 android: Add dedicated show overlay checkbox 2023-05-30 14:43:19 -07:00
Charles Lombardo
1f6283585c android: Add user directory shortcut 2023-05-30 14:43:19 -07:00
german77
d51f0e8c05 android: Fix inline keyboard input 2023-05-30 14:43:19 -07:00
Charles Lombardo
2805616afe android: Fix grammatical mistake in video core error message 2023-05-30 14:43:19 -07:00
Charles Lombardo
cb8852249f android: Adjust wording on GPU driver install button 2023-05-30 14:43:19 -07:00
Narr the Reg
d4590bf017 android: Add deadzone to stick input 2023-05-30 14:43:19 -07:00
german77
a120e90849 android: Move motion listener to emulation activity 2023-05-30 14:43:19 -07:00
Narr the Reg
113ff22884 core: hid: Finish linking motion from virtual controllers 2023-05-30 14:43:18 -07:00
Charles Lombardo
6ebe58b389 android: Change wording for "Add Games" button (#100)
Co-authored-by: bunnei <bunneidev@gmail.com>
2023-05-30 14:43:18 -07:00
Charles Lombardo
307bf959d7 android: Scroll shortcut for games list
If you reselect the "Games" menu item in the bottom navigation menu, the list smoothly scrolls to the top.
2023-05-30 14:43:18 -07:00
Charles Lombardo
18d17a5c77 android: Setup screen hotfix
Added help button link for add games warning and a check for whether a task was completed on a given screen.
2023-05-30 14:43:18 -07:00
Charles Lombardo
b2b8880241 android: Swap Default and Install buttons for GPU driver installation dialog 2023-05-30 14:43:18 -07:00
Charles Lombardo
f1ede51d2e android: Add warnings to setup screens 2023-05-30 14:43:18 -07:00
Charles Lombardo
571111dc90 android: Allow search bar to scroll offscreen 2023-05-30 14:43:18 -07:00
Charles Lombardo
4b0b74c4df android: Update app icon
Small icon updates from Flam
2023-05-30 14:43:18 -07:00
Charles Lombardo
e0ae0e23e6 android: Change organization of the settings tab in the home screen 2023-05-30 14:43:18 -07:00
Charles Lombardo
167f117d91 android: Properly pop setup fragment from the back stack 2023-05-30 14:43:17 -07:00
Charles Lombardo
77c81269a2 android: Vertically scalable setup pages
Previously the setup pages would remain at a fixed height but now the icon and two text boxes will give up space as a device gets shorter. This eliminates the need for a scrolling view further problems with padding.
2023-05-30 14:43:17 -07:00
Charles Lombardo
86814211a4 android: Fix setup rotation bug
If you rotated the device at the "Add Games" screen the buttons would disappear until you trigged them from the beginning page swap. Now button state is saved across recreation.
2023-05-30 14:43:17 -07:00
Charles Lombardo
7977e88dd4 android: Temporarily switch for a fixed version code for testing 2023-05-30 14:43:17 -07:00
Charles Lombardo
1de60e9e4d android: Fix alignment of SwipeRefreshLayout 2023-05-30 14:43:17 -07:00
Charles Lombardo
ad5c481d5f android: Shape/spacing adjustments to game card
Ripple effect now reaches into rounded corners, icon size changed, company text removed, title font adjusted, and spacing around the card was adjusted as well. Text also doesn't get cut off anymore and instead scrolls indefinitely on one line.
2023-05-30 14:43:17 -07:00
Charles Lombardo
2e3c3f5193 android: Manual tweaks for dialog colors
Small fix for Flam
2023-05-30 14:43:16 -07:00
Charles Lombardo
b732a0bb2a android: Fix black backgrounds bug
Start using a specific night mode check because black backgrounds could apply incorrectly when using the light app mode, dark system mode, and black backgrounds. Launching the settings activity will show light mode colors/navigation bars but with black backgrounds.
2023-05-30 14:43:16 -07:00
Charles Lombardo
8450c406d6 android: Use navigation bar shade view for settings activity 2023-05-30 14:43:16 -07:00
Charles Lombardo
5e8403bb6c android: Disable editing themes during emulation 2023-05-30 14:43:16 -07:00
Charles Lombardo
146dd02f5e android: Prevent situation where binding is called on a null view 2023-05-30 14:43:16 -07:00
Charles Lombardo
c3c9936d5d android: Add black backgrounds toggle 2023-05-30 14:43:16 -07:00
Charles Lombardo
5e9b1925fe android: Add theme mode picker 2023-05-30 14:43:16 -07:00
Charles Lombardo
3a2667a964 android: Add theme picker 2023-05-30 14:43:16 -07:00
Charles Lombardo
6e260cb4ea android: Prevent potential abstract settings crash 2023-05-30 14:43:15 -07:00
Charles Lombardo
9dd8edd3c3 android: Fix cast for abstract settings 2023-05-30 14:43:15 -07:00
Charles Lombardo
d96083847e android: Create xml for Material You theme 2023-05-30 14:43:15 -07:00
Charles Lombardo
a8befc7504 android: Remove check for API 29 in themes 2023-05-30 14:43:15 -07:00
Charles Lombardo
99efa8f7bb android: Adjustments to home option card
Several spacing/color adjustments provided by Flam
2023-05-30 14:43:15 -07:00
Charles Lombardo
e2c88bd48d android: Use different colors for logo in options menu
Reverting to the official logo colors
2023-05-30 14:43:15 -07:00
Charles Lombardo
e1bb12fcdb android: New default theme colors 2023-05-30 14:43:14 -07:00
Charles Lombardo
79b1394d25 android: Use libnx default icon
Credit to jaames for the original icon
2023-05-30 14:43:14 -07:00
Liam
c7e4edcef0 android: enable LTO 2023-05-30 14:43:14 -07:00
Charles Lombardo
e7e18caa66 android: Show error if invalid keys file is selected
There aren't MIME types specific enough for filtering out files that aren't amiibo or production keys. So here we just check for the extensions "bin" or "keys" where appropriate and stop the process if incorrect. Previously you could select any document and it could cause the app to hang.
2023-05-30 14:43:14 -07:00
Charles Lombardo
7ff9d8dac0 android: Fix first time setup scrolling bug
If you quickly scrolled from the second page to the first and then back, the next/back buttons would disappear.
2023-05-30 14:43:14 -07:00
Charles Lombardo
f3b0531d16 android: Fix A button preference key 2023-05-30 14:43:14 -07:00
Charles Lombardo
5f5e0217a2 android: First time setup screen 2023-05-30 14:43:14 -07:00
Charles Lombardo
f3b1a8ad44 android: Prevent editing unsafe settings at runtime
There currently isn't a visual "disabled" cue for any of the view holders that aren't the switch setting. This will be improved in the future.
2023-05-30 14:43:14 -07:00
Charles Lombardo
bead5dec38 android: Abstract settings
Previously we could only add settings that would change our ini file. Now we can create abstract settings in our presenter to alter things like shared preferences for theme support!
2023-05-30 14:43:13 -07:00
german77
fcc2688441 android: Implement gamepad input 2023-05-30 14:43:13 -07:00
Charles Lombardo
a31b661878 android: Bump minimum version to Android 11 2023-05-30 14:43:13 -07:00
Charles Lombardo
a7c006b45e android: Decouple status bar shade from navigation bar visibility 2023-05-30 14:43:13 -07:00
Charles Lombardo
6a7da72d34 android: Enable code minification 2023-05-30 14:43:13 -07:00
Charles Lombardo
f6e974c11c android: Switch from a colored status bar to a custom view
Allows for smoother transitions with the search bar
2023-05-30 14:43:13 -07:00
Charles Lombardo
7c1d15ebc7 android: Adjustments to card_game
Removed a currently unused text view and moved to material text views.
2023-05-30 14:43:13 -07:00
Charles Lombardo
59fb4cbe31 android: MainActivity overhaul
This moves several parts of the main activity into fragments that manage themselves to react to changes. UI changes like the appearance of a new search view or when the games list changes now gets updated via multiple view models. This also starts a conversion to the androidx navigation component which furthers the goals mentioned previously with more fragment responsibility. This will eventually allow us to use one activity with interchanging fragments and multiple view models that are stored within that central activity.

fdas
2023-05-30 14:43:13 -07:00
Charles Lombardo
d1d42f5a4b android: Enforce Vulkan 1.1 support as minimum 2023-05-30 14:43:12 -07:00
Charles Lombardo
62742a5adb android: Update gradle version to 8.1 2023-05-30 14:43:12 -07:00
Charles Lombardo
5dcd7fe198 android: Update app dependencies 2023-05-30 14:43:12 -07:00
Charles Lombardo
dba6eda9ec android: Convert gradle scripts to Kotlin DSL 2023-05-30 14:43:12 -07:00
bunnei
15aafc9204 android: vulkan: Disable vertex_input_dynamic_state on Qualcomm. 2023-05-30 14:43:12 -07:00
bunnei
c0928bac6f android: settings: Add scaling filter & anti-aliasing options. (#66) 2023-05-30 14:43:12 -07:00
bunnei
7600abdce3 android: video_core: Add support for disk shader cache. (#64) 2023-05-30 14:43:12 -07:00
bunnei
ab68072578 android: vulkan_debug_callback: Ignore many innocuous errors. 2023-05-30 14:43:11 -07:00
bunnei
70c9eaa8b1 android: config: Change docked mode and GPU accuracy to favor performance on Android. 2023-05-30 14:43:11 -07:00
german77
051c36fde6 service: account: Save user profile folder on first user creation 2023-05-30 14:43:11 -07:00
german77
9291532ce0 android: Initialize account manager 2023-05-30 14:43:11 -07:00
german77
67c8a04592 android: Remove unsafe null check 2023-05-30 14:43:11 -07:00
Charles Lombardo
b57c48b42d android: Scale input overlay independently of system display scale 2023-05-30 14:43:11 -07:00
Charles Lombardo
a35b124cd9 android: Use apply instead of commit for shared preferences
Previously we were operating on the assumption that apply'd settings wouldn't be visible immediately. This isn't true and settings will be accessible via memory before being stored to disk. This reduces any potential stutters caused by saving to shared preferences.
2023-05-30 14:43:11 -07:00
Charles Lombardo
84b9f7ac59 android: Add DPad slide toggle 2023-05-30 14:43:11 -07:00
Charles Lombardo
2d77a04964 android: Add relative stick center toggle 2023-05-30 14:43:11 -07:00
Charles Lombardo
97138db766 android: Make hash and branch accessible from BuildConfig 2023-05-30 14:43:11 -07:00
Charles Lombardo
60598bcb1f android: Backup shared preferences where applicable 2023-05-30 14:43:10 -07:00
Charles Lombardo
496459fc58 android: Enable retaining app data after uninstall 2023-05-30 14:43:10 -07:00
Charles Lombardo
748d1d5c32 android: Remove unused doFrame function 2023-05-30 14:43:10 -07:00
Charles Lombardo
32f6147235 android: Convert NativeLibrary to Kotlin 2023-05-30 14:43:10 -07:00
Charles Lombardo
1a9ecebc3f android: Remove LocalBroadcastManager
This causes a couple of minor changes to directory initialization. We don't have a lengthy initialization step so we could spend less time creating state receivers and just run initialization on the main thread. We also don't have a situation where external storage will be a concern so checks are removed in favor of a binary check to see if initialization is ready.

This additionally removes the unused DoFrame callback.
2023-05-30 14:43:10 -07:00
Charles Lombardo
70df43f447 android: Remove game database
The content provider + database solution was excessive and is now replaced with the simple file checks from before but turned into an array list held within a viewmodel.
2023-05-30 14:43:10 -07:00
Charles Lombardo
2fa0a1a063 android: Adjust game icon loading 2023-05-30 14:43:10 -07:00
Charles Lombardo
02fdef8a4c android: Remove unused dimensions files 2023-05-30 14:43:09 -07:00
Charles Lombardo
8e815bd9db android: Slightly reduce game card size 2023-05-30 14:43:09 -07:00
Charles Lombardo
0520a95af1 android: Only show company text view if it has content 2023-05-30 14:43:09 -07:00
Charles Lombardo
7c9f323769 android: Fix check for ok text in software keyboard 2023-05-30 14:43:09 -07:00
Narr the Reg
049c0806ed android: Implement amiibo reading from nfc tag 2023-05-30 14:43:09 -07:00
bunnei
dbba423198 android: vulkan_device: Disable VK_EXT_custom_border_color on Adreno.
- Causes crashes on sampler creation with Super Mario Odyssey.
2023-05-30 14:43:09 -07:00
Charles Lombardo
8396e0a4e4 android: Add toggle controls option to input overlay 2023-05-30 14:43:09 -07:00
Charles Lombardo
8bb37e8355 android: Do not update FPS text on null view 2023-05-30 14:43:09 -07:00
Charles Lombardo
0b6cd1401e android: Convert keyboard applet to kotlin and refactor 2023-05-30 14:43:08 -07:00
bunnei
699366709c android: Implement basic software keyboard applet. 2023-05-30 14:43:08 -07:00
bunnei
c080971741 android: config: Disable shader cache by default on Android. 2023-05-30 14:43:08 -07:00
german77
b234a1fda8 android: Fix fps counter not showing up 2023-05-30 14:43:08 -07:00
Charles Lombardo
b406961d93 android: Prevent showing games on an invalid view 2023-05-30 14:43:08 -07:00
Charles Lombardo
eabd7fe810 android: Re-implement overlay editing 2023-05-30 14:43:08 -07:00
Charles Lombardo
34f17303c8 android: Fix popup menu going out of bounds 2023-05-30 14:43:08 -07:00
Charles Lombardo
b1b71f9ec1 android: Use autofit grid for games fragment 2023-05-30 14:43:08 -07:00
Charles Lombardo
ba537056a8 android: Prevent updating empty game list text on invalid view 2023-05-30 14:43:07 -07:00
Charles Lombardo
3c1395b2c7 android: Persist settings across configuration changes
Mostly things get refactored here to remove previous assumptions made about how the activity/fragment lifecycles would operate. The important change for persistence is removing the assumption that the user will be at the first settings fragment on recreation when deciding whether or not to reload settings. Now we check a flag in Settings to know if we loaded the settings within this lifecycle.
2023-05-30 14:43:07 -07:00
Charles Lombardo
47abe8e2b0 android: Store settings object in viewmodel 2023-05-30 14:43:07 -07:00
Charles Lombardo
5036f1385a android: Remove configChanges exceptions 2023-05-30 14:43:07 -07:00
Charles Lombardo
caa0228ac5 Android: Enable resizeable activities 2023-05-30 14:43:07 -07:00
Charles Lombardo
7a0aeb7bb3 android: Fix emulation fragment comments 2023-05-30 14:43:07 -07:00
Charles Lombardo
9543b5e7a1 android: Use modal navigation drawer as in game menu 2023-05-30 14:43:07 -07:00
Charles Lombardo
6579dede80 android: Make Game class parcelable 2023-05-30 14:43:07 -07:00
Charles Lombardo
2f8047cd82 android: Add kotlin parcelize plugin 2023-05-30 14:43:06 -07:00
Charles Lombardo
bf05829c24 android: Remove deprecated use of onActivityResult 2023-05-30 14:43:06 -07:00
Charles Lombardo
df6f19236f android: Fix RTL layouts 2023-05-30 14:43:06 -07:00
Charles Lombardo
afb74e751f android: Use ellipsis character 2023-05-30 14:43:06 -07:00
Charles Lombardo
1a4328304a android: Move all array strings to main strings file 2023-05-30 14:43:06 -07:00
Charles Lombardo
de3eb7de5d android: Remove unused strings 2023-05-30 14:43:06 -07:00
Charles Lombardo
b3ebba40a3 android: Remove unused colors 2023-05-30 14:43:06 -07:00
Charles Lombardo
80f3e1b9c0 android: Remove citra date time picker 2023-05-30 14:43:06 -07:00
Charles Lombardo
9194ffc4ef android: Remove unused premium header layout 2023-05-30 14:43:06 -07:00
Charles Lombardo
9f42bc6113 android: Remove unused fragment animations 2023-05-30 14:43:05 -07:00
Charles Lombardo
73a9b94e12 android: Remove unused string arrays 2023-05-30 14:43:05 -07:00
Charles Lombardo
b079a6786b android: Remove unused integer xmls 2023-05-30 14:43:05 -07:00
Charles Lombardo
4c6cd9a3b4 android: Refactor ic_launcher.xml to drawables 2023-05-30 14:43:05 -07:00
Charles Lombardo
34f153c323 android: Suppress lint in InsetsHelper 2023-05-30 14:43:05 -07:00
Charles Lombardo
d62a017059 android: Add data extraction rules 2023-05-30 14:43:05 -07:00
Charles Lombardo
33c5940af1 android: Remove requestLegacyExternalStorage attribute 2023-05-30 14:43:05 -07:00
Charles Lombardo
b2432bceab android: Remove unused permissions 2023-05-30 14:43:05 -07:00
Charles Lombardo
8d28351ab4 android: Inset input overlay based on system cutouts 2023-05-30 14:43:05 -07:00
Narr the Reg
be153c39ab Use yuzu as category instead of citra 2023-05-30 14:43:04 -07:00
Charles Lombardo
3b1849e1ad android: Stop updating fps counter when emulation stops 2023-05-30 14:43:04 -07:00
Charles Lombardo
eb02fdd325 android: Move driver installation off of main thread
Additionally creates an indeterminate loading dialog during installation
2023-05-30 14:43:04 -07:00
Charles Lombardo
cdcc7f3ca3 android: Fix crash when decodeGameIcon creates a null Bitmap 2023-05-30 14:43:04 -07:00
Charles Lombardo
815c847443 android: Use view binding 2023-05-30 14:43:04 -07:00
Charles Lombardo
9ae13af101 android: Enable view binding 2023-05-30 14:43:04 -07:00
Charles Lombardo
20d26b3c77 android: Refactor CheckBoxSetting to SwitchSetting 2023-05-30 14:43:03 -07:00
bunnei
2955e087d3 android: EmulationActivity: Fix variable shadowing in fragment creation. 2023-05-30 14:43:03 -07:00
bunnei
df4a59da7e android: res: fragment_emulation: Ensure FPS counter is shown. 2023-05-30 14:43:03 -07:00
Liam
c3e333b3e1 common: link libandroid on android 2023-05-30 14:43:03 -07:00
Liam
431cf09b6a cmake: download architecture-specific ffmpeg for android 2023-05-30 14:43:03 -07:00
Liam
1bc9457ee4 build: only enable adrenotools on arm64 2023-05-30 14:43:03 -07:00
Charles Lombardo
aa438d767f android: Use Skyline's document provider 2023-05-30 14:43:03 -07:00
Charles Lombardo
47b36dd24b android: Use androidx splash screen 2023-05-30 14:43:03 -07:00
Charles Lombardo
5c66e95216 android: Replace Picasso with Coil 2023-05-30 14:43:02 -07:00
Charles Lombardo
63ac91a352 android: New swipe to refresh color scheme 2023-05-30 14:43:02 -07:00
Charles Lombardo
70e2d30eb0 android: New settings fragment animations 2023-05-30 14:43:02 -07:00
Charles Lombardo
e41f369027 android: Use edge to edge 2023-05-30 14:43:02 -07:00
Charles Lombardo
d0ce6b2bd9 android: Use Material 3 components 2023-05-30 14:43:02 -07:00
Charles Lombardo
fd7ead298c android: Modernize theme system 2023-05-30 14:43:02 -07:00
Charles Lombardo
ba2f46f6f7 android: Use vector icons 2023-05-30 14:43:01 -07:00
Charles Lombardo
2fe2424086 android: Use adaptive icon 2023-05-30 14:43:01 -07:00
bunnei
d0da712d11 android: settings: Dynamically evaluate valueAsString
Co-Authored-By: bunnei <bunneidev@gmail.com>
2023-05-30 14:43:01 -07:00
Charles Lombardo
b65a5d1e34 android: Add license identifier 2023-05-30 14:43:01 -07:00
Charles Lombardo
2d64a88a52 android: Convert YuzuApplication to Kotlin 2023-05-30 14:43:00 -07:00
Charles Lombardo
49bc0dd383 android: Convert Action1 to Kotlin 2023-05-30 14:43:00 -07:00
Charles Lombardo
c91e5632b5 android: Convert GameViewHolder to Kotlin 2023-05-30 14:43:00 -07:00
Charles Lombardo
09ccb55fa6 android: Remove ThemeUtil 2023-05-30 14:43:00 -07:00
Charles Lombardo
148ecbb004 android: Convert StartupHandler to Kotlin 2023-05-30 14:43:00 -07:00
Charles Lombardo
2edd193a3d android: Convert Log to Kotlin 2023-05-30 14:43:00 -07:00
Charles Lombardo
b51991ecd3 android: Convert GpuDriverMetadata to Kotlin 2023-05-30 14:43:00 -07:00
Charles Lombardo
c3bdc807d6 android: Convert GpuDriverHelper to Kotlin 2023-05-30 14:43:00 -07:00
Charles Lombardo
b4555675b1 android: Convert GameIconRequestHandler to Kotlin 2023-05-30 14:42:59 -07:00
Charles Lombardo
2ef2bb9bf3 android: Convert ForegroundService to Kotlin 2023-05-30 14:42:59 -07:00
Charles Lombardo
720034075d android: Convert FileUtil to Kotlin 2023-05-30 14:42:59 -07:00
Charles Lombardo
d8d8e97429 android: Convert FileBrowserHelper to Kotlin 2023-05-30 14:42:59 -07:00
Charles Lombardo
75741403be android: Convert EmulationMenuSettings to Kotlin 2023-05-30 14:42:59 -07:00
Charles Lombardo
0a9e7bf47b android: Convert DocumentsTree to Kotlin 2023-05-30 14:42:59 -07:00
Charles Lombardo
716d47f3d1 android: Convert DirectoryStateReceiver to Kotlin 2023-05-30 14:42:59 -07:00
Charles Lombardo
40ddb0c373 android: Convert DirectoryInitialization to Kotlin 2023-05-30 14:42:59 -07:00
Charles Lombardo
c4e428f0c3 android: Convert ControllerMappingHelper to Kotlin 2023-05-30 14:42:58 -07:00
Charles Lombardo
a60b685f43 android: Convert BiMap to Kotlin 2023-05-30 14:42:58 -07:00
Charles Lombardo
89fcb175c5 android: Convert AddDirectoryHelper to Kotlin 2023-05-30 14:42:58 -07:00
Charles Lombardo
e9496062aa android: Convert PlatformGamesView to Kotlin 2023-05-30 14:42:58 -07:00
Charles Lombardo
ac35f6d828 android: Convert PlatformGamesPresenter to Kotlin 2023-05-30 14:42:58 -07:00
Charles Lombardo
8aa54bdb45 android: Convert PlatformGamesFragment to Kotlin 2023-05-30 14:42:58 -07:00
Charles Lombardo
78332f1e64 android: Convert MainView to Kotlin 2023-05-30 14:42:58 -07:00
Charles Lombardo
8c1835ed26 android: Convert MainPresenter to Kotlin 2023-05-30 14:42:57 -07:00
Charles Lombardo
bf076e69ff android: Convert InputOverlayDrawableJoystick to Kotlin 2023-05-30 14:42:57 -07:00
Charles Lombardo
bac89a3fd1 android: Convert MainActivity to Kotlin 2023-05-30 14:42:57 -07:00
Charles Lombardo
4dfac674ab android: Remove ExampleInstrumentedTest 2023-05-30 14:42:57 -07:00
Charles Lombardo
18b11dfda7 android: Remove TwoPaneOnBackPressedCallback
Leftover UI code for dolphin's cheat system. Removing for now.
2023-05-30 14:42:57 -07:00
Charles Lombardo
c6624bb12a android: Convert InputOverlayDrawableDpad to Kotlin 2023-05-30 14:42:57 -07:00
Charles Lombardo
8e8fe279ec android: Convert InputOverlayDrawableButton to Kotlin 2023-05-30 14:42:57 -07:00
Charles Lombardo
64422de5cb android: Convert InputOverlay to Kotlin 2023-05-30 14:42:57 -07:00
Charles Lombardo
92d889415b android: Remove DividerItemDecoration
Removed in favor of material components version
2023-05-30 14:42:57 -07:00
Charles Lombardo
d310ad3b1d android: Inherit from Material 3 themes
Partially breaks the UI for now but is necessary to use new material components.
2023-05-30 14:42:56 -07:00
Charles Lombardo
89652410c3 android: Convert MinimalDocumentFile to Kotlin 2023-05-30 14:42:56 -07:00
Charles Lombardo
a91e94df62 android: Convert GameProvider to Kotlin 2023-05-30 14:42:56 -07:00
Charles Lombardo
2c84d13211 android: Convert GameDatabase to Kotlin 2023-05-30 14:42:56 -07:00
Charles Lombardo
55336f813b android: Convert Game to Kotlin 2023-05-30 14:42:56 -07:00
Charles Lombardo
338b4318cd android: Convert EmulationFragment to Kotlin 2023-05-30 14:42:56 -07:00
Charles Lombardo
9a145a507b android: Convert SettingsFile to Kotlin 2023-05-30 14:42:56 -07:00
Charles Lombardo
e063073bda android: Convert SettingsFrameLayout to Kotlin 2023-05-30 14:42:56 -07:00
Charles Lombardo
bb39d5ebc3 android: Convert SettingsFragmentView to Kotlin 2023-05-30 14:42:55 -07:00
Charles Lombardo
14fd795448 android: Convert SettingsFragmentPresenter to Kotlin 2023-05-30 14:42:55 -07:00
Charles Lombardo
a11391499d android: Convert SettingsFragment to Kotlin 2023-05-30 14:42:55 -07:00
Charles Lombardo
2e2936fc90 android: Convert SettingsActivityView to Kotlin 2023-05-30 14:42:55 -07:00
Charles Lombardo
1cd8119037 android: Convert SettingsActivityPresenter to Kotlin 2023-05-30 14:42:55 -07:00
Charles Lombardo
40c9315040 android: Convert SettingsActivity to Kotlin 2023-05-30 14:42:55 -07:00
Charles Lombardo
2d12a2c28d android: Convert SubmenuViewHolder to Kotlin 2023-05-30 14:42:55 -07:00
Charles Lombardo
06c7ddc876 android: Convert SliderViewHolder to Kotlin 2023-05-30 14:42:55 -07:00
Charles Lombardo
0db2ec1a6f android: Convert SingleChoiceViewHolder to Kotlin 2023-05-30 14:42:55 -07:00
Charles Lombardo
7b00ae6e7a android: Convert SettingViewHolder to Kotlin 2023-05-30 14:42:54 -07:00
Charles Lombardo
1ad6322a4f android: Convert HeaderViewHolder to Kotlin 2023-05-30 14:42:54 -07:00
Charles Lombardo
97892a6df9 android: Convert DateTimeViewHolder to Kotlin 2023-05-30 14:42:54 -07:00
Charles Lombardo
1d8f816673 android: Convert CheckBoxSettingViewHolder to Kotlin 2023-05-30 14:42:54 -07:00
Charles Lombardo
a10a874449 android: Convert StringSetting to Kotlin 2023-05-30 14:42:54 -07:00
Charles Lombardo
f9ab5e1622 android: Convert SettingSection to Kotlin 2023-05-30 14:42:54 -07:00
Charles Lombardo
f376119d75 android: Convert Setting to Kotlin 2023-05-30 14:42:53 -07:00
Charles Lombardo
5a4b36d5e2 android: Convert IntSetting to Kotlin 2023-05-30 14:42:53 -07:00
Charles Lombardo
b2b00b046a android: Convert FloatSetting to Kotlin 2023-05-30 14:42:53 -07:00
Charles Lombardo
880914aa21 android: Convert BooleanSetting to Kotlin 2023-05-30 14:42:53 -07:00
Charles Lombardo
011e1a4751 android: Convert SubmenuSetting to Kotlin 2023-05-30 14:42:53 -07:00
Charles Lombardo
61e0fa1686 android: Convert StringSingleChoiceSetting to Kotlin 2023-05-30 14:42:53 -07:00
Charles Lombardo
b59a5a476d android: Convert SliderSetting to Kotlin 2023-05-30 14:42:53 -07:00
Charles Lombardo
2140c4f784 android: Convert SingleChoiceSetting to Kotlin 2023-05-30 14:42:52 -07:00
Charles Lombardo
6b0a707cbf android: Convert SettingsItem to Kotlin 2023-05-30 14:42:52 -07:00
Charles Lombardo
6b7f140efa android: Convert HeaderSetting to Kotlin 2023-05-30 14:42:52 -07:00
Charles Lombardo
71a5641551 android: Convert DateTimeSetting to Kotlin 2023-05-30 14:42:52 -07:00
Charles Lombardo
7422f5f8da android: Convert CheckBoxSetting to Kotlin 2023-05-30 14:42:52 -07:00
Charles Lombardo
9f5fc9b976 android: Convert GameAdapter to Kotlin 2023-05-30 14:42:52 -07:00
Charles Lombardo
cf748097c7 android: Convert SettingsAdapter to Kotlin
Update SettingsAdapter.kt
2023-05-30 14:42:52 -07:00
Charles Lombardo
e39d9060ed android: Convert EmulationActivity to Kotlin 2023-05-30 14:42:51 -07:00
Charles Lombardo
4603696c46 android: Use material slider in settings dialog 2023-05-30 14:42:51 -07:00
Charles Lombardo
48d363b065 android: Convert Settings to Kotlin 2023-05-30 14:42:51 -07:00
Charles Lombardo
51a780b734 android: Use androidx preferences 2023-05-30 14:42:51 -07:00
bunnei
eda7cdd47e android: frontend: Add unique error strings for Vulkan initialization errors. 2023-05-30 14:42:51 -07:00
german77
414c9bff9e android: Use the center of the object and reduce draw calls 2023-05-30 14:42:51 -07:00
german77
1b30825bb9 android: Replace old buttons with vectors 2023-05-30 14:42:51 -07:00
Charles Lombardo
7d71e332cc android: Enable Kotlin support 2023-05-30 14:42:50 -07:00
Charles Lombardo
9efe4d93af android: Upgrade java version to 11 2023-05-30 14:42:50 -07:00
Charles Lombardo
10a6dcccc1 android: Upgrade dependencies 2023-05-30 14:42:50 -07:00
Charles Lombardo
1d8e6c5bd3 android: Upgrade to AGP 7.4.2 2023-05-30 14:42:50 -07:00
Charles Lombardo
aebedc198c android: Replace lintOptions with lint 2023-05-30 14:42:50 -07:00
Charles Lombardo
f8d7825c8c android: Move namespace to app module build.gradle 2023-05-30 14:42:50 -07:00
Charles Lombardo
3089744d86 android: bump compile/target sdk to 33 2023-05-30 14:42:50 -07:00
Charles Lombardo
dc1a78d3ca android: Upgrade gradle to 8.0.1 2023-05-30 14:42:50 -07:00
liushuyu
c26d31e748 video_core: fix clang-format errors 2023-05-30 14:42:49 -07:00
liushuyu
b9d6732b1b CMake: fix pkg-config behavior when building for Android 2023-05-30 14:42:49 -07:00
liushuyu
3a6b230ba8 CI: add Android build systems 2023-05-30 14:42:49 -07:00
bunnei
fc0a0bbdba android: build.gradle: Cleanup build types. 2023-05-30 14:42:49 -07:00
bunnei
6df9f81ef0 android: frontend: settings: Add graphics debugging. 2023-05-30 14:42:49 -07:00
bunnei
363bd5cddc android: jni: Ensure system is only initialized once.
- Fixes likelihood that fastmem allocation succeeds.
2023-05-30 14:42:49 -07:00
bunnei
032bfb7b28 video_core: vulkan_device: Correct error message for unsuitable driver. 2023-05-30 14:42:49 -07:00
bunnei
f4067d9f62 android: frontend: Cleanup framerate counter. 2023-05-30 14:42:49 -07:00
bunnei
5ea10323da android: vulkan: Implement adrenotools turbo mode. 2023-05-30 14:42:48 -07:00
bunnei
3a5ca6d021 android: vulkan_device: Disable VK_EXT_extended_dynamic_state2 on Qualcomm.
- Newer drivers report this as supported, but it is broken.
2023-05-30 14:42:48 -07:00
bunnei
06fc9413dc android: frontend: Add support for GPU driver selection. 2023-05-30 14:42:48 -07:00
bunnei
6dc642c734 android: native: Add support for custom Vulkan driver loading. 2023-05-30 14:42:48 -07:00
bunnei
3202b0dcfa core: frontend: Refactor GraphicsContext to its own module. 2023-05-30 14:42:48 -07:00
bunnei
05fd5644d6 common: dynamic_library: Add ctor for existing handle. 2023-05-30 14:42:48 -07:00
bunnei
73daf3cdfd android: EmulationFragment: Always reset overlay.
- Ensures correct placement until we have better overlay configuration.
2023-05-30 14:42:48 -07:00
Billy Laws
e7129fe15f Avoid using VectorExtractDynamic for subgroup mask on Adreno GPUs
This crashes their shader compiler for some reason.
2023-05-30 14:42:47 -07:00
Billy Laws
e7474efdc1 Implement scaled vertex buffer format emulation
These formats are unsupported by mobile GPUs so they need to be emulated in shaders instead.
2023-05-30 14:42:47 -07:00
Billy Laws
0a684d96c4 Disable push descriptors on adreno drivers
Regular descriptors are around 1.5x faster to update.
2023-05-30 14:42:47 -07:00
Billy Laws
eb606302c7 Disable VK_EXT_extended_dynamic_state on mali 2023-05-30 14:42:47 -07:00
Billy Laws
2bc17302e8 Disable multithreaded pipeline compilation on Qualcomm drivers
This causes crashes during compilation on several 6xx and 5xx driver versions.
2023-05-30 14:42:47 -07:00
Narr the Reg
19143f1680 android: Add motion sensor 2023-05-30 14:42:47 -07:00
Narr the Reg
7fd231034c android: Hook jni input properly 2023-05-30 14:42:47 -07:00
Narr the Reg
e51880896b android: cleanup touch update loop 2023-05-30 14:42:46 -07:00
Narr the Reg
663b550815 android: Clean joystick overlay 2023-05-30 14:42:46 -07:00
Narr the Reg
e5ea4ac539 android: Clean dpad overlay 2023-05-30 14:42:46 -07:00
Narr the Reg
b178100adf android: Clean button overlay 2023-05-30 14:42:46 -07:00
Narr the Reg
b3b4d23da2 android: Add all buttons to screen controller 2023-05-30 14:42:46 -07:00
Narr the Reg
38c954f96a android: Apply clang format 2023-05-30 14:42:46 -07:00
bunnei
ad74e8d532 android: frontend: Implement game grid view. (#9) 2023-05-30 14:42:46 -07:00
german77
7d14cd7cf3 android: Replace notification icon with yuzu 2023-05-30 14:42:46 -07:00
bunnei
75d3e0f2d8 android: strings: Refresh key dumping URL. 2023-05-30 14:42:46 -07:00
bunnei
031f4d5414 android: frontend: Modify ROM load messaging for invalid keys. 2023-05-30 14:42:46 -07:00
bunnei
e9de6494a2 android: frontend: Integrate key installation for SAF. 2023-05-30 14:42:45 -07:00
bunnei
df3a6eb96f android: jni: Add function to reload keys. 2023-05-30 14:42:45 -07:00
bunnei
5e11ebc572 core: crypto: key_manager: Add methods to reload & validate keys. 2023-05-30 14:42:45 -07:00
bunnei
309205894b android: EmulationActivity: Temporarily disable running notification. 2023-05-30 14:42:45 -07:00
bunnei
d8a9867947 android: Implement SAF support & migrate to SDK 31. (#4) 2023-05-30 14:42:45 -07:00
bunnei
9556258d0c android: Harden emulation shutdown when loader fails. 2023-05-30 14:42:45 -07:00
bunnei
5175168e2e android: SettingsFragmentPresenter: Fix default renderer backend. 2023-05-30 14:42:44 -07:00
bunnei
96cd359c2e android: jni: native: Add lock around HaltEmulation, tighten run loop. 2023-05-30 14:42:44 -07:00
bunnei
b5a68380e7 android: jni: native: Refactor locking for is_running. 2023-05-30 14:42:44 -07:00
bunnei
494bfc4285 android: jni: native: Remove unnecessary atomic for is_running. 2023-05-30 14:42:44 -07:00
bunnei
4bf3d4736b android: jni: native: Tighten up emulation start/stop signaling. 2023-05-30 14:42:44 -07:00
bunnei
d316e4e80c android: jni: native: Consolidate emulation state into EmulationSession singleton.
- Fixes state management issues across multiple boots.
- Fixes crashes related to unsafe access of perf stats.
2023-05-30 14:42:44 -07:00
bunnei
94dee0717d android: Frontend: Fix rendering aspect ratio & add a setting for it. 2023-05-30 14:42:44 -07:00
bunnei
eb6dc7dbe4 android: Integrate settings frontend with yuzu & remove unused code. 2023-05-30 14:42:44 -07:00
Liam
b57765c7dd externals: add adrenotools for bcenabler 2023-05-30 14:42:44 -07:00
Liam
2129d6f096 device_memory: Use smaller virtual reservation size for compatibility with 39-bit paging 2023-05-30 14:42:43 -07:00
bunnei
975c3d07c4 video_core: vulkan_device: Device initialization for Adreno. 2023-05-30 14:42:43 -07:00
bunnei
5a17c13664 video_core: vk_pipeline_cache: Disable support_descriptor_aliasing on Android. 2023-05-30 14:42:43 -07:00
bunnei
3042dc9da7 video_core: vk_swapchain: Fix image format for Android. 2023-05-30 14:42:43 -07:00
bunnei
0097181ff0 android: Minimize frontend & convert to yuzu. 2023-05-30 14:42:43 -07:00
bunnei
61f2a6bddd video_core: vk_blit_screen: Rotate viewport for Android landscape. 2023-05-30 14:42:42 -07:00
bunnei
a1423efcfa common: error: Fix for Android. 2023-05-30 14:42:42 -07:00
bunnei
190e08f8c1 common: fs: Implement for Android. 2023-05-30 14:42:42 -07:00
bunnei
b412b23233 common: logging: Implement Android logcat backend. 2023-05-30 14:42:41 -07:00
bunnei
0f5477b7cc common: host_memory: Implement for Android. 2023-05-30 14:42:41 -07:00
bunnei
11acf6399c android: Minimal JNI for yuzu. 2023-05-30 14:42:41 -07:00
bunnei
daf36fd560 android: Add Citra frontend. 2023-05-30 14:42:41 -07:00
bunnei
2e68d12407 cmake: Integrate bundled FFmpeg for Android. 2023-05-30 14:42:41 -07:00
bunnei
29aabdc762 cmake: Integrate submoduled LLVM & fixes for Android. 2023-05-30 14:42:41 -07:00
326 changed files with 20195 additions and 185 deletions

15
.ci/scripts/android/build.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash -ex
# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
# SPDX-License-Identifier: GPL-3.0-or-later
export NDK_CCACHE="$(which ccache)"
ccache -s
BUILD_FLAVOR=mainline
cd src/android
chmod +x ./gradlew
./gradlew "assemble${BUILD_FLAVOR}Release" "bundle${BUILD_FLAVOR}Release"
ccache -s

27
.ci/scripts/android/upload.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash -ex
# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
# SPDX-License-Identifier: GPL-3.0-or-later
. ./.ci/scripts/common/pre-upload.sh
REV_NAME="yuzu-${GITDATE}-${GITREV}"
BUILD_FLAVOR=mainline
cp src/android/app/build/outputs/apk/"${BUILD_FLAVOR}/release/app-${BUILD_FLAVOR}-release.apk" \
"artifacts/${REV_NAME}.apk"
cp src/android/app/build/outputs/bundle/"${BUILD_FLAVOR}Release"/"app-${BUILD_FLAVOR}-release.aab" \
"artifacts/${REV_NAME}.aab"
if [ -n "${ANDROID_KEYSTORE_B64}" ]
then
echo "Signing apk..."
base64 --decode <<< "${ANDROID_KEYSTORE_B64}" > ks.jks
apksigner sign --ks ks.jks \
--ks-key-alias "${ANDROID_KEY_ALIAS}" \
--ks-pass env:ANDROID_KEYSTORE_PASS "artifacts/${REV_NAME}.apk"
else
echo "No keystore specified, not signing the APK files."
fi

View File

@@ -122,3 +122,62 @@ jobs:
with:
name: ${{ env.INDIVIDUAL_EXE }}
path: ${{ env.INDIVIDUAL_EXE }}
android:
runs-on: ubuntu-latest
needs: format
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Set up cache
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.ccache
key: ${{ runner.os }}-android-${{ github.sha }}
restore-keys: |
${{ runner.os }}-android-
- name: Query tag name
uses: olegtarasov/get-tag@v2.1.2
id: tagName
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y ccache apksigner glslang-dev glslang-tools
git -C ./externals/vcpkg/ fetch --all --unshallow
- name: Build
run: ./.ci/scripts/android/build.sh
- name: Copy and sign artifacts
env:
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
run: ./.ci/scripts/android/upload.sh
- name: Upload
uses: actions/upload-artifact@v3
with:
name: android
path: artifacts/
release:
runs-on: ubuntu-latest
needs: [ android ]
if: ${{ startsWith(github.ref, 'refs/tags/') }}
steps:
- uses: actions/download-artifact@v3
- name: Create release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref_name }}
release_name: ${{ github.ref_name }}
draft: false
prerelease: false
- name: Upload artifacts
uses: alexellis/upload-assets@0.4.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
asset_paths: '["./**/*.tar.*","./**/*.AppImage","./**/*.7z","./**/*.zip","./**/*.apk","./**/*.aab"]'

3
.gitmodules vendored
View File

@@ -49,3 +49,6 @@
[submodule "cpp-jwt"]
path = externals/cpp-jwt
url = https://github.com/arun11299/cpp-jwt.git
[submodule "externals/libadrenotools"]
path = externals/libadrenotools
url = https://github.com/bylaws/libadrenotools

View File

@@ -135,3 +135,15 @@ License: GPL-3.0-or-later
Files: .github/ISSUE_TEMPLATE/*
Copyright: 2022 yuzu Emulator Project
License: GPL-2.0-or-later
Files: src/android/app/src/ea/res/*
Copyright: 2023 yuzu Emulator Project
License: GPL-3.0-or-later
Files: src/android/app/src/main/res/*
Copyright: 2023 yuzu Emulator Project
License: GPL-3.0-or-later
Files: src/android/gradle/wrapper/*
Copyright: 2023 yuzu Emulator Project
License: GPL-3.0-or-later

View File

@@ -11,6 +11,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/externals/cmake-modul
include(DownloadExternals)
include(CMakeDependentOption)
include(CTest)
include(FetchContent)
# Set bundled sdl2/qt as dependent options.
# OFF by default, but if ENABLE_SDL2 and MSVC are true then ON
@@ -19,7 +20,7 @@ CMAKE_DEPENDENT_OPTION(YUZU_USE_BUNDLED_SDL2 "Download bundled SDL2 binaries" ON
# On Linux system SDL2 is likely to be lacking HIDAPI support which have drawbacks but is needed for SDL motion
CMAKE_DEPENDENT_OPTION(YUZU_USE_EXTERNAL_SDL2 "Compile external SDL2" ON "ENABLE_SDL2;NOT MSVC" OFF)
option(ENABLE_LIBUSB "Enable the use of LibUSB" ON)
option(ENABLE_LIBUSB "Enable the use of LibUSB" "NOT ${ANDROID}")
option(ENABLE_OPENGL "Enable OpenGL" ON)
mark_as_advanced(FORCE ENABLE_OPENGL)
@@ -48,7 +49,7 @@ option(YUZU_TESTS "Compile tests" "${BUILD_TESTING}")
option(YUZU_USE_PRECOMPILED_HEADERS "Use precompiled headers" ON)
option(YUZU_ROOM "Compile LDN room server" ON)
option(YUZU_ROOM "Compile LDN room server" "NOT ${ANDROID}")
CMAKE_DEPENDENT_OPTION(YUZU_CRASH_DUMPS "Compile Windows crash dump (Minidump) support" OFF "WIN32" OFF)
@@ -60,7 +61,67 @@ option(YUZU_ENABLE_LTO "Enable link-time optimization" OFF)
CMAKE_DEPENDENT_OPTION(YUZU_USE_FASTER_LD "Check if a faster linker is available" ON "NOT WIN32" OFF)
# On Android, fetch and compile libcxx before doing anything else
if (ANDROID)
set(CMAKE_SKIP_INSTALL_RULES ON)
set(LLVM_VERSION "15.0.6")
# Note: even though libcxx and libcxxabi have separate releases on the project page,
# the separated releases cannot be compiled. Only in-tree builds work. Therefore we
# must fetch the source release for the entire llvm tree.
FetchContent_Declare(llvm
URL "https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_VERSION}/llvm-project-${LLVM_VERSION}.src.tar.xz"
URL_HASH SHA256=9d53ad04dc60cb7b30e810faf64c5ab8157dadef46c8766f67f286238256ff92
TLS_VERIFY TRUE
)
FetchContent_MakeAvailable(llvm)
# libcxx has support for most of the range library, but it's gated behind a flag:
add_compile_definitions(_LIBCPP_ENABLE_EXPERIMENTAL)
# Disable standard header inclusion
set(ANDROID_STL "none")
# libcxxabi
set(LIBCXXABI_INCLUDE_TESTS OFF)
set(LIBCXXABI_ENABLE_SHARED FALSE)
set(LIBCXXABI_ENABLE_STATIC TRUE)
set(LIBCXXABI_LIBCXX_INCLUDES "${LIBCXX_TARGET_INCLUDE_DIRECTORY}" CACHE STRING "" FORCE)
add_subdirectory("${llvm_SOURCE_DIR}/libcxxabi" "${llvm_BINARY_DIR}/libcxxabi")
link_libraries(cxxabi_static)
# libcxx
set(LIBCXX_ABI_NAMESPACE "__ndk1" CACHE STRING "" FORCE)
set(LIBCXX_CXX_ABI "libcxxabi")
set(LIBCXX_INCLUDE_TESTS OFF)
set(LIBCXX_INCLUDE_BENCHMARKS OFF)
set(LIBCXX_INCLUDE_DOCS OFF)
set(LIBCXX_ENABLE_SHARED FALSE)
set(LIBCXX_ENABLE_STATIC TRUE)
set(LIBCXX_ENABLE_ASSERTIONS FALSE)
add_subdirectory("${llvm_SOURCE_DIR}/libcxx" "${llvm_BINARY_DIR}/libcxx")
set_target_properties(cxx-headers PROPERTIES INTERFACE_COMPILE_OPTIONS "-isystem${CMAKE_BINARY_DIR}/${LIBCXX_INSTALL_INCLUDE_DIR}")
link_libraries(cxx_static cxx-headers)
endif()
if (YUZU_USE_BUNDLED_VCPKG)
if (ANDROID)
set(ENV{ANDROID_NDK_HOME} "${ANDROID_NDK}")
list(APPEND VCPKG_MANIFEST_FEATURES "android")
if (CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a")
set(VCPKG_TARGET_TRIPLET "arm64-android")
# this is to avoid CMake using the host pkg-config to find the host
# libraries when building for Android targets
set(PKG_CONFIG_EXECUTABLE "aarch64-none-linux-android-pkg-config" CACHE FILEPATH "" FORCE)
elseif (CMAKE_ANDROID_ARCH_ABI STREQUAL "x86_64")
set(VCPKG_TARGET_TRIPLET "x64-android")
set(PKG_CONFIG_EXECUTABLE "x86_64-none-linux-android-pkg-config" CACHE FILEPATH "" FORCE)
else()
message(FATAL_ERROR "Unsupported Android architecture ${CMAKE_ANDROID_ARCH_ABI}")
endif()
endif()
if (YUZU_TESTS)
list(APPEND VCPKG_MANIFEST_FEATURES "yuzu-tests")
endif()
@@ -457,7 +518,7 @@ set(FFmpeg_COMPONENTS
avutil
swscale)
if (UNIX AND NOT APPLE)
if (UNIX AND NOT APPLE AND NOT ANDROID)
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBVA libva)
endif()

View File

@@ -7,6 +7,7 @@
# prefix_var: name of a variable which will be set with the path to the extracted contents
function(download_bundled_external remote_path lib_name prefix_var)
set(package_base_url "https://github.com/yuzu-emu/")
set(package_repo "no_platform")
set(package_extension "no_platform")
if (WIN32)
@@ -15,10 +16,13 @@ if (WIN32)
elseif (${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
set(package_repo "ext-linux-bin/raw/main/")
set(package_extension ".tar.xz")
elseif (ANDROID)
set(package_repo "ext-android-bin/raw/main/")
set(package_extension ".tar.xz")
else()
message(FATAL_ERROR "No package available for this platform")
endif()
set(package_url "https://github.com/yuzu-emu/${package_repo}")
set(package_url "${package_base_url}${package_repo}")
set(prefix "${CMAKE_BINARY_DIR}/externals/${lib_name}")
if (NOT EXISTS "${prefix}")

373
LICENSES/MPL-2.0.txt Normal file
View File

@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

View File

@@ -147,3 +147,9 @@ endif()
add_library(stb stb/stb_dxt.cpp)
target_include_directories(stb PUBLIC ./stb)
if (ANDROID)
if (ARCHITECTURE_arm64)
add_subdirectory(libadrenotools)
endif()
endif()

View File

@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2021 yuzu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
if (NOT WIN32)
if (NOT WIN32 AND NOT ANDROID)
# Build FFmpeg from externals
message(STATUS "Using FFmpeg from externals")
@@ -44,10 +44,12 @@ if (NOT WIN32)
endforeach()
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBVA libva)
pkg_check_modules(CUDA cuda)
pkg_check_modules(FFNVCODEC ffnvcodec)
pkg_check_modules(VDPAU vdpau)
if (NOT ANDROID)
pkg_check_modules(LIBVA libva)
pkg_check_modules(CUDA cuda)
pkg_check_modules(FFNVCODEC ffnvcodec)
pkg_check_modules(VDPAU vdpau)
endif()
set(FFmpeg_HWACCEL_LIBRARIES)
set(FFmpeg_HWACCEL_FLAGS)
@@ -121,6 +123,26 @@ if (NOT WIN32)
list(APPEND FFmpeg_HWACCEL_FLAGS --disable-vdpau)
endif()
find_program(BASH_PROGRAM bash REQUIRED)
set(FFmpeg_CROSS_COMPILE_FLAGS "")
if (ANDROID)
string(TOLOWER "${CMAKE_HOST_SYSTEM_NAME}" FFmpeg_HOST_SYSTEM_NAME)
set(TOOLCHAIN "${ANDROID_NDK}/toolchains/llvm/prebuilt/${FFmpeg_HOST_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}")
set(SYSROOT "${TOOLCHAIN}/sysroot")
set(FFmpeg_CPU "armv8-a")
list(APPEND FFmpeg_CROSS_COMPILE_FLAGS
--arch=arm64
#--cpu=${FFmpeg_CPU}
--enable-cross-compile
--cross-prefix=${TOOLCHAIN}/bin/aarch64-linux-android-
--sysroot=${SYSROOT}
--target-os=android
--extra-ldflags="--ld-path=${TOOLCHAIN}/bin/ld.lld"
--extra-ldflags="-nostdlib"
)
endif()
# `configure` parameters builds only exactly what yuzu needs from FFmpeg
# `--disable-vdpau` is needed to avoid linking issues
set(FFmpeg_CC ${CMAKE_C_COMPILER_LAUNCHER} ${CMAKE_C_COMPILER})
@@ -129,7 +151,7 @@ if (NOT WIN32)
OUTPUT
${FFmpeg_MAKEFILE}
COMMAND
/bin/bash ${FFmpeg_PREFIX}/configure
${BASH_PROGRAM} ${FFmpeg_PREFIX}/configure
--disable-avdevice
--disable-avformat
--disable-doc
@@ -146,12 +168,14 @@ if (NOT WIN32)
--cc="${FFmpeg_CC}"
--cxx="${FFmpeg_CXX}"
${FFmpeg_HWACCEL_FLAGS}
${FFmpeg_CROSS_COMPILE_FLAGS}
WORKING_DIRECTORY
${FFmpeg_BUILD_DIR}
)
unset(FFmpeg_CC)
unset(FFmpeg_CXX)
unset(FFmpeg_HWACCEL_FLAGS)
unset(FFmpeg_CROSS_COMPILE_FLAGS)
# Workaround for Ubuntu 18.04's older version of make not being able to call make as a child
# with context of the jobserver. Also helps ninja users.
@@ -197,7 +221,38 @@ if (NOT WIN32)
else()
message(FATAL_ERROR "FFmpeg not found")
endif()
else(WIN32)
elseif(ANDROID)
# Use yuzu FFmpeg binaries
if (ARCHITECTURE_arm64)
set(FFmpeg_EXT_NAME "ffmpeg-android-v5.1.LTS-aarch64")
elseif (ARCHITECTURE_x86_64)
set(FFmpeg_EXT_NAME "ffmpeg-android-v5.1.LTS-x86_64")
else()
message(FATAL_ERROR "Unsupported architecture for Android FFmpeg")
endif()
set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}")
download_bundled_external("ffmpeg/" ${FFmpeg_EXT_NAME} "")
set(FFmpeg_FOUND YES)
set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE)
set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/lib" CACHE PATH "Path to FFmpeg library directory" FORCE)
set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE)
set(FFmpeg_LIBRARIES
${FFmpeg_LIBRARY_DIR}/libavcodec.so
${FFmpeg_LIBRARY_DIR}/libavdevice.so
${FFmpeg_LIBRARY_DIR}/libavfilter.so
${FFmpeg_LIBRARY_DIR}/libavformat.so
${FFmpeg_LIBRARY_DIR}/libavutil.so
${FFmpeg_LIBRARY_DIR}/libswresample.so
${FFmpeg_LIBRARY_DIR}/libswscale.so
${FFmpeg_LIBRARY_DIR}/libvpx.a
${FFmpeg_LIBRARY_DIR}/libx264.a
CACHE PATH "Paths to FFmpeg libraries" FORCE)
# exported variables
set(FFmpeg_PATH "${FFmpeg_PATH}" PARENT_SCOPE)
set(FFmpeg_LDFLAGS "${FFmpeg_LDFLAGS}" PARENT_SCOPE)
set(FFmpeg_LIBRARIES "${FFmpeg_LIBRARIES}" PARENT_SCOPE)
set(FFmpeg_INCLUDE_DIR "${FFmpeg_INCLUDE_DIR}" PARENT_SCOPE)
elseif(WIN32)
# Use yuzu FFmpeg binaries
set(FFmpeg_EXT_NAME "ffmpeg-5.1.3")
set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}")
@@ -206,7 +261,6 @@ else(WIN32)
set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE)
set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg library directory" FORCE)
set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE)
set(FFmpeg_DLL_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg dll's" FORCE)
set(FFmpeg_LIBRARIES
${FFmpeg_LIBRARY_DIR}/swscale.lib
${FFmpeg_LIBRARY_DIR}/avcodec.lib

1
externals/libadrenotools vendored Submodule

View File

@@ -195,3 +195,8 @@ endif()
if (ENABLE_WEB_SERVICE)
add_subdirectory(web_service)
endif()
if (ANDROID)
add_subdirectory(android/app/src/main/jni)
target_include_directories(yuzu-android PRIVATE android/app/src/main)
endif()

65
src/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,65 @@
# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
# SPDX-License-Identifier: GPL-3.0-or-later
# Built application files
*.apk
*.ap_
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
# Keystore files
# Uncomment the following line if you do not want to check your keystore files in.
#*.jks
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# CXX compile cache
app/.cxx
# Google Services (e.g. APIs or Firebase)
google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md

View File

@@ -0,0 +1,245 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.8.21"
}
/**
* Use the number of seconds/10 since Jan 1 2016 as the versionCode.
* This lets us upload a new build at most every 10 seconds for the
* next 680 years.
*/
val autoVersion = (((System.currentTimeMillis() / 1000) - 1451606400) / 10).toInt()
@Suppress("UnstableApiUsage")
android {
namespace = "org.yuzu.yuzu_emu"
compileSdkVersion = "android-33"
ndkVersion = "25.2.9519653"
buildFeatures {
viewBinding = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packagingOptions {
// This is necessary for libadrenotools custom driver loading
jniLibs.useLegacyPackaging = true
}
lint {
// This is important as it will run lint but not abort on error
// Lint has some overly obnoxious "errors" that should really be warnings
abortOnError = false
//Uncomment disable lines for test builds...
//disable 'MissingTranslation'bin
//disable 'ExtraTranslation'
}
defaultConfig {
// TODO If this is ever modified, change application_id in strings.xml
applicationId = "org.yuzu.yuzu_emu"
minSdk = 30
targetSdk = 33
versionName = getGitVersion()
ndk {
abiFilters += listOf("arm64-v8a")
}
buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"")
buildConfigField("String", "BRANCH", "\"${getBranch()}\"")
}
// Define build types, which are orthogonal to product flavors.
buildTypes {
// Signed by release key, allowing for upload to Play Store.
release {
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isDebuggable = false
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard-rules.pro"
)
}
register("relWithVersionCode") {
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isDebuggable = false
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard-rules.pro"
)
}
// builds a release build that doesn't need signing
// Attaches 'debug' suffix to version and package name, allowing installation alongside the release build.
register("relWithDebInfo") {
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isDebuggable = true
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard-rules.pro"
)
versionNameSuffix = "-debug"
isJniDebuggable = true
}
// Signed by debug key disallowing distribution on Play Store.
// Attaches 'debug' suffix to version and package name, allowing installation alongside the release build.
debug {
isDebuggable = true
isJniDebuggable = true
versionNameSuffix = "-debug"
}
}
flavorDimensions.add("version")
productFlavors {
create("mainline") {
dimension = "version"
buildConfigField("Boolean", "PREMIUM", "false")
}
create("ea") {
dimension = "version"
buildConfigField("Boolean", "PREMIUM", "true")
applicationIdSuffix = ".ea"
}
}
externalNativeBuild {
cmake {
version = "3.22.1"
path = file("../../../CMakeLists.txt")
}
}
defaultConfig {
externalNativeBuild {
cmake {
arguments(
"-DENABLE_QT=0", // Don't use QT
"-DENABLE_SDL2=0", // Don't use SDL
"-DENABLE_WEB_SERVICE=0", // Don't use telemetry
"-DBUNDLE_SPEEX=ON",
"-DANDROID_ARM_NEON=true", // cryptopp requires Neon to work
"-DYUZU_USE_BUNDLED_VCPKG=ON",
"-DYUZU_USE_BUNDLED_FFMPEG=ON",
"-DYUZU_ENABLE_LTO=ON"
)
abiFilters("arm64-v8a", "x86_64")
}
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.recyclerview:recyclerview:1.3.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.fragment:fragment-ktx:1.5.7")
implementation("androidx.documentfile:documentfile:1.0.1")
implementation("com.google.android.material:material:1.9.0")
implementation("androidx.preference:preference:1.2.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
implementation("io.coil-kt:coil:2.2.2")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.window:window:1.0.0")
implementation("org.ini4j:ini4j:0.5.4")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.navigation:navigation-fragment-ktx:2.5.3")
implementation("androidx.navigation:navigation-ui-ktx:2.5.3")
implementation("info.debatty:java-string-similarity:2.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
}
fun getGitVersion(): String {
var versionName = "0.0"
try {
versionName = ProcessBuilder("git", "describe", "--always", "--long")
.directory(project.rootDir)
.redirectOutput(ProcessBuilder.Redirect.PIPE)
.redirectError(ProcessBuilder.Redirect.PIPE)
.start().inputStream.bufferedReader().use { it.readText() }
.trim()
.replace(Regex("(-0)?-[^-]+$"), "")
} catch (e: Exception) {
logger.error("Cannot find git, defaulting to dummy version number")
}
if (System.getenv("GITHUB_ACTIONS") != null) {
val gitTag = System.getenv("GIT_TAG_NAME")
versionName = gitTag ?: versionName
}
return versionName
}
fun getGitHash(): String {
try {
val processBuilder = ProcessBuilder("git", "rev-parse", "--short", "HEAD")
processBuilder.directory(project.rootDir)
val process = processBuilder.start()
val inputStream = process.inputStream
val errorStream = process.errorStream
process.waitFor()
return if (process.exitValue() == 0) {
inputStream.bufferedReader()
.use { it.readText().trim() } // return the value of gitHash
} else {
val errorMessage = errorStream.bufferedReader().use { it.readText().trim() }
logger.error("Error running git command: $errorMessage")
"dummy-hash" // return a dummy hash value in case of an error
}
} catch (e: Exception) {
logger.error("$e: Cannot find git, defaulting to dummy build hash")
return "dummy-hash" // return a dummy hash value in case of an error
}
}
fun getBranch(): String {
try {
val processBuilder = ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")
processBuilder.directory(project.rootDir)
val process = processBuilder.start()
val inputStream = process.inputStream
val errorStream = process.errorStream
process.waitFor()
return if (process.exitValue() == 0) {
inputStream.bufferedReader()
.use { it.readText().trim() } // return the value of gitHash
} else {
val errorMessage = errorStream.bufferedReader().use { it.readText().trim() }
logger.error("Error running git command: $errorMessage")
"dummy-hash" // return a dummy hash value in case of an error
}
} catch (e: Exception) {
logger.error("$e: Cannot find git, defaulting to dummy build hash")
return "dummy-hash" // return a dummy hash value in case of an error
}
}

24
src/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,24 @@
# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
# SPDX-License-Identifier: GPL-3.0-or-later
# To get usable stack traces
-dontobfuscate
# Prevents crashing when using Wini
-keep class org.ini4j.spi.IniParser
-keep class org.ini4j.spi.IniBuilder
-keep class org.ini4j.spi.IniFormatter
# Suppress warnings for R8
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
-dontwarn java.beans.Introspector
-dontwarn java.beans.VetoableChangeListener
-dontwarn java.beans.VetoableChangeSupport

View File

@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="500"
android:viewportHeight="500">
<path
android:fillColor="#C6C6C6"
android:fillType="nonZero"
android:pathData="M262.66,175.11L262.66,375.05C318.54,375.05 363.85,330.29 363.85,275.08C363.85,219.87 318.54,175.11 262.66,175.11M282.43,197.01C318.67,206 344.09,238.19 344.09,275.11C344.09,312.03 318.67,344.22 282.43,353.2L282.43,197.01"
android:strokeWidth="1.46"
android:strokeColor="#00000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
<path
android:fillColor="#FFDC00"
android:fillType="nonZero"
android:pathData="M237.31,125.11C181.43,125.11 136.12,169.87 136.12,225.08C136.12,280.29 181.43,325.05 237.31,325.05ZM217.57,147.01L217.57,303.2C189.11,296.16 166.67,274.54 158.84,246.6C151.01,218.65 159,188.71 179.75,168.21C190.16,157.86 203.24,150.53 217.57,147.01"
android:strokeWidth="1.46"
android:strokeColor="#00000000"
android:strokeLineCap="butt"
android:strokeLineJoin="miter" />
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="155.3dp"
android:height="172.55dp"
android:viewportWidth="155.3"
android:viewportHeight="172.55">
<path
android:fillColor="#C6C6C6"
android:pathData="M86.28,34.51v138a69,69 0,0 0,0 -138M99.76,49.63a55.57,55.57 0,0 1,0 107.8V49.63" />
<path
android:fillColor="#FFDC00"
android:pathData="M69,0a69,69 0,0 0,0 138ZM55.54,15.12v107.8A55.55,55.55 0,0 1,29.75 29.75,55.1 55.1,0 0,1 55.54,15.12" />
</vector>

View File

@@ -0,0 +1,24 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="340.97dp"
android:height="389.85dp"
android:viewportWidth="340.97"
android:viewportHeight="389.85">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M341,268.68v73c0,14.5 -2.24,25.24 -6.83,32.82 -5.92,10.15 -16.21,15.32 -30.54,15.32S279,384.61 273,374.27c-4.56,-7.64 -6.8,-18.42 -6.8,-32.92V268.68a4.52,4.52 0,0 1,4.51 -4.51H273a4.5,4.5 0,0 1,4.5 4.51v72.5c0,33.53 14.88,37.4 26.07,37.4 12.14,0 26.08,-4.17 26.08,-36.71V268.68a4.52,4.52 0,0 1,4.52 -4.51h2.27A4.5,4.5 0,0 1,341 268.68Z" />
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M246.49,389.85H178.6c-2.35,0 -4.72,-1.88 -4.72,-6.08a8.28,8.28 0,0 1,1.33 -4.48l60.33,-104.47H186a4.51,4.51 0,0 1,-4.51 -4.51v-1.58a4.51,4.51 0,0 1,4.48 -4.51h0.8c58.69,-0.11 59.12,0 59.67,0.07a5.19,5.19 0,0 1,4 5.8,8.69 8.69,0 0,1 -1.33,3.76l-60.6,104.77h58a4.51,4.51 0,0 1,4.51 4.51v2.21A4.51,4.51 0,0 1,246.49 389.85Z" />
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M73.6,268.68v82.06c0,26 -11.8,38.44 -37.12,39.09h-0.12a4.51,4.51 0,0 1,-4.51 -4.51V383a4.51,4.51 0,0 1,4.48 -4.5c18.49,-0.15 26,-8.23 26,-27.9v-2.37A32.34,32.34 0,0 1,59 351.46c-6.39,5.5 -14.5,8.29 -24.07,8.29C12.09,359.75 0,347.34 0,323.86V268.68a4.52,4.52 0,0 1,4.51 -4.51H6.73a4.52,4.52 0,0 1,4.5 4.51v55c0,7.6 1.82,14.22 5,18.18 3.57,4.56 9.17,6.49 18.75,6.49 10.13,0 17.32,-3.76 22,-11.5 3.61,-5.92 5.43,-13.66 5.43,-23V268.68a4.52,4.52 0,0 1,4.51 -4.51h2.22A4.52,4.52 0,0 1,73.6 268.68Z" />
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M163.27,268.68v73c0,14.5 -2.24,25.24 -6.84,32.82 -5.92,10.15 -16.2,15.32 -30.53,15.32s-24.62,-5.23 -30.58,-15.57c-4.56,-7.64 -6.79,-18.42 -6.79,-32.92V268.68A4.51,4.51 0,0 1,93 264.17h2.28a4.51,4.51 0,0 1,4.51 4.51v72.5c0,33.53 14.88,37.4 26.07,37.4 12.14,0 26.08,-4.17 26.08,-36.71V268.68a4.51,4.51 0,0 1,4.51 -4.51h2.27A4.51,4.51 0,0 1,163.27 268.68Z" />
<path
android:fillColor="#C6C6C6"
android:pathData="M181.2,42.83V214.17a85.67,85.67 0,0 0,0 -171.34M197.93,61.6a69,69 0,0 1,0 133.8V61.6" />
<path
android:fillColor="#FFDC00"
android:pathData="M159.78,0a85.67,85.67 0,1 0,0 171.33ZM143.05,18.77v133.8A69,69 0,0 1,111 36.92a68.47,68.47 0,0 1,32 -18.15" />
</vector>

View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2023 yuzu Emulator Project
SPDX-License-Identifier: GPL-3.0-or-later
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false"/>
<uses-feature
android:name="android.hardware.gamepad"
android:required="false"/>
<uses-feature
android:name="android.hardware.vulkan.version"
android:version="0x401000"
android:required="true" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name="org.yuzu.yuzu_emu.YuzuApplication"
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:allowBackup="true"
android:hasFragileUserData="true"
android:supportsRtl="true"
android:isGame="true"
android:banner="@drawable/ic_launcher"
android:extractNativeLibs="true"
android:fullBackupContent="@xml/data_extraction_rules"
android:dataExtractionRules="@xml/data_extraction_rules_api_31"
android:enableOnBackInvokedCallback="true">
<activity
android:name="org.yuzu.yuzu_emu.ui.main.MainActivity"
android:exported="true"
android:theme="@style/Theme.Yuzu.Splash.Main">
<!-- This intentfilter marks this Activity as the one that gets launched from Home screen. -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name="org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity"
android:theme="@style/Theme.Yuzu.Main"
android:label="@string/preferences_settings"/>
<activity
android:name="org.yuzu.yuzu_emu.activities.EmulationActivity"
android:theme="@style/Theme.Yuzu.Main"
android:launchMode="singleTop"
android:screenOrientation="userLandscape"
android:exported="true">
<intent-filter>
<action android:name="android.nfc.action.TECH_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/octet-stream" />
</intent-filter>
<meta-data
android:name="android.nfc.action.TECH_DISCOVERED"
android:resource="@xml/nfc_tech_filter" />
</activity>
<service android:name="org.yuzu.yuzu_emu.utils.ForegroundService"/>
<provider
android:name=".features.DocumentProvider"
android:authorities="${applicationId}.user"
android:grantUriPermissions="true"
android:exported="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,508 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.Surface
import android.view.View
import android.widget.TextView
import androidx.annotation.Keep
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.YuzuApplication.Companion.appContext
import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.utils.DocumentsTree.Companion.isNativePath
import org.yuzu.yuzu_emu.utils.FileUtil.getFileSize
import org.yuzu.yuzu_emu.utils.FileUtil.openContentUri
import org.yuzu.yuzu_emu.utils.Log.error
import org.yuzu.yuzu_emu.utils.Log.verbose
import org.yuzu.yuzu_emu.utils.Log.warning
import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable
import java.lang.ref.WeakReference
/**
* Class which contains methods that interact
* with the native side of the Yuzu code.
*/
object NativeLibrary {
/**
* Default controller id for each device
*/
const val Player1Device = 0
const val Player2Device = 1
const val Player3Device = 2
const val Player4Device = 3
const val Player5Device = 4
const val Player6Device = 5
const val Player7Device = 6
const val Player8Device = 7
const val ConsoleDevice = 8
/**
* Controller type for each device
*/
const val ProController = 3
const val Handheld = 4
const val JoyconDual = 5
const val JoyconLeft = 6
const val JoyconRight = 7
const val GameCube = 8
const val Pokeball = 9
const val NES = 10
const val SNES = 11
const val N64 = 12
const val SegaGenesis = 13
@JvmField
var sEmulationActivity = WeakReference<EmulationActivity?>(null)
init {
try {
System.loadLibrary("yuzu-android")
} catch (ex: UnsatisfiedLinkError) {
error("[NativeLibrary] $ex")
}
}
@Keep
@JvmStatic
fun openContentUri(path: String?, openmode: String?): Int {
return if (isNativePath(path!!)) {
YuzuApplication.documentsTree!!.openContentUri(path, openmode)
} else openContentUri(appContext, path, openmode)
}
@Keep
@JvmStatic
fun getSize(path: String?): Long {
return if (isNativePath(path!!)) {
YuzuApplication.documentsTree!!.getFileSize(path)
} else getFileSize(appContext, path)
}
/**
* Returns true if pro controller isn't available and handheld is
*/
external fun isHandheldOnly(): Boolean
/**
* Changes controller type for a specific device.
*
* @param Device The input descriptor of the gamepad.
* @param Type The NpadStyleIndex of the gamepad.
*/
external fun setDeviceType(Device: Int, Type: Int): Boolean
/**
* Handles event when a gamepad is connected.
*
* @param Device The input descriptor of the gamepad.
*/
external fun onGamePadConnectEvent(Device: Int): Boolean
/**
* Handles event when a gamepad is disconnected.
*
* @param Device The input descriptor of the gamepad.
*/
external fun onGamePadDisconnectEvent(Device: Int): Boolean
/**
* Handles button press events for a gamepad.
*
* @param Device The input descriptor of the gamepad.
* @param Button Key code identifying which button was pressed.
* @param Action Mask identifying which action is happening (button pressed down, or button released).
* @return If we handled the button press.
*/
external fun onGamePadButtonEvent(Device: Int, Button: Int, Action: Int): Boolean
/**
* Handles joystick movement events.
*
* @param Device The device ID of the gamepad.
* @param Axis The axis ID
* @param x_axis The value of the x-axis represented by the given ID.
* @param y_axis The value of the y-axis represented by the given ID.
*/
external fun onGamePadJoystickEvent(
Device: Int,
Axis: Int,
x_axis: Float,
y_axis: Float
): Boolean
/**
* Handles motion events.
*
* @param delta_timestamp The finger id corresponding to this event
* @param gyro_x,gyro_y,gyro_z The value of the accelerometer sensor.
* @param accel_x,accel_y,accel_z The value of the y-axis
*/
external fun onGamePadMotionEvent(
Device: Int,
delta_timestamp: Long,
gyro_x: Float,
gyro_y: Float,
gyro_z: Float,
accel_x: Float,
accel_y: Float,
accel_z: Float
): Boolean
/**
* Signals and load a nfc tag
*
* @param data Byte array containing all the data from a nfc tag
*/
external fun onReadNfcTag(data: ByteArray?): Boolean
/**
* Removes current loaded nfc tag
*/
external fun onRemoveNfcTag(): Boolean
/**
* Handles touch press events.
*
* @param finger_id The finger id corresponding to this event
* @param x_axis The value of the x-axis.
* @param y_axis The value of the y-axis.
*/
external fun onTouchPressed(finger_id: Int, x_axis: Float, y_axis: Float)
/**
* Handles touch movement.
*
* @param x_axis The value of the instantaneous x-axis.
* @param y_axis The value of the instantaneous y-axis.
*/
external fun onTouchMoved(finger_id: Int, x_axis: Float, y_axis: Float)
/**
* Handles touch release events.
*
* @param finger_id The finger id corresponding to this event
*/
external fun onTouchReleased(finger_id: Int)
external fun reloadSettings()
external fun getUserSetting(gameID: String?, Section: String?, Key: String?): String?
external fun setUserSetting(gameID: String?, Section: String?, Key: String?, Value: String?)
external fun initGameIni(gameID: String?)
/**
* Gets the embedded icon within the given ROM.
*
* @param filename the file path to the ROM.
* @return a byte array containing the JPEG data for the icon.
*/
external fun getIcon(filename: String): ByteArray
/**
* Gets the embedded title of the given ISO/ROM.
*
* @param filename The file path to the ISO/ROM.
* @return the embedded title of the ISO/ROM.
*/
external fun getTitle(filename: String): String
external fun getDescription(filename: String): String
external fun getGameId(filename: String): String
external fun getRegions(filename: String): String
external fun getCompany(filename: String): String
external fun setAppDirectory(directory: String)
external fun initializeGpuDriver(
hookLibDir: String?,
customDriverDir: String?,
customDriverName: String?,
fileRedirectDir: String?
)
external fun reloadKeys(): Boolean
external fun initializeEmulation()
external fun defaultCPUCore(): Int
/**
* Begins emulation.
*/
external fun run(path: String?)
/**
* Begins emulation from the specified savestate.
*/
external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean)
// Surface Handling
external fun surfaceChanged(surf: Surface?)
external fun surfaceDestroyed()
/**
* Unpauses emulation from a paused state.
*/
external fun unPauseEmulation()
/**
* Pauses emulation.
*/
external fun pauseEmulation()
/**
* Stops emulation.
*/
external fun stopEmulation()
/**
* Resets the in-memory ROM metadata cache.
*/
external fun resetRomMetadata()
/**
* Returns true if emulation is running (or is paused).
*/
external fun isRunning(): Boolean
/**
* Returns the performance stats for the current game
*/
external fun getPerfStats(): DoubleArray
/**
* Notifies the core emulation that the orientation has changed.
*/
external fun notifyOrientationChange(layout_option: Int, rotation: Int)
enum class CoreError {
ErrorSystemFiles,
ErrorSavestate,
ErrorUnknown
}
private var coreErrorAlertResult = false
private val coreErrorAlertLock = Object()
class CoreErrorDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val title = requireArguments().serializable<String>("title")
val message = requireArguments().serializable<String>("message")
return MaterialAlertDialogBuilder(requireActivity())
.setTitle(title)
.setMessage(message)
.setPositiveButton(R.string.continue_button, null)
.setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int ->
coreErrorAlertResult = false
synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() }
}
.create()
}
override fun onDismiss(dialog: DialogInterface) {
coreErrorAlertResult = true
synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() }
}
companion object {
fun newInstance(title: String?, message: String?): CoreErrorDialogFragment {
val frag = CoreErrorDialogFragment()
val args = Bundle()
args.putString("title", title)
args.putString("message", message)
frag.arguments = args
return frag
}
}
}
private fun onCoreErrorImpl(title: String, message: String) {
val emulationActivity = sEmulationActivity.get()
if (emulationActivity == null) {
error("[NativeLibrary] EmulationActivity not present")
return
}
val fragment = CoreErrorDialogFragment.newInstance(title, message)
fragment.show(emulationActivity.supportFragmentManager, "coreError")
}
/**
* Handles a core error.
*
* @return true: continue; false: abort
*/
fun onCoreError(error: CoreError?, details: String): Boolean {
val emulationActivity = sEmulationActivity.get()
if (emulationActivity == null) {
error("[NativeLibrary] EmulationActivity not present")
return false
}
val title: String
val message: String
when (error) {
CoreError.ErrorSystemFiles -> {
title = emulationActivity.getString(R.string.system_archive_not_found)
message = emulationActivity.getString(
R.string.system_archive_not_found_message,
details.ifEmpty { emulationActivity.getString(R.string.system_archive_general) }
)
}
CoreError.ErrorSavestate -> {
title = emulationActivity.getString(R.string.save_load_error)
message = details
}
CoreError.ErrorUnknown -> {
title = emulationActivity.getString(R.string.fatal_error)
message = emulationActivity.getString(R.string.fatal_error_message)
}
else -> {
return true
}
}
// Show the AlertDialog on the main thread.
emulationActivity.runOnUiThread(Runnable { onCoreErrorImpl(title, message) })
// Wait for the lock to notify that it is complete.
synchronized(coreErrorAlertLock) { coreErrorAlertLock.wait() }
return coreErrorAlertResult
}
@Keep
@JvmStatic
fun exitEmulationActivity(resultCode: Int) {
val Success = 0
val ErrorNotInitialized = 1
val ErrorGetLoader = 2
val ErrorSystemFiles = 3
val ErrorSharedFont = 4
val ErrorVideoCore = 5
val ErrorUnknown = 6
val ErrorLoader = 7
val captionId: Int
var descriptionId: Int
when (resultCode) {
ErrorVideoCore -> {
captionId = R.string.loader_error_video_core
descriptionId = R.string.loader_error_video_core_description
}
else -> {
captionId = R.string.loader_error_encrypted
descriptionId = R.string.loader_error_encrypted_roms_description
if (!reloadKeys()) {
descriptionId = R.string.loader_error_encrypted_keys_description
}
}
}
val emulationActivity = sEmulationActivity.get()
if (emulationActivity == null) {
warning("[NativeLibrary] EmulationActivity is null, can't exit.")
return
}
val builder = MaterialAlertDialogBuilder(emulationActivity)
.setTitle(captionId)
.setMessage(
Html.fromHtml(
emulationActivity.getString(descriptionId),
Html.FROM_HTML_MODE_LEGACY
)
)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> emulationActivity.finish() }
.setOnDismissListener { emulationActivity.finish() }
emulationActivity.runOnUiThread {
val alert = builder.create()
alert.show()
(alert.findViewById<View>(android.R.id.message) as TextView).movementMethod =
LinkMovementMethod.getInstance()
}
}
fun setEmulationActivity(emulationActivity: EmulationActivity?) {
verbose("[NativeLibrary] Registering EmulationActivity.")
sEmulationActivity = WeakReference(emulationActivity)
}
fun clearEmulationActivity() {
verbose("[NativeLibrary] Unregistering EmulationActivity.")
sEmulationActivity.clear()
}
/**
* Logs the Yuzu version, Android version and, CPU.
*/
external fun logDeviceInfo()
/**
* Submits inline keyboard text. Called on input for buttons that result text.
* @param text Text to submit to the inline software keyboard implementation.
*/
external fun submitInlineKeyboardText(text: String?)
/**
* Submits inline keyboard input. Used to indicate keys pressed that are not text.
* @param key_code Android Key Code associated with the keyboard input.
*/
external fun submitInlineKeyboardInput(key_code: Int)
/**
* Button type for use in onTouchEvent
*/
object ButtonType {
const val BUTTON_A = 0
const val BUTTON_B = 1
const val BUTTON_X = 2
const val BUTTON_Y = 3
const val STICK_L = 4
const val STICK_R = 5
const val TRIGGER_L = 6
const val TRIGGER_R = 7
const val TRIGGER_ZL = 8
const val TRIGGER_ZR = 9
const val BUTTON_PLUS = 10
const val BUTTON_MINUS = 11
const val DPAD_LEFT = 12
const val DPAD_UP = 13
const val DPAD_RIGHT = 14
const val DPAD_DOWN = 15
const val BUTTON_SL = 16
const val BUTTON_SR = 17
const val BUTTON_HOME = 18
const val BUTTON_CAPTURE = 19
}
/**
* Stick type for use in onTouchEvent
*/
object StickType {
const val STICK_L = 0
const val STICK_R = 1
}
/**
* Button states
*/
object ButtonState {
const val RELEASED = 0
const val PRESSED = 1
}
}

View File

@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.DocumentsTree
import org.yuzu.yuzu_emu.utils.GpuDriverHelper
import java.io.File
fun Context.getPublicFilesDir() : File = getExternalFilesDir(null) ?: filesDir
class YuzuApplication : Application() {
private fun createNotificationChannels() {
val emulationChannel = NotificationChannel(
getString(R.string.emulation_notification_channel_id),
getString(R.string.emulation_notification_channel_name),
NotificationManager.IMPORTANCE_LOW
)
emulationChannel.description = getString(R.string.emulation_notification_channel_description)
emulationChannel.setSound(null, null)
emulationChannel.vibrationPattern = null
val noticeChannel = NotificationChannel(
getString(R.string.notice_notification_channel_id),
getString(R.string.notice_notification_channel_name),
NotificationManager.IMPORTANCE_HIGH
)
noticeChannel.description = getString(R.string.notice_notification_channel_description)
noticeChannel.setSound(null, null)
// Register the channel with the system; you can't change the importance
// or other notification behaviors after this
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(emulationChannel)
notificationManager.createNotificationChannel(noticeChannel)
}
override fun onCreate() {
super.onCreate()
application = this
documentsTree = DocumentsTree()
DirectoryInitialization.start(applicationContext)
GpuDriverHelper.initializeDriverParameters(applicationContext)
NativeLibrary.logDeviceInfo()
createNotificationChannels();
}
companion object {
var documentsTree: DocumentsTree? = null
lateinit var application: YuzuApplication
val appContext: Context
get() = application.applicationContext
}
}

View File

@@ -0,0 +1,296 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.activities
import android.app.Activity
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.Rect
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.Surface
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider.OnChangeListener
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.DialogSliderBinding
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.fragments.EmulationFragment
import org.yuzu.yuzu_emu.model.Game
import org.yuzu.yuzu_emu.utils.ControllerMappingHelper
import org.yuzu.yuzu_emu.utils.ForegroundService
import org.yuzu.yuzu_emu.utils.InputHandler
import org.yuzu.yuzu_emu.utils.NfcReader
import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
import org.yuzu.yuzu_emu.utils.ThemeHelper
import kotlin.math.roundToInt
class EmulationActivity : AppCompatActivity(), SensorEventListener {
private var controllerMappingHelper: ControllerMappingHelper? = null
var isActivityRecreated = false
private var menuVisible = false
private var emulationFragment: EmulationFragment? = null
private lateinit var nfcReader: NfcReader
private lateinit var inputHandler: InputHandler
private val gyro = FloatArray(3)
private val accel = FloatArray(3)
private var motionTimestamp: Long = 0
private var flipMotionOrientation: Boolean = false
private lateinit var game: Game
override fun onDestroy() {
stopForegroundService(this)
super.onDestroy()
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.setTheme(this)
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
// Get params we were passed
game = intent.parcelable(EXTRA_SELECTED_GAME)!!
isActivityRecreated = false
} else {
isActivityRecreated = true
restoreState(savedInstanceState)
}
controllerMappingHelper = ControllerMappingHelper()
// Set these options now so that the SurfaceView the game renders into is the right size.
enableFullscreenImmersive()
setContentView(R.layout.activity_emulation)
window.decorView.setBackgroundColor(getColor(android.R.color.black))
// Find or create the EmulationFragment
emulationFragment =
supportFragmentManager.findFragmentById(R.id.frame_emulation_fragment) as EmulationFragment?
if (emulationFragment == null) {
emulationFragment = EmulationFragment.newInstance(game)
supportFragmentManager.beginTransaction()
.add(R.id.frame_emulation_fragment, emulationFragment!!)
.commit()
}
title = game.title
nfcReader = NfcReader(this)
nfcReader.initialize()
inputHandler = InputHandler()
inputHandler.initialize()
// Start a foreground service to prevent the app from getting killed in the background
val startIntent = Intent(this, ForegroundService::class.java)
startForegroundService(startIntent)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) {
if (keyCode == KeyEvent.KEYCODE_ENTER) {
// Special case, we do not support multiline input, dismiss the keyboard.
val overlayView: View =
this.findViewById(R.id.surface_input_overlay)
val im =
overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
im.hideSoftInputFromWindow(overlayView.windowToken, 0)
} else {
val textChar = event.unicodeChar
if (textChar == 0) {
// No text, button input.
NativeLibrary.submitInlineKeyboardInput(keyCode)
} else {
// Text submitted.
NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString())
}
}
}
return super.onKeyDown(keyCode, event)
}
override fun onResume() {
super.onResume()
nfcReader.startScanning()
startMotionSensorListener()
}
override fun onPause() {
super.onPause()
nfcReader.stopScanning()
stopMotionSensorListener()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
nfcReader.onNewIntent(intent)
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putParcelable(EXTRA_SELECTED_GAME, game)
super.onSaveInstanceState(outState)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
) {
return super.dispatchKeyEvent(event)
}
return inputHandler.dispatchKeyEvent(event)
}
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
) {
return super.dispatchGenericMotionEvent(event)
}
// Don't attempt to do anything if we are disconnecting a device.
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
return true
}
return inputHandler.dispatchGenericMotionEvent(event)
}
override fun onSensorChanged(event: SensorEvent) {
val rotation = this.display?.rotation
if (rotation == Surface.ROTATION_90) {
flipMotionOrientation = true
}
if (rotation == Surface.ROTATION_270) {
flipMotionOrientation = false
}
if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
if (flipMotionOrientation) {
accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH
accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH
} else {
accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH
accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH
}
accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH
}
if (event.sensor.type == Sensor.TYPE_GYROSCOPE) {
// Investigate why sensor value is off by 6x
if (flipMotionOrientation) {
gyro[0] = -event.values[1] / 6.0f
gyro[1] = event.values[0] / 6.0f
} else {
gyro[0] = event.values[1] / 6.0f
gyro[1] = -event.values[0] / 6.0f
}
gyro[2] = event.values[2] / 6.0f
}
// Only update state on accelerometer data
if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) {
return
}
val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000
motionTimestamp = event.timestamp
NativeLibrary.onGamePadMotionEvent(
NativeLibrary.Player1Device,
deltaTimestamp,
gyro[0],
gyro[1],
gyro[2],
accel[0],
accel[1],
accel[2]
)
NativeLibrary.onGamePadMotionEvent(
NativeLibrary.ConsoleDevice,
deltaTimestamp,
gyro[0],
gyro[1],
gyro[2],
accel[0],
accel[1],
accel[2]
)
}
override fun onAccuracyChanged(sensor: Sensor, i: Int) {}
private fun restoreState(savedInstanceState: Bundle) {
game = savedInstanceState.parcelable(EXTRA_SELECTED_GAME)!!
}
private fun enableFullscreenImmersive() {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
// It would be nice to use IMMERSIVE_STICKY, but that doesn't show the toolbar.
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE
}
private fun startMotionSensorListener() {
val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME)
sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_GAME)
}
private fun stopMotionSensorListener() {
val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
sensorManager.unregisterListener(this, gyroSensor)
sensorManager.unregisterListener(this, accelSensor)
}
companion object {
const val EXTRA_SELECTED_GAME = "SelectedGame"
fun launch(activity: AppCompatActivity, game: Game) {
val launcher = Intent(activity, EmulationActivity::class.java)
launcher.putExtra(EXTRA_SELECTED_GAME, game)
activity.startActivity(launcher)
}
fun stopForegroundService(activity: Activity) {
val startIntent = Intent(activity, ForegroundService::class.java)
startIntent.action = ForegroundService.ACTION_STOP
activity.startForegroundService(startIntent)
}
private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean {
if (view == null) {
return true
}
val viewBounds = Rect()
view.getGlobalVisibleRect(viewBounds)
return !viewBounds.contains(x.roundToInt(), y.roundToInt())
}
}
}

View File

@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.adapters
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import kotlinx.coroutines.launch
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.databinding.CardGameBinding
import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.model.Game
import org.yuzu.yuzu_emu.adapters.GameAdapter.GameViewHolder
import org.yuzu.yuzu_emu.model.GamesViewModel
class GameAdapter(private val activity: AppCompatActivity) :
ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
View.OnClickListener {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
// Create a new view.
val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.cardGame.setOnClickListener(this)
// Use that view to create a ViewHolder.
return GameViewHolder(binding)
}
override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
holder.bind(currentList[position])
}
override fun getItemCount(): Int = currentList.size
/**
* Launches the game that was clicked on.
*
* @param view The card representing the game the user wants to play.
*/
override fun onClick(view: View) {
val holder = view.tag as GameViewHolder
val gameExists = DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(holder.game.path))?.exists() == true
if (!gameExists) {
Toast.makeText(
YuzuApplication.appContext,
R.string.loader_error_file_not_found,
Toast.LENGTH_LONG
).show()
ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
return
}
val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
preferences.edit()
.putLong(
holder.game.keyLastPlayedTime,
System.currentTimeMillis()
)
.apply()
EmulationActivity.launch(activity, holder.game)
}
inner class GameViewHolder(val binding: CardGameBinding) :
RecyclerView.ViewHolder(binding.root) {
lateinit var game: Game
init {
binding.cardGame.tag = this
}
fun bind(game: Game) {
this.game = game
binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
activity.lifecycleScope.launch {
val bitmap = decodeGameIcon(game.path)
binding.imageGameScreen.load(bitmap) {
error(R.drawable.default_icon)
}
}
binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ")
binding.textGameTitle.postDelayed(
{
binding.textGameTitle.ellipsize = TextUtils.TruncateAt.MARQUEE
binding.textGameTitle.isSelected = true
},
3000
)
}
}
private class DiffCallback : DiffUtil.ItemCallback<Game>() {
override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean {
return oldItem.gameId == newItem.gameId
}
override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean {
return oldItem == newItem
}
}
private fun decodeGameIcon(uri: String): Bitmap? {
val data = NativeLibrary.getIcon(uri)
return BitmapFactory.decodeByteArray(
data,
0,
data.size,
BitmapFactory.Options()
)
}
}

View File

@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.adapters
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding
import org.yuzu.yuzu_emu.model.HomeSetting
class HomeSettingAdapter(private val activity: AppCompatActivity, var options: List<HomeSetting>) :
RecyclerView.Adapter<HomeSettingAdapter.HomeOptionViewHolder>(),
View.OnClickListener {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder {
val binding =
CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
binding.root.setOnClickListener(this)
return HomeOptionViewHolder(binding)
}
override fun getItemCount(): Int {
return options.size
}
override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) {
holder.bind(options[position])
}
override fun onClick(view: View) {
val holder = view.tag as HomeOptionViewHolder
holder.option.onClick.invoke()
}
inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) :
RecyclerView.ViewHolder(binding.root) {
lateinit var option: HomeSetting
init {
itemView.tag = this
}
fun bind(option: HomeSetting) {
this.option = option
binding.optionTitle.text = activity.resources.getString(option.titleId)
binding.optionDescription.text = activity.resources.getString(option.descriptionId)
binding.optionIcon.setImageDrawable(
ResourcesCompat.getDrawable(
activity.resources,
option.iconId,
activity.theme
)
)
when (option.titleId) {
R.string.get_early_access -> binding.optionLayout.background =
ContextCompat.getDrawable(
binding.optionCard.context,
R.drawable.premium_background
)
}
}
}
}

View File

@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.adapters
import android.text.Html
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import org.yuzu.yuzu_emu.databinding.PageSetupBinding
import org.yuzu.yuzu_emu.model.SetupPage
class SetupAdapter(val activity: AppCompatActivity, val pages: List<SetupPage>) :
RecyclerView.Adapter<SetupAdapter.SetupPageViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder {
val binding = PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return SetupPageViewHolder(binding)
}
override fun getItemCount(): Int = pages.size
override fun onBindViewHolder(holder: SetupPageViewHolder, position: Int) =
holder.bind(pages[position])
inner class SetupPageViewHolder(val binding: PageSetupBinding) :
RecyclerView.ViewHolder(binding.root) {
lateinit var page: SetupPage
init {
itemView.tag = this
}
fun bind(page: SetupPage) {
this.page = page
binding.icon.setImageDrawable(
ResourcesCompat.getDrawable(
activity.resources,
page.iconId,
activity.theme
)
)
binding.textTitle.text = activity.resources.getString(page.titleId)
binding.textDescription.text =
Html.fromHtml(activity.resources.getString(page.descriptionId), 0)
binding.buttonAction.apply {
text = activity.resources.getString(page.buttonTextId)
if (page.buttonIconId != 0) {
icon = ResourcesCompat.getDrawable(
activity.resources,
page.buttonIconId,
activity.theme
)
}
iconGravity =
if (page.leftAlignedIcon) {
MaterialButton.ICON_GRAVITY_START
} else {
MaterialButton.ICON_GRAVITY_END
}
setOnClickListener {
page.buttonAction.invoke()
}
}
}
}
}

View File

@@ -0,0 +1,121 @@
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.applets.keyboard
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import android.view.View
import android.view.WindowInsets
import android.view.inputmethod.InputMethodManager
import androidx.annotation.Keep
import androidx.core.view.ViewCompat
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.applets.keyboard.ui.KeyboardDialogFragment
import java.io.Serializable
@Keep
object SoftwareKeyboard {
lateinit var data: KeyboardData
val dataLock = Object()
private fun executeNormalImpl(config: KeyboardConfig) {
val emulationActivity = NativeLibrary.sEmulationActivity.get()
data = KeyboardData(SwkbdResult.Cancel.ordinal, "")
val fragment = KeyboardDialogFragment.newInstance(config)
fragment.show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG)
}
private fun executeInlineImpl(config: KeyboardConfig) {
val emulationActivity = NativeLibrary.sEmulationActivity.get()
val overlayView = emulationActivity!!.findViewById<View>(R.id.surface_input_overlay)
val im =
overlayView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
im.showSoftInput(overlayView, InputMethodManager.SHOW_FORCED)
// There isn't a good way to know that the IMM is dismissed, so poll every 500ms to submit inline keyboard result.
val handler = Handler(Looper.myLooper()!!)
val delayMs = 500
handler.postDelayed(object : Runnable {
override fun run() {
val insets = ViewCompat.getRootWindowInsets(overlayView)
val isKeyboardVisible = insets!!.isVisible(WindowInsets.Type.ime())
if (isKeyboardVisible) {
handler.postDelayed(this, delayMs.toLong())
return
}
// No longer visible, submit the result.
NativeLibrary.submitInlineKeyboardInput(KeyEvent.KEYCODE_ENTER)
}
}, delayMs.toLong())
}
@JvmStatic
fun executeNormal(config: KeyboardConfig): KeyboardData {
NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeNormalImpl(config) }
synchronized(dataLock) {
dataLock.wait()
}
return data
}
@JvmStatic
fun executeInline(config: KeyboardConfig) {
NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeInlineImpl(config) }
}
// Corresponds to Service::AM::Applets::SwkbdType
enum class SwkbdType {
Normal,
NumberPad,
Qwerty,
Unknown3,
Latin,
SimplifiedChinese,
TraditionalChinese,
Korean
}
// Corresponds to Service::AM::Applets::SwkbdPasswordMode
enum class SwkbdPasswordMode {
Disabled,
Enabled
}
// Corresponds to Service::AM::Applets::SwkbdResult
enum class SwkbdResult {
Ok,
Cancel
}
@Keep
data class KeyboardConfig(
var ok_text: String? = null,
var header_text: String? = null,
var sub_text: String? = null,
var guide_text: String? = null,
var initial_text: String? = null,
var left_optional_symbol_key: Short = 0,
var right_optional_symbol_key: Short = 0,
var max_text_length: Int = 0,
var min_text_length: Int = 0,
var initial_cursor_position: Int = 0,
var type: Int = 0,
var password_mode: Int = 0,
var text_draw_type: Int = 0,
var key_disable_flags: Int = 0,
var use_blur_background: Boolean = false,
var enable_backspace_button: Boolean = false,
var enable_return_button: Boolean = false,
var disable_cancel_button: Boolean = false
) : Serializable
// Corresponds to Frontend::KeyboardData
@Keep
data class KeyboardData(var result: Int, var text: String)
}

View File

@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.applets.keyboard.ui
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.text.InputFilter
import android.text.InputType
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard
import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard.KeyboardConfig
import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding
import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable
class KeyboardDialogFragment : DialogFragment() {
private lateinit var binding: DialogEditTextBinding
private lateinit var config: KeyboardConfig
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
binding = DialogEditTextBinding.inflate(layoutInflater)
config = requireArguments().serializable(CONFIG)!!
// Set up the input
binding.editText.hint = config.initial_text
binding.editText.isSingleLine = !config.enable_return_button
binding.editText.filters =
arrayOf<InputFilter>(InputFilter.LengthFilter(config.max_text_length))
// Handle input type
var inputType: Int
when (config.type) {
SoftwareKeyboard.SwkbdType.Normal.ordinal,
SoftwareKeyboard.SwkbdType.Qwerty.ordinal,
SoftwareKeyboard.SwkbdType.Unknown3.ordinal,
SoftwareKeyboard.SwkbdType.Latin.ordinal,
SoftwareKeyboard.SwkbdType.SimplifiedChinese.ordinal,
SoftwareKeyboard.SwkbdType.TraditionalChinese.ordinal,
SoftwareKeyboard.SwkbdType.Korean.ordinal -> {
inputType = InputType.TYPE_CLASS_TEXT
if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) {
inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
}
SoftwareKeyboard.SwkbdType.NumberPad.ordinal -> {
inputType = InputType.TYPE_CLASS_NUMBER
if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) {
inputType = inputType or InputType.TYPE_NUMBER_VARIATION_PASSWORD
}
}
else -> {
inputType = InputType.TYPE_CLASS_TEXT
if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) {
inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
}
}
binding.editText.inputType = inputType
val headerText =
config.header_text!!.ifEmpty { resources.getString(R.string.software_keyboard) }
val okText =
config.ok_text!!.ifEmpty { resources.getString(android.R.string.ok) }
return MaterialAlertDialogBuilder(requireContext())
.setTitle(headerText)
.setView(binding.root)
.setPositiveButton(okText) { _, _ ->
SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Ok.ordinal
SoftwareKeyboard.data.text = binding.editText.text.toString()
}
.setNegativeButton(resources.getString(android.R.string.cancel)) { _, _ ->
SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Cancel.ordinal
}
.create()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
synchronized(SoftwareKeyboard.dataLock) {
SoftwareKeyboard.dataLock.notifyAll()
}
}
companion object {
const val TAG = "KeyboardDialogFragment"
const val CONFIG = "keyboard_config"
fun newInstance(config: KeyboardConfig?): KeyboardDialogFragment {
val frag = KeyboardDialogFragment()
val args = Bundle()
args.putSerializable(CONFIG, config)
frag.arguments = args
return frag
}
}
}

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.disk_shader_cache
import androidx.annotation.Keep
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.disk_shader_cache.ui.ShaderProgressDialogFragment
@Keep
object DiskShaderCacheProgress {
val finishLock = Object()
private lateinit var fragment: ShaderProgressDialogFragment
private fun prepareDialog() {
val emulationActivity = NativeLibrary.sEmulationActivity.get()!!
emulationActivity.runOnUiThread {
fragment = ShaderProgressDialogFragment.newInstance(
emulationActivity.getString(R.string.loading),
emulationActivity.getString(R.string.preparing_shaders)
)
fragment.show(emulationActivity.supportFragmentManager, ShaderProgressDialogFragment.TAG)
}
synchronized(finishLock) { finishLock.wait() }
}
@JvmStatic
fun loadProgress(stage: Int, progress: Int, max: Int) {
val emulationActivity = NativeLibrary.sEmulationActivity.get()
?: error("[DiskShaderCacheProgress] EmulationActivity not present")
when (LoadCallbackStage.values()[stage]) {
LoadCallbackStage.Prepare -> prepareDialog()
LoadCallbackStage.Build -> fragment.onUpdateProgress(
emulationActivity.getString(R.string.building_shaders),
progress,
max
)
LoadCallbackStage.Complete -> fragment.dismiss()
}
}
// Equivalent to VideoCore::LoadCallbackStage
enum class LoadCallbackStage {
Prepare, Build, Complete
}
}

View File

@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.disk_shader_cache
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class ShaderProgressViewModel : ViewModel() {
private val _progress = MutableLiveData(0)
val progress: LiveData<Int> get() = _progress
private val _max = MutableLiveData(0)
val max: LiveData<Int> get() = _max
private val _message = MutableLiveData("")
val message: LiveData<String> get() = _message
fun setProgress(progress: Int) {
_progress.postValue(progress)
}
fun setMax(max: Int) {
_max.postValue(max)
}
fun setMessage(msg: String) {
_message.postValue(msg)
}
}

View File

@@ -0,0 +1,101 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.disk_shader_cache.ui
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
import org.yuzu.yuzu_emu.disk_shader_cache.DiskShaderCacheProgress
import org.yuzu.yuzu_emu.disk_shader_cache.ShaderProgressViewModel
class ShaderProgressDialogFragment : DialogFragment() {
private var _binding: DialogProgressBarBinding? = null
private val binding get() = _binding!!
private lateinit var alertDialog: AlertDialog
private lateinit var shaderProgressViewModel: ShaderProgressViewModel
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = DialogProgressBarBinding.inflate(layoutInflater)
shaderProgressViewModel =
ViewModelProvider(requireActivity())[ShaderProgressViewModel::class.java]
val title = requireArguments().getString(TITLE)
val message = requireArguments().getString(MESSAGE)
isCancelable = false
alertDialog = MaterialAlertDialogBuilder(requireActivity())
.setView(binding.root)
.setTitle(title)
.setMessage(message)
.create()
return alertDialog
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
shaderProgressViewModel.progress.observe(viewLifecycleOwner) { progress ->
binding.progressBar.progress = progress
setUpdateText()
}
shaderProgressViewModel.max.observe(viewLifecycleOwner) { max ->
binding.progressBar.max = max
setUpdateText()
}
shaderProgressViewModel.message.observe(viewLifecycleOwner) { msg ->
alertDialog.setMessage(msg)
}
synchronized(DiskShaderCacheProgress.finishLock) { DiskShaderCacheProgress.finishLock.notifyAll() }
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
fun onUpdateProgress(msg: String, progress: Int, max: Int) {
shaderProgressViewModel.setProgress(progress)
shaderProgressViewModel.setMax(max)
shaderProgressViewModel.setMessage(msg)
}
private fun setUpdateText() {
binding.progressText.text = String.format(
"%d/%d",
shaderProgressViewModel.progress.value,
shaderProgressViewModel.max.value
)
}
companion object {
const val TAG = "ProgressDialogFragment"
const val TITLE = "title"
const val MESSAGE = "message"
fun newInstance(title: String, message: String): ShaderProgressDialogFragment {
val frag = ShaderProgressDialogFragment()
val args = Bundle()
args.putString(TITLE, title)
args.putString(MESSAGE, message)
frag.arguments = args
return frag
}
}
}

View File

@@ -0,0 +1,302 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
// SPDX-License-Identifier: MPL-2.0
// Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/)
package org.yuzu.yuzu_emu.features
import android.database.Cursor
import android.database.MatrixCursor
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsProvider
import android.webkit.MimeTypeMap
import org.yuzu.yuzu_emu.BuildConfig
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.getPublicFilesDir
import java.io.*
class DocumentProvider : DocumentsProvider() {
private val baseDirectory: File
get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath)
companion object {
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_MIME_TYPES,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
)
private val DEFAULT_DOCUMENT_PROJECTION: Array<String> = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE
)
const val AUTHORITY : String = BuildConfig.APPLICATION_ID + ".user"
const val ROOT_ID: String = "root"
}
override fun onCreate(): Boolean {
return true
}
/**
* @return The [File] that corresponds to the document ID supplied by [getDocumentId]
*/
private fun getFile(documentId: String): File {
if (documentId.startsWith(ROOT_ID)) {
val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1))
if (!file.exists()) throw FileNotFoundException("${file.absolutePath} ($documentId) not found")
return file
} else {
throw FileNotFoundException("'$documentId' is not in any known root")
}
}
/**
* @return A unique ID for the provided [File]
*/
private fun getDocumentId(file: File): String {
return "$ROOT_ID/${file.toRelativeString(baseDirectory)}"
}
override fun queryRoots(projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
cursor.newRow().apply {
add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID)
add(DocumentsContract.Root.COLUMN_SUMMARY, null)
add(
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
)
add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name))
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory))
add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*")
add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace)
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu)
}
return cursor
}
override fun queryDocument(documentId: String?, projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
return includeFile(cursor, documentId, null)
}
override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean {
return documentId?.startsWith(parentDocumentId!!) ?: false
}
/**
* @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file
*/
private fun File.resolveWithoutConflict(name: String): File {
var file = resolve(name)
if (file.exists()) {
var noConflictId =
1 // Makes sure two files don't have the same name by adding a number to the end
val extension = name.substringAfterLast('.')
val baseName = name.substringBeforeLast('.')
while (file.exists())
file = resolve("$baseName (${noConflictId++}).$extension")
}
return file
}
override fun createDocument(
parentDocumentId: String?,
mimeType: String?,
displayName: String
): String {
val parentFile = getFile(parentDocumentId!!)
val newFile = parentFile.resolveWithoutConflict(displayName)
try {
if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) {
if (!newFile.mkdir())
throw IOException("Failed to create directory")
} else {
if (!newFile.createNewFile())
throw IOException("Failed to create file")
}
} catch (e: IOException) {
throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}")
}
return getDocumentId(newFile)
}
override fun deleteDocument(documentId: String?) {
val file = getFile(documentId!!)
if (!file.delete())
throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
}
override fun removeDocument(documentId: String, parentDocumentId: String?) {
val parent = getFile(parentDocumentId!!)
val file = getFile(documentId)
if (parent == file || file.parentFile == null || file.parentFile!! == parent) {
if (!file.delete())
throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
} else {
throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
}
}
override fun renameDocument(documentId: String?, displayName: String?): String {
if (displayName == null)
throw FileNotFoundException("Couldn't rename document '$documentId' as the new name is null")
val sourceFile = getFile(documentId!!)
val sourceParentFile = sourceFile.parentFile
?: throw FileNotFoundException("Couldn't rename document '$documentId' as it has no parent")
val destFile = sourceParentFile.resolve(displayName)
try {
if (!sourceFile.renameTo(destFile))
throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'")
} catch (e: Exception) {
throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': ${e.message}")
}
return getDocumentId(destFile)
}
private fun copyDocument(
sourceDocumentId: String, sourceParentDocumentId: String,
targetParentDocumentId: String?
): String {
if (!isChildDocument(sourceParentDocumentId, sourceDocumentId))
throw FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'")
return copyDocument(sourceDocumentId, targetParentDocumentId)
}
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String {
val parent = getFile(targetParentDocumentId!!)
val oldFile = getFile(sourceDocumentId)
val newFile = parent.resolveWithoutConflict(oldFile.name)
try {
if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true)))
throw IOException("Couldn't create new file")
FileInputStream(oldFile).use { inStream ->
FileOutputStream(newFile).use { outStream ->
inStream.copyTo(outStream)
}
}
} catch (e: IOException) {
throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}")
}
return getDocumentId(newFile)
}
override fun moveDocument(
sourceDocumentId: String, sourceParentDocumentId: String?,
targetParentDocumentId: String?
): String {
try {
val newDocumentId = copyDocument(
sourceDocumentId, sourceParentDocumentId!!,
targetParentDocumentId
)
removeDocument(sourceDocumentId, sourceParentDocumentId)
return newDocumentId
} catch (e: FileNotFoundException) {
throw FileNotFoundException("Couldn't move document '$sourceDocumentId'")
}
}
private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor {
val localDocumentId = documentId ?: file?.let { getDocumentId(it) }
val localFile = file ?: getFile(documentId!!)
var flags = 0
if (localFile.isDirectory && localFile.canWrite()) {
flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE
} else if (localFile.canWrite()) {
flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME
}
cursor.newRow().apply {
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId)
add(
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
if (localFile == baseDirectory) context!!.getString(R.string.app_name) else localFile.name
)
add(DocumentsContract.Document.COLUMN_SIZE, localFile.length())
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile))
add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified())
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
if (localFile == baseDirectory)
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu)
}
return cursor
}
private fun getTypeForFile(file: File): Any {
return if (file.isDirectory)
DocumentsContract.Document.MIME_TYPE_DIR
else
getTypeForName(file.name)
}
private fun getTypeForName(name: String): Any {
val lastDot = name.lastIndexOf('.')
if (lastDot >= 0) {
val extension = name.substring(lastDot + 1)
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
if (mime != null)
return mime
}
return "application/octect-stream"
}
override fun queryChildDocuments(
parentDocumentId: String?,
projection: Array<out String>?,
sortOrder: String?
): Cursor {
var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
val parent = getFile(parentDocumentId!!)
for (file in parent.listFiles()!!)
cursor = includeFile(cursor, null, file)
return cursor
}
override fun openDocument(
documentId: String?,
mode: String?,
signal: CancellationSignal?
): ParcelFileDescriptor {
val file = documentId?.let { getFile(it) }
val accessMode = ParcelFileDescriptor.parseMode(mode)
return ParcelFileDescriptor.open(file, accessMode)
}
}

View File

@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
interface AbstractBooleanSetting : AbstractSetting {
var boolean: Boolean
}

View File

@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
interface AbstractFloatSetting : AbstractSetting {
var float: Float
}

View File

@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
interface AbstractIntSetting : AbstractSetting {
var int: Int
}

View File

@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
interface AbstractSetting {
val key: String?
val section: String?
val isRuntimeEditable: Boolean
val valueAsString: String
val defaultValue: Any
}

View File

@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
interface AbstractStringSetting : AbstractSetting {
var string: String
}

View File

@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
enum class BooleanSetting(
override val key: String,
override val section: String,
override val defaultValue: Boolean
) : AbstractBooleanSetting {
USE_CUSTOM_RTC("custom_rtc_enabled", Settings.SECTION_SYSTEM, false);
override var boolean: Boolean = defaultValue
override val valueAsString: String
get() = boolean.toString()
override val isRuntimeEditable: Boolean
get() {
for (setting in NOT_RUNTIME_EDITABLE) {
if (setting == this) {
return false
}
}
return true
}
companion object {
private val NOT_RUNTIME_EDITABLE = listOf(
USE_CUSTOM_RTC
)
fun from(key: String): BooleanSetting? =
BooleanSetting.values().firstOrNull { it.key == key }
fun clear() = BooleanSetting.values().forEach { it.boolean = it.defaultValue }
}
}

View File

@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
enum class FloatSetting(
override val key: String,
override val section: String,
override val defaultValue: Float
) : AbstractFloatSetting {
// No float settings currently exist
EMPTY_SETTING("", "", 0f);
override var float: Float = defaultValue
override val valueAsString: String
get() = float.toString()
override val isRuntimeEditable: Boolean
get() {
for (setting in NOT_RUNTIME_EDITABLE) {
if (setting == this) {
return false
}
}
return true
}
companion object {
private val NOT_RUNTIME_EDITABLE = emptyList<FloatSetting>()
fun from(key: String): FloatSetting? = FloatSetting.values().firstOrNull { it.key == key }
fun clear() = FloatSetting.values().forEach { it.float = it.defaultValue }
}
}

View File

@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
enum class IntSetting(
override val key: String,
override val section: String,
override val defaultValue: Int
) : AbstractIntSetting {
RENDERER_USE_SPEED_LIMIT(
"use_speed_limit",
Settings.SECTION_RENDERER,
1
),
USE_DOCKED_MODE(
"use_docked_mode",
Settings.SECTION_SYSTEM,
0
),
RENDERER_USE_DISK_SHADER_CACHE(
"use_disk_shader_cache",
Settings.SECTION_RENDERER,
1
),
RENDERER_FORCE_MAX_CLOCK(
"force_max_clock",
Settings.SECTION_RENDERER,
1
),
RENDERER_ASYNCHRONOUS_SHADERS(
"use_asynchronous_shaders",
Settings.SECTION_RENDERER,
0
),
RENDERER_DEBUG(
"debug",
Settings.SECTION_RENDERER,
0
),
RENDERER_SPEED_LIMIT(
"speed_limit",
Settings.SECTION_RENDERER,
100
),
CPU_ACCURACY(
"cpu_accuracy",
Settings.SECTION_CPU,
0
),
REGION_INDEX(
"region_index",
Settings.SECTION_SYSTEM,
-1
),
LANGUAGE_INDEX(
"language_index",
Settings.SECTION_SYSTEM,
1
),
RENDERER_BACKEND(
"backend",
Settings.SECTION_RENDERER,
1
),
RENDERER_ACCURACY(
"gpu_accuracy",
Settings.SECTION_RENDERER,
0
),
RENDERER_RESOLUTION(
"resolution_setup",
Settings.SECTION_RENDERER,
2
),
RENDERER_VSYNC(
"use_vsync",
Settings.SECTION_RENDERER,
0
),
RENDERER_SCALING_FILTER(
"scaling_filter",
Settings.SECTION_RENDERER,
1
),
RENDERER_ANTI_ALIASING(
"anti_aliasing",
Settings.SECTION_RENDERER,
0
),
RENDERER_ASPECT_RATIO(
"aspect_ratio",
Settings.SECTION_RENDERER,
0
),
AUDIO_VOLUME(
"volume",
Settings.SECTION_AUDIO,
100
);
override var int: Int = defaultValue
override val valueAsString: String
get() = int.toString()
override val isRuntimeEditable: Boolean
get() {
for (setting in NOT_RUNTIME_EDITABLE) {
if (setting == this) {
return false
}
}
return true
}
companion object {
private val NOT_RUNTIME_EDITABLE = listOf(
RENDERER_USE_DISK_SHADER_CACHE,
RENDERER_ASYNCHRONOUS_SHADERS,
RENDERER_DEBUG,
RENDERER_BACKEND,
RENDERER_RESOLUTION,
RENDERER_VSYNC
)
fun from(key: String): IntSetting? = IntSetting.values().firstOrNull { it.key == key }
fun clear() = IntSetting.values().forEach { it.int = it.defaultValue }
}
}

View File

@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
/**
* A semantically-related group of Settings objects. These Settings are
* internally stored as a HashMap.
*/
class SettingSection(val name: String) {
val settings = HashMap<String, AbstractSetting>()
/**
* Convenience method; inserts a value directly into the backing HashMap.
*
* @param setting The Setting to be inserted.
*/
fun putSetting(setting: AbstractSetting) {
settings[setting.key!!] = setting
}
/**
* Convenience method; gets a value directly from the backing HashMap.
*
* @param key Used to retrieve the Setting.
* @return A Setting object (you should probably cast this before using)
*/
fun getSetting(key: String): AbstractSetting? {
return settings[key]
}
fun mergeSection(settingSection: SettingSection) {
for (setting in settingSection.settings.values) {
putSetting(setting)
}
}
}

View File

@@ -0,0 +1,157 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
import android.text.TextUtils
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import java.util.*
class Settings {
private var gameId: String? = null
var isLoaded = false
/**
* A HashMap<String></String>, SettingSection> that constructs a new SettingSection instead of returning null
* when getting a key not already in the map
*/
class SettingsSectionMap : HashMap<String, SettingSection?>() {
override operator fun get(key: String): SettingSection? {
if (!super.containsKey(key)) {
val section = SettingSection(key)
super.put(key, section)
return section
}
return super.get(key)
}
}
var sections: HashMap<String, SettingSection?> = SettingsSectionMap()
fun getSection(sectionName: String): SettingSection? {
return sections[sectionName]
}
val isEmpty: Boolean
get() = sections.isEmpty()
fun loadSettings(view: SettingsActivityView) {
sections = SettingsSectionMap()
loadYuzuSettings(view)
if (!TextUtils.isEmpty(gameId)) {
loadCustomGameSettings(gameId!!, view)
}
isLoaded = true
}
private fun loadYuzuSettings(view: SettingsActivityView) {
for ((fileName) in configFileSectionsMap) {
sections.putAll(SettingsFile.readFile(fileName, view))
}
}
private fun loadCustomGameSettings(gameId: String, view: SettingsActivityView) {
// Custom game settings
mergeSections(SettingsFile.readCustomGameSettings(gameId, view))
}
private fun mergeSections(updatedSections: HashMap<String, SettingSection?>) {
for ((key, updatedSection) in updatedSections) {
if (sections.containsKey(key)) {
val originalSection = sections[key]
originalSection!!.mergeSection(updatedSection!!)
} else {
sections[key] = updatedSection
}
}
}
fun loadSettings(gameId: String, view: SettingsActivityView) {
this.gameId = gameId
loadSettings(view)
}
fun saveSettings(view: SettingsActivityView) {
if (TextUtils.isEmpty(gameId)) {
view.showToastMessage(
YuzuApplication.appContext.getString(R.string.ini_saved),
false
)
for ((fileName, sectionNames) in configFileSectionsMap) {
val iniSections = TreeMap<String, SettingSection>()
for (section in sectionNames) {
iniSections[section] = sections[section]!!
}
SettingsFile.saveFile(fileName, iniSections, view)
}
} else {
// Custom game settings
view.showToastMessage(
YuzuApplication.appContext.getString(R.string.gameid_saved, gameId),
false
)
SettingsFile.saveCustomGameSettings(gameId, sections)
}
}
companion object {
const val SECTION_GENERAL = "General"
const val SECTION_SYSTEM = "System"
const val SECTION_RENDERER = "Renderer"
const val SECTION_AUDIO = "Audio"
const val SECTION_CPU = "Cpu"
const val SECTION_THEME = "Theme"
const val PREF_OVERLAY_INIT = "OverlayInit"
const val PREF_CONTROL_SCALE = "controlScale"
const val PREF_CONTROL_OPACITY = "controlOpacity"
const val PREF_TOUCH_ENABLED = "isTouchEnabled"
const val PREF_BUTTON_TOGGLE_0 = "buttonToggle0"
const val PREF_BUTTON_TOGGLE_1 = "buttonToggle1"
const val PREF_BUTTON_TOGGLE_2 = "buttonToggle2"
const val PREF_BUTTON_TOGGLE_3 = "buttonToggle3"
const val PREF_BUTTON_TOGGLE_4 = "buttonToggle4"
const val PREF_BUTTON_TOGGLE_5 = "buttonToggle5"
const val PREF_BUTTON_TOGGLE_6 = "buttonToggle6"
const val PREF_BUTTON_TOGGLE_7 = "buttonToggle7"
const val PREF_BUTTON_TOGGLE_8 = "buttonToggle8"
const val PREF_BUTTON_TOGGLE_9 = "buttonToggle9"
const val PREF_BUTTON_TOGGLE_10 = "buttonToggle10"
const val PREF_BUTTON_TOGGLE_11 = "buttonToggle11"
const val PREF_BUTTON_TOGGLE_12 = "buttonToggle12"
const val PREF_BUTTON_TOGGLE_13 = "buttonToggle13"
const val PREF_BUTTON_TOGGLE_14 = "buttonToggle14"
const val PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER = "EmulationMenuSettings_JoystickRelCenter"
const val PREF_MENU_SETTINGS_DPAD_SLIDE = "EmulationMenuSettings_DpadSlideEnable"
const val PREF_MENU_SETTINGS_HAPTICS = "EmulationMenuSettings_Haptics"
const val PREF_MENU_SETTINGS_LANDSCAPE = "EmulationMenuSettings_LandscapeScreenLayout"
const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps"
const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay"
const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch"
const val PREF_THEME = "Theme"
const val PREF_THEME_MODE = "ThemeMode"
const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds"
private val configFileSectionsMap: MutableMap<String, List<String>> = HashMap()
init {
configFileSectionsMap[SettingsFile.FILE_NAME_CONFIG] =
listOf(
SECTION_GENERAL,
SECTION_SYSTEM,
SECTION_RENDERER,
SECTION_AUDIO,
SECTION_CPU
)
}
}
}

View File

@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
import androidx.lifecycle.ViewModel
class SettingsViewModel : ViewModel() {
val settings = Settings()
}

View File

@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model
enum class StringSetting(
override val key: String,
override val section: String,
override val defaultValue: String
) : AbstractStringSetting {
CUSTOM_RTC("custom_rtc", Settings.SECTION_SYSTEM, "0");
override var string: String = defaultValue
override val valueAsString: String
get() = string
override val isRuntimeEditable: Boolean
get() {
for (setting in NOT_RUNTIME_EDITABLE) {
if (setting == this) {
return false
}
}
return true
}
companion object {
private val NOT_RUNTIME_EDITABLE = listOf(
CUSTOM_RTC
)
fun from(key: String): StringSetting? = StringSetting.values().firstOrNull { it.key == key }
fun clear() = StringSetting.values().forEach { it.string = it.defaultValue }
}
}

View File

@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model.view
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
class DateTimeSetting(
setting: AbstractSetting?,
titleId: Int,
descriptionId: Int,
val key: String? = null,
private val defaultValue: String? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_DATETIME_SETTING
val value: String
get() = if (setting != null) {
val setting = setting as AbstractStringSetting
setting.string
} else {
defaultValue!!
}
fun setSelectedValue(datetime: String): AbstractStringSetting {
val stringSetting = setting as AbstractStringSetting
stringSetting.string = datetime
return stringSetting
}
}

View File

@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model.view
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
class HeaderSetting(
setting: AbstractSetting?,
titleId: Int,
descriptionId: Int
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_HEADER
}

View File

@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model.view
class RunnableSetting(
titleId: Int,
descriptionId: Int,
val isRuntimeRunnable: Boolean,
val runnable: () -> Unit
) : SettingsItem(null, titleId, descriptionId) {
override val type = TYPE_RUNNABLE
}

View File

@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model.view
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
/**
* ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments.
* Each one corresponds to a [AbstractSetting] object, so this class's subclasses
* should vaguely correspond to those subclasses. There are a few with multiple analogues
* and a few with none (Headers, for example, do not correspond to anything in the ini
* file.)
*/
abstract class SettingsItem(
var setting: AbstractSetting?,
val nameId: Int,
val descriptionId: Int
) {
abstract val type: Int
val isEditable: Boolean
get() {
if (!NativeLibrary.isRunning()) return true
return setting?.isRuntimeEditable ?: false
}
companion object {
const val TYPE_HEADER = 0
const val TYPE_SWITCH = 1
const val TYPE_SINGLE_CHOICE = 2
const val TYPE_SLIDER = 3
const val TYPE_SUBMENU = 4
const val TYPE_STRING_SINGLE_CHOICE = 5
const val TYPE_DATETIME_SETTING = 6
const val TYPE_RUNNABLE = 7
}
}

View File

@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model.view
import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
class SingleChoiceSetting(
setting: AbstractIntSetting?,
titleId: Int,
descriptionId: Int,
val choicesId: Int,
val valuesId: Int,
val key: String? = null,
val defaultValue: Int? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_SINGLE_CHOICE
val selectedValue: Int
get() = if (setting != null) {
val setting = setting as AbstractIntSetting
setting.int
} else {
defaultValue!!
}
/**
* Write a value to the backing int. If that int was previously null,
* initializes a new one and returns it, so it can be added to the Hashmap.
*
* @param selection New value of the int.
* @return the existing setting with the new value applied.
*/
fun setSelectedValue(selection: Int): AbstractIntSetting {
val intSetting = setting as AbstractIntSetting
intSetting.int = selection
return intSetting
}
}

View File

@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model.view
import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
import org.yuzu.yuzu_emu.utils.Log
import kotlin.math.roundToInt
class SliderSetting(
setting: AbstractSetting?,
titleId: Int,
descriptionId: Int,
val min: Int,
val max: Int,
val units: String,
val key: String? = null,
val defaultValue: Int? = null,
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_SLIDER
val selectedValue: Int
get() {
val setting = setting ?: return defaultValue!!
return when (setting) {
is AbstractIntSetting -> setting.int
is AbstractFloatSetting -> setting.float.roundToInt()
else -> {
Log.error("[SliderSetting] Error casting setting type.")
-1
}
}
}
/**
* Write a value to the backing int. If that int was previously null,
* initializes a new one and returns it, so it can be added to the Hashmap.
*
* @param selection New value of the int.
* @return the existing setting with the new value applied.
*/
fun setSelectedValue(selection: Int): AbstractIntSetting {
val intSetting = setting as AbstractIntSetting
intSetting.int = selection
return intSetting
}
/**
* Write a value to the backing float. If that float was previously null,
* initializes a new one and returns it, so it can be added to the Hashmap.
*
* @param selection New value of the float.
* @return the existing setting with the new value applied.
*/
fun setSelectedValue(selection: Float): AbstractFloatSetting {
val floatSetting = setting as AbstractFloatSetting
floatSetting.float = selection
return floatSetting
}
}

View File

@@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model.view
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
import org.yuzu.yuzu_emu.features.settings.model.StringSetting
class StringSingleChoiceSetting(
val key: String? = null,
setting: AbstractSetting?,
titleId: Int,
descriptionId: Int,
val choicesId: Array<String>,
private val valuesId: Array<String>?,
private val defaultValue: String? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_STRING_SINGLE_CHOICE
fun getValueAt(index: Int): String? {
if (valuesId == null) return null
return if (index >= 0 && index < valuesId.size) {
valuesId[index]
} else ""
}
val selectedValue: String
get() = if (setting != null) {
val setting = setting as AbstractStringSetting
setting.string
} else {
defaultValue!!
}
val selectValueIndex: Int
get() {
val selectedValue = selectedValue
for (i in valuesId!!.indices) {
if (valuesId[i] == selectedValue) {
return i
}
}
return -1
}
/**
* Write a value to the backing int. If that int was previously null,
* initializes a new one and returns it, so it can be added to the Hashmap.
*
* @param selection New value of the int.
* @return the existing setting with the new value applied.
*/
fun setSelectedValue(selection: String): AbstractStringSetting {
val stringSetting = setting as AbstractStringSetting
stringSetting.string = selection
return stringSetting
}
}

View File

@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model.view
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
class SubmenuSetting(
setting: AbstractSetting?,
titleId: Int,
descriptionId: Int,
val menuKey: String
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_SUBMENU
}

View File

@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.model.view
import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
class SwitchSetting(
setting: AbstractSetting,
titleId: Int,
descriptionId: Int,
val key: String? = null,
val defaultValue: Any? = null
) : SettingsItem(setting, titleId, descriptionId) {
override val type = TYPE_SWITCH
val isChecked: Boolean
get() {
if (setting == null) {
return defaultValue as Boolean
}
// Try integer setting
try {
val setting = setting as AbstractIntSetting
return setting.int == 1
} catch (_: ClassCastException) {
}
// Try boolean setting
try {
val setting = setting as AbstractBooleanSetting
return setting.boolean
} catch (_: ClassCastException) {
}
return defaultValue as Boolean
}
/**
* Write a value to the backing boolean. If that boolean was previously null,
* initializes a new one and returns it, so it can be added to the Hashmap.
*
* @param checked Pretty self explanatory.
* @return the existing setting with the new value applied.
*/
fun setChecked(checked: Boolean): AbstractSetting {
// Try integer setting
try {
val setting = setting as AbstractIntSetting
setting.int = if (checked) 1 else 0
return setting
} catch (_: ClassCastException) {
}
// Try boolean setting
val setting = setting as AbstractBooleanSetting
setting.boolean = checked
return setting
}
}

View File

@@ -0,0 +1,249 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import android.view.ViewGroup.MarginLayoutParams
import androidx.activity.OnBackPressedCallback
import androidx.core.view.updatePadding
import com.google.android.material.color.MaterialColors
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting
import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel
import org.yuzu.yuzu_emu.features.settings.model.StringSetting
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import org.yuzu.yuzu_emu.utils.*
import java.io.IOException
class SettingsActivity : AppCompatActivity(), SettingsActivityView {
private val presenter = SettingsActivityPresenter(this)
private lateinit var binding: ActivitySettingsBinding
private val settingsViewModel: SettingsViewModel by viewModels()
override val settings: Settings get() = settingsViewModel.settings
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.setTheme(this)
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
WindowCompat.setDecorFitsSystemWindows(window, false)
val launcher = intent
val gameID = launcher.getStringExtra(ARG_GAME_ID)
val menuTag = launcher.getStringExtra(ARG_MENU_TAG)
presenter.onCreate(savedInstanceState, menuTag!!, gameID!!)
// Show "Back" button in the action bar for navigation
setSupportActionBar(binding.toolbarSettings)
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (InsetsHelper.getSystemGestureType(applicationContext) != InsetsHelper.GESTURE_NAVIGATION) {
binding.navigationBarShade.setBackgroundColor(
ThemeHelper.getColorWithOpacity(
MaterialColors.getColor(
binding.navigationBarShade,
com.google.android.material.R.attr.colorSurface
),
ThemeHelper.SYSTEM_BAR_ALPHA
)
)
}
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = navigateBack()
})
setInsets()
}
override fun onSupportNavigateUp(): Boolean {
navigateBack()
return true
}
private fun navigateBack() {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
} else {
finish()
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
val inflater = menuInflater
inflater.inflate(R.menu.menu_settings, menu)
return true
}
override fun onSaveInstanceState(outState: Bundle) {
// Critical: If super method is not called, rotations will be busted.
super.onSaveInstanceState(outState)
presenter.saveState(outState)
}
override fun onStart() {
super.onStart()
presenter.onStart()
}
/**
* If this is called, the user has left the settings screen (potentially through the
* home button) and will expect their changes to be persisted. So we kick off an
* IntentService which will do so on a background thread.
*/
override fun onStop() {
super.onStop()
presenter.onStop(isFinishing)
// Update framebuffer layout when closing the settings
NativeLibrary.notifyOrientationChange(
EmulationMenuSettings.landscapeScreenLayout,
windowManager.defaultDisplay.rotation
)
}
override fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String) {
if (!addToStack && settingsFragment != null) {
return
}
val transaction = supportFragmentManager.beginTransaction()
if (addToStack) {
if (areSystemAnimationsEnabled()) {
transaction.setCustomAnimations(
R.anim.anim_settings_fragment_in,
R.anim.anim_settings_fragment_out,
0,
R.anim.anim_pop_settings_fragment_out
)
}
transaction.addToBackStack(null)
}
transaction.replace(
R.id.frame_content,
SettingsFragment.newInstance(menuTag, gameId),
FRAGMENT_TAG
)
transaction.commit()
}
private fun areSystemAnimationsEnabled(): Boolean {
val duration = android.provider.Settings.Global.getFloat(
contentResolver,
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f
)
val transition = android.provider.Settings.Global.getFloat(
contentResolver,
android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE, 1f
)
return duration != 0f && transition != 0f
}
override fun onSettingsFileLoaded() {
val fragment: SettingsFragmentView? = settingsFragment
fragment?.loadSettingsList()
}
override fun onSettingsFileNotFound() {
val fragment: SettingsFragmentView? = settingsFragment
fragment?.loadSettingsList()
}
override fun showToastMessage(message: String, is_long: Boolean) {
Toast.makeText(
this,
message,
if (is_long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
).show()
}
override fun onSettingChanged() {
presenter.onSettingChanged()
}
fun onSettingsReset() {
// Prevents saving to a non-existent settings file
presenter.onSettingsReset()
// Reset the static memory representation of each setting
BooleanSetting.clear()
FloatSetting.clear()
IntSetting.clear()
StringSetting.clear()
// Delete settings file because the user may have changed values that do not exist in the UI
val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
if (!settingsFile.delete()) {
throw IOException("Failed to delete $settingsFile")
}
showToastMessage(getString(R.string.settings_reset), true)
finish()
}
fun setToolbarTitle(title: String) {
binding.toolbarSettingsLayout.title = title
}
private val settingsFragment: SettingsFragment?
get() = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as SettingsFragment?
private fun setInsets() {
ViewCompat.setOnApplyWindowInsetsListener(binding.frameContent) { view: View, windowInsets: WindowInsetsCompat ->
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
view.updatePadding(
left = barInsets.left + cutoutInsets.left,
right = barInsets.right + cutoutInsets.right
)
val mlpAppBar = binding.appbarSettings.layoutParams as MarginLayoutParams
mlpAppBar.leftMargin = barInsets.left + cutoutInsets.left
mlpAppBar.rightMargin = barInsets.right + cutoutInsets.right
binding.appbarSettings.layoutParams = mlpAppBar
val mlpShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
mlpShade.height = barInsets.bottom
binding.navigationBarShade.layoutParams = mlpShade
windowInsets
}
}
companion object {
private const val ARG_MENU_TAG = "menu_tag"
private const val ARG_GAME_ID = "game_id"
private const val FRAGMENT_TAG = "settings"
fun launch(context: Context, menuTag: String?, gameId: String?) {
val settings = Intent(context, SettingsActivity::class.java)
settings.putExtra(ARG_MENU_TAG, menuTag)
settings.putExtra(ARG_GAME_ID, gameId)
context.startActivity(settings)
}
}
}

View File

@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui
import android.content.Context
import android.os.Bundle
import android.text.TextUtils
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.Log
import java.io.File
class SettingsActivityPresenter(private val activityView: SettingsActivityView) {
val settings: Settings get() = activityView.settings
private var shouldSave = false
private lateinit var menuTag: String
private lateinit var gameId: String
fun onCreate(savedInstanceState: Bundle?, menuTag: String, gameId: String) {
this.menuTag = menuTag
this.gameId = gameId
if (savedInstanceState != null) {
shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE)
}
}
fun onStart() {
prepareDirectoriesIfNeeded()
}
private fun loadSettingsUI() {
if (!settings.isLoaded) {
if (!TextUtils.isEmpty(gameId)) {
settings.loadSettings(gameId, activityView)
} else {
settings.loadSettings(activityView)
}
}
activityView.showSettingsFragment(menuTag, false, gameId)
activityView.onSettingsFileLoaded()
}
private fun prepareDirectoriesIfNeeded() {
val configFile =
File(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini")
if (!configFile.exists()) {
Log.error(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini")
Log.error("yuzu config file could not be found!")
}
if (!DirectoryInitialization.areDirectoriesReady) {
DirectoryInitialization.start(activityView as Context)
}
loadSettingsUI()
}
fun onStop(finishing: Boolean) {
if (finishing && shouldSave) {
Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...")
settings.saveSettings(activityView)
}
NativeLibrary.reloadSettings()
}
fun onSettingChanged() {
shouldSave = true
}
fun onSettingsReset() {
shouldSave = false
}
fun saveState(outState: Bundle) {
outState.putBoolean(KEY_SHOULD_SAVE, shouldSave)
}
companion object {
private const val KEY_SHOULD_SAVE = "should_save"
}
}

View File

@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui
import org.yuzu.yuzu_emu.features.settings.model.Settings
/**
* Abstraction for the Activity that manages SettingsFragments.
*/
interface SettingsActivityView {
/**
* Show a new SettingsFragment.
*
* @param menuTag Identifier for the settings group that should be displayed.
* @param addToStack Whether or not this fragment should replace a previous one.
*/
fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String)
/**
* Called by a contained Fragment to get access to the Setting HashMap
* loaded from disk, so that each Fragment doesn't need to perform its own
* read operation.
*
* @return A HashMap of Settings.
*/
val settings: Settings
/**
* Called when a load operation completes.
*/
fun onSettingsFileLoaded()
/**
* Called when a load operation fails.
*/
fun onSettingsFileNotFound()
/**
* Display a popup text message on screen.
*
* @param message The contents of the onscreen message.
* @param is_long Whether this should be a long Toast or short one.
*/
fun showToastMessage(message: String, is_long: Boolean)
/**
* End the activity.
*/
fun finish()
/**
* Called by a containing Fragment to tell the Activity that a setting was changed;
* unless this has been called, the Activity will not save to disk.
*/
fun onSettingChanged()
}

View File

@@ -0,0 +1,340 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui
import android.content.Context
import android.content.DialogInterface
import android.icu.util.Calendar
import android.icu.util.TimeZone
import android.text.format.DateFormat
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.setFragmentResultListener
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.DialogSliderBinding
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding
import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding
import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
import org.yuzu.yuzu_emu.features.settings.model.view.*
import org.yuzu.yuzu_emu.features.settings.ui.viewholder.*
class SettingsAdapter(
private val fragmentView: SettingsFragmentView,
private val context: Context
) : RecyclerView.Adapter<SettingViewHolder?>(), DialogInterface.OnClickListener {
private var settings: ArrayList<SettingsItem>? = null
private var clickedItem: SettingsItem? = null
private var clickedPosition: Int
private var dialog: AlertDialog? = null
private var sliderProgress = 0
private var textSliderValue: TextView? = null
private var defaultCancelListener =
DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() }
init {
clickedPosition = -1
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
SettingsItem.TYPE_HEADER -> {
HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
}
SettingsItem.TYPE_SWITCH -> {
SwitchSettingViewHolder(ListItemSettingSwitchBinding.inflate(inflater), this)
}
SettingsItem.TYPE_SINGLE_CHOICE, SettingsItem.TYPE_STRING_SINGLE_CHOICE -> {
SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this)
}
SettingsItem.TYPE_SLIDER -> {
SliderViewHolder(ListItemSettingBinding.inflate(inflater), this)
}
SettingsItem.TYPE_SUBMENU -> {
SubmenuViewHolder(ListItemSettingBinding.inflate(inflater), this)
}
SettingsItem.TYPE_DATETIME_SETTING -> {
DateTimeViewHolder(ListItemSettingBinding.inflate(inflater), this)
}
SettingsItem.TYPE_RUNNABLE -> {
RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this)
}
else -> {
// TODO: Create an error view since we can't return null now
HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
}
}
}
override fun onBindViewHolder(holder: SettingViewHolder, position: Int) {
holder.bind(getItem(position))
}
private fun getItem(position: Int): SettingsItem {
return settings!![position]
}
override fun getItemCount(): Int {
return if (settings != null) {
settings!!.size
} else {
0
}
}
override fun getItemViewType(position: Int): Int {
return getItem(position).type
}
fun setSettingsList(settings: ArrayList<SettingsItem>?) {
this.settings = settings
notifyDataSetChanged()
}
fun onBooleanClick(item: SwitchSetting, position: Int, checked: Boolean) {
val setting = item.setChecked(checked)
fragmentView.putSetting(setting)
fragmentView.onSettingChanged()
}
private fun onSingleChoiceClick(item: SingleChoiceSetting) {
clickedItem = item
val value = getSelectionForSingleChoiceValue(item)
dialog = MaterialAlertDialogBuilder(context)
.setTitle(item.nameId)
.setSingleChoiceItems(item.choicesId, value, this)
.show()
}
fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) {
clickedPosition = position
onSingleChoiceClick(item)
}
private fun onStringSingleChoiceClick(item: StringSingleChoiceSetting) {
clickedItem = item
dialog = MaterialAlertDialogBuilder(context)
.setTitle(item.nameId)
.setSingleChoiceItems(item.choicesId, item.selectValueIndex, this)
.show()
}
fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) {
clickedPosition = position
onStringSingleChoiceClick(item)
}
fun onDateTimeClick(item: DateTimeSetting, position: Int) {
clickedItem = item
clickedPosition = position
val storedTime = java.lang.Long.decode(item.value) * 1000
// Helper to extract hour and minute from epoch time
val calendar: Calendar = Calendar.getInstance()
calendar.timeInMillis = storedTime
calendar.timeZone = TimeZone.getTimeZone("UTC")
var timeFormat: Int = TimeFormat.CLOCK_12H
if (DateFormat.is24HourFormat(fragmentView.activityView as AppCompatActivity)) {
timeFormat = TimeFormat.CLOCK_24H
}
val datePicker: MaterialDatePicker<Long> = MaterialDatePicker.Builder.datePicker()
.setSelection(storedTime)
.setTitleText(R.string.select_rtc_date)
.build()
val timePicker: MaterialTimePicker = MaterialTimePicker.Builder()
.setTimeFormat(timeFormat)
.setHour(calendar.get(Calendar.HOUR_OF_DAY))
.setMinute(calendar.get(Calendar.MINUTE))
.setTitleText(R.string.select_rtc_time)
.build()
datePicker.addOnPositiveButtonClickListener {
timePicker.show(
(fragmentView.activityView as AppCompatActivity).supportFragmentManager,
"TimePicker"
)
}
timePicker.addOnPositiveButtonClickListener {
var epochTime: Long = datePicker.selection!! / 1000
epochTime += timePicker.hour.toLong() * 60 * 60
epochTime += timePicker.minute.toLong() * 60
val rtcString = epochTime.toString()
if (item.value != rtcString) {
fragmentView.onSettingChanged()
}
notifyItemChanged(clickedPosition)
val setting = item.setSelectedValue(rtcString)
fragmentView.putSetting(setting)
clickedItem = null
}
datePicker.show(
(fragmentView.activityView as AppCompatActivity).supportFragmentManager,
"DatePicker"
)
}
fun onSliderClick(item: SliderSetting, position: Int) {
clickedItem = item
clickedPosition = position
sliderProgress = item.selectedValue
val inflater = LayoutInflater.from(context)
val sliderBinding = DialogSliderBinding.inflate(inflater)
textSliderValue = sliderBinding.textValue
textSliderValue!!.text = sliderProgress.toString()
sliderBinding.textUnits.text = item.units
sliderBinding.slider.apply {
valueFrom = item.min.toFloat()
valueTo = item.max.toFloat()
value = sliderProgress.toFloat()
addOnChangeListener { _: Slider, value: Float, _: Boolean ->
sliderProgress = value.toInt()
textSliderValue!!.text = sliderProgress.toString()
}
}
dialog = MaterialAlertDialogBuilder(context)
.setTitle(item.nameId)
.setView(sliderBinding.root)
.setPositiveButton(android.R.string.ok, this)
.setNegativeButton(android.R.string.cancel, defaultCancelListener)
.setNeutralButton(R.string.slider_default) { dialog: DialogInterface, which: Int ->
sliderBinding.slider.value = item.defaultValue!!.toFloat()
onClick(dialog, which)
}
.show()
}
fun onSubmenuClick(item: SubmenuSetting) {
fragmentView.loadSubMenu(item.menuKey)
}
override fun onClick(dialog: DialogInterface, which: Int) {
when (clickedItem) {
is SingleChoiceSetting -> {
val scSetting = clickedItem as SingleChoiceSetting
val value = getValueForSingleChoiceSelection(scSetting, which)
if (scSetting.selectedValue != value) {
fragmentView.onSettingChanged()
}
// Get the backing Setting, which may be null (if for example it was missing from the file)
val setting = scSetting.setSelectedValue(value)
fragmentView.putSetting(setting)
closeDialog()
}
is StringSingleChoiceSetting -> {
val scSetting = clickedItem as StringSingleChoiceSetting
val value = scSetting.getValueAt(which)
if (scSetting.selectedValue != value) fragmentView.onSettingChanged()
val setting = scSetting.setSelectedValue(value!!)
fragmentView.putSetting(setting)
closeDialog()
}
is SliderSetting -> {
val sliderSetting = clickedItem as SliderSetting
if (sliderSetting.selectedValue != sliderProgress) {
fragmentView.onSettingChanged()
}
if (sliderSetting.setting is FloatSetting) {
val value = sliderProgress.toFloat()
val setting = sliderSetting.setSelectedValue(value)
fragmentView.putSetting(setting)
} else {
val setting = sliderSetting.setSelectedValue(sliderProgress)
fragmentView.putSetting(setting)
}
closeDialog()
}
}
clickedItem = null
sliderProgress = -1
}
fun onLongClick(setting: AbstractSetting, position: Int): Boolean {
MaterialAlertDialogBuilder(context)
.setMessage(R.string.reset_setting_confirmation)
.setPositiveButton(android.R.string.ok) { dialog: DialogInterface, which: Int ->
when (setting) {
is AbstractBooleanSetting -> setting.boolean = setting.defaultValue as Boolean
is AbstractFloatSetting -> setting.float = setting.defaultValue as Float
is AbstractIntSetting -> setting.int = setting.defaultValue as Int
is AbstractStringSetting -> setting.string = setting.defaultValue as String
}
notifyItemChanged(position)
fragmentView.onSettingChanged()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
return true
}
fun closeDialog() {
if (dialog != null) {
if (clickedPosition != -1) {
notifyItemChanged(clickedPosition)
clickedPosition = -1
}
dialog!!.dismiss()
dialog = null
}
}
private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int {
val valuesId = item.valuesId
return if (valuesId > 0) {
val valuesArray = context.resources.getIntArray(valuesId)
valuesArray[which]
} else {
which
}
}
private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int {
val value = item.selectedValue
val valuesId = item.valuesId
if (valuesId > 0) {
val valuesArray = context.resources.getIntArray(valuesId)
for (index in valuesArray.indices) {
val current = valuesArray[index]
if (current == value) {
return index
}
}
} else {
return value
}
return -1
}
}

View File

@@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.divider.MaterialDividerItemDecoration
import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
class SettingsFragment : Fragment(), SettingsFragmentView {
override var activityView: SettingsActivityView? = null
private val fragmentPresenter = SettingsFragmentPresenter(this)
private var settingsAdapter: SettingsAdapter? = null
private var _binding: FragmentSettingsBinding? = null
private val binding get() = _binding!!
override fun onAttach(context: Context) {
super.onAttach(context)
activityView = requireActivity() as SettingsActivityView
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val menuTag = requireArguments().getString(ARGUMENT_MENU_TAG)
val gameId = requireArguments().getString(ARGUMENT_GAME_ID)
fragmentPresenter.onCreate(menuTag!!, gameId!!)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSettingsBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
settingsAdapter = SettingsAdapter(this, requireActivity())
val dividerDecoration = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)
dividerDecoration.isLastItemDecorated = false
binding.listSettings.apply {
adapter = settingsAdapter
layoutManager = LinearLayoutManager(activity)
addItemDecoration(dividerDecoration)
}
fragmentPresenter.onViewCreated()
setInsets()
}
override fun onDetach() {
super.onDetach()
activityView = null
if (settingsAdapter != null) {
settingsAdapter!!.closeDialog()
}
}
override fun showSettingsList(settingsList: ArrayList<SettingsItem>) {
settingsAdapter!!.setSettingsList(settingsList)
}
override fun loadSettingsList() {
fragmentPresenter.loadSettingsList()
}
override fun loadSubMenu(menuKey: String) {
activityView!!.showSettingsFragment(
menuKey,
true,
requireArguments().getString(ARGUMENT_GAME_ID)!!
)
}
override fun showToastMessage(message: String?, is_long: Boolean) {
activityView!!.showToastMessage(message!!, is_long)
}
override fun putSetting(setting: AbstractSetting) {
fragmentPresenter.putSetting(setting)
}
override fun onSettingChanged() {
activityView!!.onSettingChanged()
}
private fun setInsets() {
ViewCompat.setOnApplyWindowInsetsListener(binding.listSettings) { view: View, windowInsets: WindowInsetsCompat ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
view.updatePadding(bottom = insets.bottom)
windowInsets
}
}
companion object {
private const val ARGUMENT_MENU_TAG = "menu_tag"
private const val ARGUMENT_GAME_ID = "game_id"
fun newInstance(menuTag: String?, gameId: String?): Fragment {
val fragment = SettingsFragment()
val arguments = Bundle()
arguments.putString(ARGUMENT_MENU_TAG, menuTag)
arguments.putString(ARGUMENT_GAME_ID, gameId)
fragment.arguments = arguments
return fragment
}
}
}

View File

@@ -0,0 +1,454 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui
import android.content.SharedPreferences
import android.os.Build
import android.text.TextUtils
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.features.settings.model.StringSetting
import org.yuzu.yuzu_emu.features.settings.model.view.*
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment
import org.yuzu.yuzu_emu.utils.ThemeHelper
class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) {
private var menuTag: String? = null
private lateinit var gameId: String
private var settingsList: ArrayList<SettingsItem>? = null
private val settingsActivity get() = fragmentView.activityView as SettingsActivity
private val settings get() = fragmentView.activityView!!.settings
private lateinit var preferences: SharedPreferences
fun onCreate(menuTag: String, gameId: String) {
this.gameId = gameId
this.menuTag = menuTag
}
fun onViewCreated() {
preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
loadSettingsList()
}
fun putSetting(setting: AbstractSetting) {
if (setting.section == null) {
return
}
val section = settings.getSection(setting.section!!)!!
if (section.getSetting(setting.key!!) == null) {
section.putSetting(setting)
}
}
fun loadSettingsList() {
if (!TextUtils.isEmpty(gameId)) {
settingsActivity.setToolbarTitle("Game Settings: $gameId")
}
val sl = ArrayList<SettingsItem>()
if (menuTag == null) {
return
}
when (menuTag) {
SettingsFile.FILE_NAME_CONFIG -> addConfigSettings(sl)
Settings.SECTION_GENERAL -> addGeneralSettings(sl)
Settings.SECTION_SYSTEM -> addSystemSettings(sl)
Settings.SECTION_RENDERER -> addGraphicsSettings(sl)
Settings.SECTION_AUDIO -> addAudioSettings(sl)
Settings.SECTION_THEME -> addThemeSettings(sl)
else -> {
fragmentView.showToastMessage("Unimplemented menu", false)
return
}
}
settingsList = sl
fragmentView.showSettingsList(settingsList!!)
}
private fun addConfigSettings(sl: ArrayList<SettingsItem>) {
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_advanced_settings))
sl.apply {
add(
SubmenuSetting(
null,
R.string.preferences_general,
0,
Settings.SECTION_GENERAL
)
)
add(
SubmenuSetting(
null,
R.string.preferences_system,
0,
Settings.SECTION_SYSTEM
)
)
add(
SubmenuSetting(
null,
R.string.preferences_graphics,
0,
Settings.SECTION_RENDERER
)
)
add(
SubmenuSetting(
null,
R.string.preferences_audio,
0,
Settings.SECTION_AUDIO
)
)
add(
RunnableSetting(
R.string.reset_to_default,
0,
false
) {
ResetSettingsDialogFragment().show(
settingsActivity.supportFragmentManager,
ResetSettingsDialogFragment.TAG
)
}
)
}
}
private fun addGeneralSettings(sl: ArrayList<SettingsItem>) {
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_general))
sl.apply {
add(
SwitchSetting(
IntSetting.RENDERER_USE_SPEED_LIMIT,
R.string.frame_limit_enable,
R.string.frame_limit_enable_description,
IntSetting.RENDERER_USE_SPEED_LIMIT.key,
IntSetting.RENDERER_USE_SPEED_LIMIT.defaultValue
)
)
add(
SliderSetting(
IntSetting.RENDERER_SPEED_LIMIT,
R.string.frame_limit_slider,
R.string.frame_limit_slider_description,
1,
200,
"%",
IntSetting.RENDERER_SPEED_LIMIT.key,
IntSetting.RENDERER_SPEED_LIMIT.defaultValue
)
)
add(
SingleChoiceSetting(
IntSetting.CPU_ACCURACY,
R.string.cpu_accuracy,
0,
R.array.cpuAccuracyNames,
R.array.cpuAccuracyValues,
IntSetting.CPU_ACCURACY.key,
IntSetting.CPU_ACCURACY.defaultValue
)
)
}
}
private fun addSystemSettings(sl: ArrayList<SettingsItem>) {
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_system))
sl.apply {
add(
SwitchSetting(
IntSetting.USE_DOCKED_MODE,
R.string.use_docked_mode,
R.string.use_docked_mode_description,
IntSetting.USE_DOCKED_MODE.key,
IntSetting.USE_DOCKED_MODE.defaultValue
)
)
add(
SingleChoiceSetting(
IntSetting.REGION_INDEX,
R.string.emulated_region,
0,
R.array.regionNames,
R.array.regionValues,
IntSetting.REGION_INDEX.key,
IntSetting.REGION_INDEX.defaultValue
)
)
add(
SingleChoiceSetting(
IntSetting.LANGUAGE_INDEX,
R.string.emulated_language,
0,
R.array.languageNames,
R.array.languageValues,
IntSetting.LANGUAGE_INDEX.key,
IntSetting.LANGUAGE_INDEX.defaultValue
)
)
add(
SwitchSetting(
BooleanSetting.USE_CUSTOM_RTC,
R.string.use_custom_rtc,
R.string.use_custom_rtc_description,
BooleanSetting.USE_CUSTOM_RTC.key,
BooleanSetting.USE_CUSTOM_RTC.defaultValue
)
)
add(
DateTimeSetting(
StringSetting.CUSTOM_RTC,
R.string.set_custom_rtc,
0,
StringSetting.CUSTOM_RTC.key,
StringSetting.CUSTOM_RTC.defaultValue
)
)
}
}
private fun addGraphicsSettings(sl: ArrayList<SettingsItem>) {
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics))
sl.apply {
add(
SingleChoiceSetting(
IntSetting.RENDERER_BACKEND,
R.string.renderer_api,
0,
R.array.rendererApiNames,
R.array.rendererApiValues,
IntSetting.RENDERER_BACKEND.key,
IntSetting.RENDERER_BACKEND.defaultValue
)
)
add(
SingleChoiceSetting(
IntSetting.RENDERER_ACCURACY,
R.string.renderer_accuracy,
0,
R.array.rendererAccuracyNames,
R.array.rendererAccuracyValues,
IntSetting.RENDERER_ACCURACY.key,
IntSetting.RENDERER_ACCURACY.defaultValue
)
)
add(
SingleChoiceSetting(
IntSetting.RENDERER_RESOLUTION,
R.string.renderer_resolution,
0,
R.array.rendererResolutionNames,
R.array.rendererResolutionValues,
IntSetting.RENDERER_RESOLUTION.key,
IntSetting.RENDERER_RESOLUTION.defaultValue
)
)
add(
SingleChoiceSetting(
IntSetting.RENDERER_VSYNC,
R.string.renderer_vsync,
0,
R.array.rendererVSyncNames,
R.array.rendererVSyncValues,
IntSetting.RENDERER_VSYNC.key,
IntSetting.RENDERER_VSYNC.defaultValue
)
)
add(
SingleChoiceSetting(
IntSetting.RENDERER_SCALING_FILTER,
R.string.renderer_scaling_filter,
0,
R.array.rendererScalingFilterNames,
R.array.rendererScalingFilterValues,
IntSetting.RENDERER_SCALING_FILTER.key,
IntSetting.RENDERER_SCALING_FILTER.defaultValue
)
)
add(
SingleChoiceSetting(
IntSetting.RENDERER_ANTI_ALIASING,
R.string.renderer_anti_aliasing,
0,
R.array.rendererAntiAliasingNames,
R.array.rendererAntiAliasingValues,
IntSetting.RENDERER_ANTI_ALIASING.key,
IntSetting.RENDERER_ANTI_ALIASING.defaultValue
)
)
add(
SingleChoiceSetting(
IntSetting.RENDERER_ASPECT_RATIO,
R.string.renderer_aspect_ratio,
0,
R.array.rendererAspectRatioNames,
R.array.rendererAspectRatioValues,
IntSetting.RENDERER_ASPECT_RATIO.key,
IntSetting.RENDERER_ASPECT_RATIO.defaultValue
)
)
add(
SwitchSetting(
IntSetting.RENDERER_USE_DISK_SHADER_CACHE,
R.string.use_disk_shader_cache,
R.string.use_disk_shader_cache_description,
IntSetting.RENDERER_USE_DISK_SHADER_CACHE.key,
IntSetting.RENDERER_USE_DISK_SHADER_CACHE.defaultValue
)
)
add(
SwitchSetting(
IntSetting.RENDERER_FORCE_MAX_CLOCK,
R.string.renderer_force_max_clock,
R.string.renderer_force_max_clock_description,
IntSetting.RENDERER_FORCE_MAX_CLOCK.key,
IntSetting.RENDERER_FORCE_MAX_CLOCK.defaultValue
)
)
add(
SwitchSetting(
IntSetting.RENDERER_ASYNCHRONOUS_SHADERS,
R.string.renderer_asynchronous_shaders,
R.string.renderer_asynchronous_shaders_description,
IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.key,
IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.defaultValue
)
)
add(
SwitchSetting(
IntSetting.RENDERER_DEBUG,
R.string.renderer_debug,
R.string.renderer_debug_description,
IntSetting.RENDERER_DEBUG.key,
IntSetting.RENDERER_DEBUG.defaultValue
)
)
}
}
private fun addAudioSettings(sl: ArrayList<SettingsItem>) {
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_audio))
sl.add(
SliderSetting(
IntSetting.AUDIO_VOLUME,
R.string.audio_volume,
R.string.audio_volume_description,
0,
100,
"%",
IntSetting.AUDIO_VOLUME.key,
IntSetting.AUDIO_VOLUME.defaultValue
)
)
}
private fun addThemeSettings(sl: ArrayList<SettingsItem>) {
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_theme))
sl.apply {
val theme: AbstractIntSetting = object : AbstractIntSetting {
override var int: Int
get() = preferences.getInt(Settings.PREF_THEME, 0)
set(value) {
preferences.edit()
.putInt(Settings.PREF_THEME, value)
.apply()
settingsActivity.recreate()
}
override val key: String? = null
override val section: String? = null
override val isRuntimeEditable: Boolean = false
override val valueAsString: String
get() = preferences.getInt(Settings.PREF_THEME, 0).toString()
override val defaultValue: Any = 0
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(
SingleChoiceSetting(
theme,
R.string.change_app_theme,
0,
R.array.themeEntriesA12,
R.array.themeValuesA12
)
)
} else {
add(
SingleChoiceSetting(
theme,
R.string.change_app_theme,
0,
R.array.themeEntries,
R.array.themeValues
)
)
}
val themeMode: AbstractIntSetting = object : AbstractIntSetting {
override var int: Int
get() = preferences.getInt(Settings.PREF_THEME_MODE, -1)
set(value) {
preferences.edit()
.putInt(Settings.PREF_THEME_MODE, value)
.apply()
ThemeHelper.setThemeMode(settingsActivity)
}
override val key: String? = null
override val section: String? = null
override val isRuntimeEditable: Boolean = false
override val valueAsString: String
get() = preferences.getInt(Settings.PREF_THEME_MODE, -1).toString()
override val defaultValue: Any = -1
}
add(
SingleChoiceSetting(
themeMode,
R.string.change_theme_mode,
0,
R.array.themeModeEntries,
R.array.themeModeValues
)
)
val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting {
override var boolean: Boolean
get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false)
set(value) {
preferences.edit()
.putBoolean(Settings.PREF_BLACK_BACKGROUNDS, value)
.apply()
settingsActivity.recreate()
}
override val key: String? = null
override val section: String? = null
override val isRuntimeEditable: Boolean = false
override val valueAsString: String
get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false)
.toString()
override val defaultValue: Any = false
}
add(
SwitchSetting(
blackBackgrounds,
R.string.use_black_backgrounds,
R.string.use_black_backgrounds_description
)
)
}
}
}

View File

@@ -0,0 +1,58 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
/**
* Abstraction for a screen showing a list of settings. Instances of
* this type of view will each display a layer of the setting hierarchy.
*/
interface SettingsFragmentView {
/**
* Pass an ArrayList to the View so that it can be displayed on screen.
*
* @param settingsList The result of converting the HashMap to an ArrayList
*/
fun showSettingsList(settingsList: ArrayList<SettingsItem>)
/**
* Instructs the Fragment to load the settings screen.
*/
fun loadSettingsList()
/**
* @return The Fragment's containing activity.
*/
val activityView: SettingsActivityView?
/**
* Tell the Fragment to tell the containing Activity to show a new
* Fragment containing a submenu of settings.
*
* @param menuKey Identifier for the settings group that should be shown.
*/
fun loadSubMenu(menuKey: String)
/**
* Tell the Fragment to tell the containing activity to display a toast message.
*
* @param message Text to be shown in the Toast
* @param is_long Whether this should be a long Toast or short one.
*/
fun showToastMessage(message: String?, is_long: Boolean)
/**
* Have the fragment add a setting to the HashMap.
*
* @param setting The (possibly previously missing) new setting.
*/
fun putSetting(setting: AbstractSetting)
/**
* Have the fragment tell the containing Activity that a setting was modified.
*/
fun onSettingChanged()
}

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
import android.view.View
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
import org.yuzu.yuzu_emu.features.settings.model.view.DateTimeSetting
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: DateTimeSetting
override fun bind(item: SettingsItem) {
setting = item as DateTimeSetting
binding.textSettingName.setText(item.nameId)
if (item.descriptionId != 0) {
binding.textSettingDescription.setText(item.descriptionId)
binding.textSettingDescription.visibility = View.VISIBLE
} else {
val epochTime = setting.value.toLong()
val instant = Instant.ofEpochMilli(epochTime * 1000)
val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"))
val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
binding.textSettingDescription.text = dateFormatter.format(zonedTime)
}
}
override fun onClick(clicked: View) {
if (setting.isEditable) {
adapter.onDateTimeClick(setting, bindingAdapterPosition)
}
}
override fun onLongClick(clicked: View): Boolean {
if (setting.isEditable) {
return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
}
return false
}
}

View File

@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
import android.view.View
import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
class HeaderViewHolder(val binding: ListItemSettingsHeaderBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
init {
itemView.setOnClickListener(null)
}
override fun bind(item: SettingsItem) {
binding.textHeaderName.setText(item.nameId)
}
override fun onClick(clicked: View) {
// no-op
}
override fun onLongClick(clicked: View): Boolean {
// no-op
return true
}
}

View File

@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
import android.view.View
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
import org.yuzu.yuzu_emu.features.settings.model.view.RunnableSetting
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: RunnableSetting
override fun bind(item: SettingsItem) {
setting = item as RunnableSetting
binding.textSettingName.setText(item.nameId)
if (item.descriptionId != 0) {
binding.textSettingDescription.setText(item.descriptionId)
binding.textSettingDescription.visibility = View.VISIBLE
} else {
binding.textSettingDescription.visibility = View.GONE
}
}
override fun onClick(clicked: View) {
if (!setting.isRuntimeRunnable && !NativeLibrary.isRunning()) {
setting.runnable.invoke()
}
}
override fun onLongClick(clicked: View): Boolean {
// no-op
return true
}
}

View File

@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) :
RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener {
init {
itemView.setOnClickListener(this)
itemView.setOnLongClickListener(this)
}
/**
* Called by the adapter to set this ViewHolder's child views to display the list item
* it must now represent.
*
* @param item The list item that should be represented by this ViewHolder.
*/
abstract fun bind(item: SettingsItem)
/**
* Called when this ViewHolder's view is clicked on. Implementations should usually pass
* this event up to the adapter.
*
* @param clicked The view that was clicked on.
*/
abstract override fun onClick(clicked: View)
abstract override fun onLongClick(clicked: View): Boolean
}

View File

@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
import android.view.View
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting
import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: SettingsItem
override fun bind(item: SettingsItem) {
setting = item
binding.textSettingName.setText(item.nameId)
binding.textSettingDescription.visibility = View.VISIBLE
if (item.descriptionId != 0) {
binding.textSettingDescription.setText(item.descriptionId)
} else if (item is SingleChoiceSetting) {
val resMgr = binding.textSettingDescription.context.resources
val values = resMgr.getIntArray(item.valuesId)
for (i in values.indices) {
if (values[i] == item.selectedValue) {
binding.textSettingDescription.text = resMgr.getStringArray(item.choicesId)[i]
}
}
} else {
binding.textSettingDescription.visibility = View.GONE
}
}
override fun onClick(clicked: View) {
if (!setting.isEditable) {
return
}
if (setting is SingleChoiceSetting) {
adapter.onSingleChoiceClick(
(setting as SingleChoiceSetting),
bindingAdapterPosition
)
} else if (setting is StringSingleChoiceSetting) {
adapter.onStringSingleChoiceClick(
(setting as StringSingleChoiceSetting),
bindingAdapterPosition
)
}
}
override fun onLongClick(clicked: View): Boolean {
if (setting.isEditable) {
return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
}
return false
}
}

View File

@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
import android.view.View
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: SliderSetting
override fun bind(item: SettingsItem) {
setting = item as SliderSetting
binding.textSettingName.setText(item.nameId)
if (item.descriptionId != 0) {
binding.textSettingDescription.setText(item.descriptionId)
binding.textSettingDescription.visibility = View.VISIBLE
} else {
binding.textSettingDescription.visibility = View.GONE
}
}
override fun onClick(clicked: View) {
if (setting.isEditable) {
adapter.onSliderClick(setting, bindingAdapterPosition)
}
}
override fun onLongClick(clicked: View): Boolean {
if (setting.isEditable) {
return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
}
return false
}
}

View File

@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
import android.view.View
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
import org.yuzu.yuzu_emu.features.settings.model.view.SubmenuSetting
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var item: SubmenuSetting
override fun bind(item: SettingsItem) {
this.item = item as SubmenuSetting
binding.textSettingName.setText(item.nameId)
if (item.descriptionId != 0) {
binding.textSettingDescription.setText(item.descriptionId)
binding.textSettingDescription.visibility = View.VISIBLE
} else {
binding.textSettingDescription.visibility = View.GONE
}
}
override fun onClick(clicked: View) {
adapter.onSubmenuClick(item)
}
override fun onLongClick(clicked: View): Boolean {
// no-op
return true
}
}

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
import android.view.View
import android.widget.CompoundButton
import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding
import org.yuzu.yuzu_emu.features.settings.model.view.SwitchSetting
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) :
SettingViewHolder(binding.root, adapter) {
private lateinit var setting: SwitchSetting
override fun bind(item: SettingsItem) {
setting = item as SwitchSetting
binding.textSettingName.setText(item.nameId)
if (item.descriptionId != 0) {
binding.textSettingDescription.setText(item.descriptionId)
binding.textSettingDescription.visibility = View.VISIBLE
} else {
binding.textSettingDescription.text = ""
binding.textSettingDescription.visibility = View.GONE
}
binding.switchWidget.isChecked = setting.isChecked
binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean ->
adapter.onBooleanClick(item, bindingAdapterPosition, binding.switchWidget.isChecked)
}
binding.switchWidget.isEnabled = setting.isEditable
}
override fun onClick(clicked: View) {
if (setting.isEditable) {
binding.switchWidget.toggle()
}
}
override fun onLongClick(clicked: View): Boolean {
if (setting.isEditable) {
return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
}
return false
}
}

View File

@@ -0,0 +1,238 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.features.settings.utils
import org.ini4j.Wini
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.features.settings.model.*
import org.yuzu.yuzu_emu.features.settings.model.Settings.SettingsSectionMap
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView
import org.yuzu.yuzu_emu.utils.BiMap
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.Log
import java.io.*
import java.util.*
/**
* Contains static methods for interacting with .ini files in which settings are stored.
*/
object SettingsFile {
const val FILE_NAME_CONFIG = "config"
private var sectionsMap = BiMap<String?, String?>()
/**
* Reads a given .ini file from disk and returns it as a HashMap of Settings, themselves
* effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it
* failed.
*
* @param ini The ini file to load the settings from
* @param isCustomGame
* @param view The current view.
* @return An Observable that emits a HashMap of the file's contents, then completes.
*/
private fun readFile(
ini: File?,
isCustomGame: Boolean,
view: SettingsActivityView?
): HashMap<String, SettingSection?> {
val sections: HashMap<String, SettingSection?> = SettingsSectionMap()
var reader: BufferedReader? = null
try {
reader = BufferedReader(FileReader(ini))
var current: SettingSection? = null
var line: String?
while (reader.readLine().also { line = it } != null) {
if (line!!.startsWith("[") && line!!.endsWith("]")) {
current = sectionFromLine(line!!, isCustomGame)
sections[current.name] = current
} else if (current != null) {
val setting = settingFromLine(line!!)
if (setting != null) {
current.putSetting(setting)
}
}
}
} catch (e: FileNotFoundException) {
Log.error("[SettingsFile] File not found: " + e.message)
view?.onSettingsFileNotFound()
} catch (e: IOException) {
Log.error("[SettingsFile] Error reading from: " + e.message)
view?.onSettingsFileNotFound()
} finally {
if (reader != null) {
try {
reader.close()
} catch (e: IOException) {
Log.error("[SettingsFile] Error closing: " + e.message)
}
}
}
return sections
}
fun readFile(fileName: String, view: SettingsActivityView): HashMap<String, SettingSection?> {
return readFile(getSettingsFile(fileName), false, view)
}
/**
* Reads a given .ini file from disk and returns it as a HashMap of SettingSections, themselves
* effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it
* failed.
*
* @param gameId the id of the game to load it's settings.
* @param view The current view.
*/
fun readCustomGameSettings(
gameId: String,
view: SettingsActivityView
): HashMap<String, SettingSection?> {
return readFile(getCustomGameSettingsFile(gameId), true, view)
}
/**
* Saves a Settings HashMap to a given .ini file on disk. If unsuccessful, outputs an error
* telling why it failed.
*
* @param fileName The target filename without a path or extension.
* @param sections The HashMap containing the Settings we want to serialize.
* @param view The current view.
*/
fun saveFile(
fileName: String,
sections: TreeMap<String, SettingSection>,
view: SettingsActivityView
) {
val ini = getSettingsFile(fileName)
try {
val writer = Wini(ini)
val keySet: Set<String> = sections.keys
for (key in keySet) {
val section = sections[key]
writeSection(writer, section!!)
}
writer.store()
} catch (e: IOException) {
Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.message)
view.showToastMessage(
YuzuApplication.appContext
.getString(R.string.error_saving, fileName, e.message),
false
)
}
}
fun saveCustomGameSettings(gameId: String?, sections: HashMap<String, SettingSection?>) {
val sortedSections: Set<String> = TreeSet(sections.keys)
for (sectionKey in sortedSections) {
val section = sections[sectionKey]
val settings = section!!.settings
val sortedKeySet: Set<String> = TreeSet(settings.keys)
for (settingKey in sortedKeySet) {
val setting = settings[settingKey]
NativeLibrary.setUserSetting(
gameId, mapSectionNameFromIni(
section.name
), setting!!.key, setting.valueAsString
)
}
}
}
private fun mapSectionNameFromIni(generalSectionName: String): String? {
return if (sectionsMap.getForward(generalSectionName) != null) {
sectionsMap.getForward(generalSectionName)
} else generalSectionName
}
private fun mapSectionNameToIni(generalSectionName: String): String {
return if (sectionsMap.getBackward(generalSectionName) != null) {
sectionsMap.getBackward(generalSectionName).toString()
} else generalSectionName
}
fun getSettingsFile(fileName: String): File {
return File(
DirectoryInitialization.userDirectory + "/config/" + fileName + ".ini"
)
}
private fun getCustomGameSettingsFile(gameId: String): File {
return File(DirectoryInitialization.userDirectory + "/GameSettings/" + gameId + ".ini")
}
private fun sectionFromLine(line: String, isCustomGame: Boolean): SettingSection {
var sectionName: String = line.substring(1, line.length - 1)
if (isCustomGame) {
sectionName = mapSectionNameToIni(sectionName)
}
return SettingSection(sectionName)
}
/**
* For a line of text, determines what type of data is being represented, and returns
* a Setting object containing this data.
*
* @param line The line of text being parsed.
* @return A typed Setting containing the key/value contained in the line.
*/
private fun settingFromLine(line: String): AbstractSetting? {
val splitLine = line.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if (splitLine.size != 2) {
return null
}
val key = splitLine[0].trim { it <= ' ' }
val value = splitLine[1].trim { it <= ' ' }
if (value.isEmpty()) {
return null
}
val booleanSetting = BooleanSetting.from(key)
if (booleanSetting != null) {
booleanSetting.boolean = value.toBoolean()
return booleanSetting
}
val intSetting = IntSetting.from(key)
if (intSetting != null) {
intSetting.int = value.toInt()
return intSetting
}
val floatSetting = FloatSetting.from(key)
if (floatSetting != null) {
floatSetting.float = value.toFloat()
return floatSetting
}
val stringSetting = StringSetting.from(key)
if (stringSetting != null) {
stringSetting.string = value
return stringSetting
}
return null
}
/**
* Writes the contents of a Section HashMap to disk.
*
* @param parser A Wini pointed at a file on disk.
* @param section A section containing settings to be written to the file.
*/
private fun writeSection(parser: Wini, section: SettingSection) {
// Write the section header.
val header = section.name
// Write this section's values.
val settings = section.settings
val keySet: Set<String> = settings.keys
for (key in keySet) {
val setting = settings[key]
parser.put(header, setting!!.key, setting.valueAsString)
}
}
}

View File

@@ -0,0 +1,121 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.transition.MaterialSharedAxis
import org.yuzu.yuzu_emu.BuildConfig
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding
import org.yuzu.yuzu_emu.model.HomeViewModel
class AboutFragment : Fragment() {
private var _binding: FragmentAboutBinding? = null
private val binding get() = _binding!!
private val homeViewModel: HomeViewModel by activityViewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentAboutBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
homeViewModel.setNavigationVisibility(visible = false, animated = true)
homeViewModel.setStatusBarShadeVisibility(visible = false)
binding.toolbarAbout.setNavigationOnClickListener {
parentFragmentManager.primaryNavigationFragment?.findNavController()?.popBackStack()
}
binding.imageLogo.setOnLongClickListener {
Toast.makeText(
requireContext(),
R.string.gaia_is_not_real,
Toast.LENGTH_SHORT
).show()
true
}
binding.buttonContributors.setOnClickListener { openLink(getString(R.string.contributors_link)) }
binding.textBuildHash.text = BuildConfig.GIT_HASH
binding.buttonBuildHash.setOnClickListener {
val clipBoard =
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(getString(R.string.build), BuildConfig.GIT_HASH)
clipBoard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(
requireContext(),
R.string.copied_to_clipboard,
Toast.LENGTH_SHORT
).show()
}
}
binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) }
binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) }
binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) }
setInsets()
}
private fun openLink(link: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
startActivity(intent)
}
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
val leftInsets = barInsets.left + cutoutInsets.left
val rightInsets = barInsets.right + cutoutInsets.right
val mlpAppBar = binding.appbarAbout.layoutParams as MarginLayoutParams
mlpAppBar.leftMargin = leftInsets
mlpAppBar.rightMargin = rightInsets
binding.appbarAbout.layoutParams = mlpAppBar
val mlpScrollAbout = binding.scrollAbout.layoutParams as MarginLayoutParams
mlpScrollAbout.leftMargin = leftInsets
mlpScrollAbout.rightMargin = rightInsets
binding.scrollAbout.layoutParams = mlpScrollAbout
binding.contentAbout.updatePadding(bottom = barInsets.bottom)
windowInsets
}
}

View File

@@ -0,0 +1,83 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.transition.MaterialSharedAxis
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.FragmentEarlyAccessBinding
import org.yuzu.yuzu_emu.model.HomeViewModel
class EarlyAccessFragment : Fragment() {
private var _binding: FragmentEarlyAccessBinding? = null
private val binding get() = _binding!!
private val homeViewModel: HomeViewModel by activityViewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentEarlyAccessBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
homeViewModel.setNavigationVisibility(visible = false, animated = true)
homeViewModel.setStatusBarShadeVisibility(visible = false)
binding.toolbarAbout.setNavigationOnClickListener {
parentFragmentManager.primaryNavigationFragment?.findNavController()?.popBackStack()
}
binding.getEarlyAccessButton.setOnClickListener { openLink(getString(R.string.play_store_link)) }
setInsets()
}
private fun openLink(link: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
startActivity(intent)
}
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
val leftInsets = barInsets.left + cutoutInsets.left
val rightInsets = barInsets.right + cutoutInsets.right
val mlpAppBar = binding.appbarEa.layoutParams as ViewGroup.MarginLayoutParams
mlpAppBar.leftMargin = leftInsets
mlpAppBar.rightMargin = rightInsets
binding.appbarEa.layoutParams = mlpAppBar
binding.scrollEa.updatePadding(
left = leftInsets,
right = rightInsets,
bottom = barInsets.bottom
)
windowInsets
}
}

View File

@@ -0,0 +1,568 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.content.DialogInterface
import android.content.SharedPreferences
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.*
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.slider.Slider
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding
import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import org.yuzu.yuzu_emu.model.Game
import org.yuzu.yuzu_emu.utils.*
import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
class EmulationFragment : Fragment(), SurfaceHolder.Callback {
private lateinit var preferences: SharedPreferences
private lateinit var emulationState: EmulationState
private var emulationActivity: EmulationActivity? = null
private var perfStatsUpdater: (() -> Unit)? = null
private var _binding: FragmentEmulationBinding? = null
private val binding get() = _binding!!
private lateinit var game: Game
override fun onAttach(context: Context) {
super.onAttach(context)
if (context is EmulationActivity) {
emulationActivity = context
NativeLibrary.setEmulationActivity(context)
} else {
throw IllegalStateException("EmulationFragment must have EmulationActivity parent")
}
}
/**
* Initialize anything that doesn't depend on the layout / views in here.
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// So this fragment doesn't restart on configuration changes; i.e. rotation.
retainInstance = true
preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
game = requireArguments().parcelable(EmulationActivity.EXTRA_SELECTED_GAME)!!
emulationState = EmulationState(game.path)
}
/**
* Initialize the UI and start emulation in here.
*/
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentEmulationBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.surfaceEmulation.holder.addCallback(this)
binding.showFpsText.setTextColor(Color.YELLOW)
binding.doneControlConfig.setOnClickListener { stopConfiguringControls() }
// Setup overlay.
updateShowFpsOverlay()
binding.inGameMenu.getHeaderView(0).findViewById<TextView>(R.id.text_game_title).text =
game.title
binding.inGameMenu.setNavigationItemSelectedListener {
when (it.itemId) {
R.id.menu_pause_emulation -> {
if (emulationState.isPaused) {
emulationState.run(false)
it.title = resources.getString(R.string.emulation_pause)
it.icon = ResourcesCompat.getDrawable(
resources,
R.drawable.ic_pause,
requireContext().theme
)
} else {
emulationState.pause()
it.title = resources.getString(R.string.emulation_unpause)
it.icon = ResourcesCompat.getDrawable(
resources,
R.drawable.ic_play,
requireContext().theme
)
}
true
}
R.id.menu_settings -> {
SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "")
true
}
R.id.menu_overlay_controls -> {
showOverlayOptions()
true
}
R.id.menu_exit -> {
emulationState.stop()
requireActivity().finish()
true
}
else -> true
}
}
setInsets()
requireActivity().onBackPressedDispatcher.addCallback(
requireActivity(),
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (binding.drawerLayout.isOpen) binding.drawerLayout.close() else binding.drawerLayout.open()
}
})
}
override fun onResume() {
super.onResume()
if (!DirectoryInitialization.areDirectoriesReady) {
DirectoryInitialization.start(requireContext())
}
emulationState.run(emulationActivity!!.isActivityRecreated)
}
override fun onPause() {
if (emulationState.isRunning) {
emulationState.pause()
}
super.onPause()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
override fun onDetach() {
NativeLibrary.clearEmulationActivity()
super.onDetach()
}
private fun refreshInputOverlay() {
binding.surfaceInputOverlay.refreshControls()
}
private fun resetInputOverlay() {
preferences.edit()
.remove(Settings.PREF_CONTROL_SCALE)
.remove(Settings.PREF_CONTROL_OPACITY)
.apply()
binding.surfaceInputOverlay.post { binding.surfaceInputOverlay.resetButtonPlacement() }
}
private fun updateShowFpsOverlay() {
if (EmulationMenuSettings.showFps) {
val SYSTEM_FPS = 0
val FPS = 1
val FRAMETIME = 2
val SPEED = 3
perfStatsUpdater = {
val perfStats = NativeLibrary.getPerfStats()
if (perfStats[FPS] > 0 && _binding != null) {
binding.showFpsText.text = String.format("FPS: %.1f", perfStats[FPS])
}
if (!emulationState.isStopped) {
perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 100)
}
}
perfStatsUpdateHandler.post(perfStatsUpdater!!)
binding.showFpsText.text = resources.getString(R.string.emulation_game_loading)
binding.showFpsText.visibility = View.VISIBLE
} else {
if (perfStatsUpdater != null) {
perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!)
}
binding.showFpsText.visibility = View.GONE
}
}
override fun surfaceCreated(holder: SurfaceHolder) {
// We purposely don't do anything here.
// All work is done in surfaceChanged, which we are guaranteed to get even for surface creation.
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height)
emulationState.newSurface(holder.surface)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
emulationState.clearSurface()
}
private fun showOverlayOptions() {
val anchor = binding.inGameMenu.findViewById<View>(R.id.menu_overlay_controls)
val popup = PopupMenu(requireContext(), anchor)
popup.menuInflater.inflate(R.menu.menu_overlay_options, popup.menu)
popup.menu.apply {
findItem(R.id.menu_toggle_fps).isChecked = EmulationMenuSettings.showFps
findItem(R.id.menu_rel_stick_center).isChecked = EmulationMenuSettings.joystickRelCenter
findItem(R.id.menu_dpad_slide).isChecked = EmulationMenuSettings.dpadSlide
findItem(R.id.menu_show_overlay).isChecked = EmulationMenuSettings.showOverlay
findItem(R.id.menu_haptics).isChecked = EmulationMenuSettings.hapticFeedback
}
popup.setOnMenuItemClickListener {
when (it.itemId) {
R.id.menu_toggle_fps -> {
it.isChecked = !it.isChecked
EmulationMenuSettings.showFps = it.isChecked
updateShowFpsOverlay()
true
}
R.id.menu_edit_overlay -> {
binding.drawerLayout.close()
binding.surfaceInputOverlay.requestFocus()
startConfiguringControls()
true
}
R.id.menu_adjust_overlay -> {
adjustOverlay()
true
}
R.id.menu_toggle_controls -> {
val preferences =
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
val optionsArray = BooleanArray(15)
for (i in 0..14) {
optionsArray[i] = preferences.getBoolean("buttonToggle$i", i < 13)
}
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.emulation_toggle_controls)
.setMultiChoiceItems(
R.array.gamepadButtons,
optionsArray
) { _, indexSelected, isChecked ->
preferences.edit()
.putBoolean("buttonToggle$indexSelected", isChecked)
.apply()
}
.setPositiveButton(android.R.string.ok) { _, _ ->
refreshInputOverlay()
}
.setNeutralButton(R.string.emulation_toggle_all) { _, _ -> }
.show()
// Override normal behaviour so the dialog doesn't close
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)
.setOnClickListener {
val isChecked = !optionsArray[0]
for (i in 0..14) {
optionsArray[i] = isChecked
dialog.listView.setItemChecked(i, isChecked)
preferences.edit()
.putBoolean("buttonToggle$i", isChecked)
.apply()
}
}
true
}
R.id.menu_show_overlay -> {
it.isChecked = !it.isChecked
EmulationMenuSettings.showOverlay = it.isChecked
refreshInputOverlay()
true
}
R.id.menu_rel_stick_center -> {
it.isChecked = !it.isChecked
EmulationMenuSettings.joystickRelCenter = it.isChecked
true
}
R.id.menu_dpad_slide -> {
it.isChecked = !it.isChecked
EmulationMenuSettings.dpadSlide = it.isChecked
true
}
R.id.menu_haptics -> {
it.isChecked = !it.isChecked
EmulationMenuSettings.hapticFeedback = it.isChecked
true
}
R.id.menu_reset_overlay -> {
binding.drawerLayout.close()
resetInputOverlay()
true
}
else -> true
}
}
popup.show()
}
private fun startConfiguringControls() {
binding.doneControlConfig.visibility = View.VISIBLE
binding.surfaceInputOverlay.setIsInEditMode(true)
}
private fun stopConfiguringControls() {
binding.doneControlConfig.visibility = View.GONE
binding.surfaceInputOverlay.setIsInEditMode(false)
}
@SuppressLint("SetTextI18n")
private fun adjustOverlay() {
val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater)
adjustBinding.apply {
inputScaleSlider.apply {
valueTo = 150F
value = preferences.getInt(Settings.PREF_CONTROL_SCALE, 50).toFloat()
addOnChangeListener(Slider.OnChangeListener { _, value, _ ->
inputScaleValue.text = "${value.toInt()}%"
setControlScale(value.toInt())
})
}
inputOpacitySlider.apply {
valueTo = 100F
value = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100).toFloat()
addOnChangeListener(Slider.OnChangeListener { _, value, _ ->
inputOpacityValue.text = "${value.toInt()}%"
setControlOpacity(value.toInt())
})
}
inputScaleValue.text = "${inputScaleSlider.value.toInt()}%"
inputOpacityValue.text = "${inputOpacitySlider.value.toInt()}%"
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.emulation_control_adjust)
.setView(adjustBinding.root)
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int ->
setControlScale(50)
setControlOpacity(100)
}
.show()
}
private fun setControlScale(scale: Int) {
preferences.edit()
.putInt(Settings.PREF_CONTROL_SCALE, scale)
.apply()
refreshInputOverlay()
}
private fun setControlOpacity(opacity: Int) {
preferences.edit()
.putInt(Settings.PREF_CONTROL_OPACITY, opacity)
.apply()
refreshInputOverlay()
}
private fun setInsets() {
ViewCompat.setOnApplyWindowInsetsListener(binding.inGameMenu) { v: View, windowInsets: WindowInsetsCompat ->
val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
var left = 0
var right = 0
if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) {
left = cutInsets.left
} else {
right = cutInsets.right
}
v.setPadding(left, cutInsets.top, right, 0)
// Ensure FPS text doesn't get cut off by rounded display corners
val sidePadding = resources.getDimensionPixelSize(R.dimen.spacing_xtralarge)
if (cutInsets.left == 0) {
binding.showFpsText.setPadding(
sidePadding,
cutInsets.top,
cutInsets.right,
cutInsets.bottom
)
} else {
binding.showFpsText.setPadding(
cutInsets.left,
cutInsets.top,
cutInsets.right,
cutInsets.bottom
)
}
windowInsets
}
}
private class EmulationState(private val gamePath: String) {
private var state: State
private var surface: Surface? = null
private var runWhenSurfaceIsValid = false
init {
// Starting state is stopped.
state = State.STOPPED
}
@get:Synchronized
val isStopped: Boolean
get() = state == State.STOPPED
// Getters for the current state
@get:Synchronized
val isPaused: Boolean
get() = state == State.PAUSED
@get:Synchronized
val isRunning: Boolean
get() = state == State.RUNNING
@Synchronized
fun stop() {
if (state != State.STOPPED) {
Log.debug("[EmulationFragment] Stopping emulation.")
NativeLibrary.stopEmulation()
state = State.STOPPED
} else {
Log.warning("[EmulationFragment] Stop called while already stopped.")
}
}
// State changing methods
@Synchronized
fun pause() {
if (state != State.PAUSED) {
Log.debug("[EmulationFragment] Pausing emulation.")
// Release the surface before pausing, since emulation has to be running for that.
NativeLibrary.surfaceDestroyed()
NativeLibrary.pauseEmulation()
state = State.PAUSED
} else {
Log.warning("[EmulationFragment] Pause called while already paused.")
}
}
@Synchronized
fun run(isActivityRecreated: Boolean) {
if (isActivityRecreated) {
if (NativeLibrary.isRunning()) {
state = State.PAUSED
}
} else {
Log.debug("[EmulationFragment] activity resumed or fresh start")
}
// If the surface is set, run now. Otherwise, wait for it to get set.
if (surface != null) {
runWithValidSurface()
} else {
runWhenSurfaceIsValid = true
}
}
// Surface callbacks
@Synchronized
fun newSurface(surface: Surface?) {
this.surface = surface
if (runWhenSurfaceIsValid) {
runWithValidSurface()
}
}
@Synchronized
fun clearSurface() {
if (surface == null) {
Log.warning("[EmulationFragment] clearSurface called, but surface already null.")
} else {
surface = null
Log.debug("[EmulationFragment] Surface destroyed.")
when (state) {
State.RUNNING -> {
NativeLibrary.surfaceDestroyed()
state = State.PAUSED
}
State.PAUSED -> Log.warning("[EmulationFragment] Surface cleared while emulation paused.")
else -> Log.warning("[EmulationFragment] Surface cleared while emulation stopped.")
}
}
}
private fun runWithValidSurface() {
runWhenSurfaceIsValid = false
when (state) {
State.STOPPED -> {
NativeLibrary.surfaceChanged(surface)
val emulationThread = Thread({
Log.debug("[EmulationFragment] Starting emulation thread.")
NativeLibrary.run(gamePath)
}, "NativeEmulation")
emulationThread.start()
}
State.PAUSED -> {
Log.debug("[EmulationFragment] Resuming emulation.")
NativeLibrary.surfaceChanged(surface)
NativeLibrary.unPauseEmulation()
}
else -> Log.debug("[EmulationFragment] Bug, run called while already running.")
}
state = State.RUNNING
}
private enum class State {
STOPPED, RUNNING, PAUSED
}
}
companion object {
private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!)
fun newInstance(game: Game): EmulationFragment {
val args = Bundle()
args.putParcelable(EmulationActivity.EXTRA_SELECTED_GAME, game)
val fragment = EmulationFragment()
fragment.arguments = args
return fragment
}
}
}

View File

@@ -0,0 +1,296 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.DocumentsContract
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.transition.MaterialSharedAxis
import org.yuzu.yuzu_emu.BuildConfig
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter
import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding
import org.yuzu.yuzu_emu.features.DocumentProvider
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import org.yuzu.yuzu_emu.model.HomeSetting
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.ui.main.MainActivity
import org.yuzu.yuzu_emu.utils.GpuDriverHelper
class HomeSettingsFragment : Fragment() {
private var _binding: FragmentHomeSettingsBinding? = null
private val binding get() = _binding!!
private lateinit var mainActivity: MainActivity
private val homeViewModel: HomeViewModel by activityViewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentHomeSettingsBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
mainActivity = requireActivity() as MainActivity
val optionsList: MutableList<HomeSetting> = mutableListOf(
HomeSetting(
R.string.advanced_settings,
R.string.settings_description,
R.drawable.ic_settings
) { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") },
HomeSetting(
R.string.open_user_folder,
R.string.open_user_folder_description,
R.drawable.ic_folder_open
) { openFileManager() },
HomeSetting(
R.string.preferences_theme,
R.string.theme_and_color_description,
R.drawable.ic_palette
) { SettingsActivity.launch(requireContext(), Settings.SECTION_THEME, "") },
HomeSetting(
R.string.install_gpu_driver,
R.string.install_gpu_driver_description,
R.drawable.ic_exit
) { driverInstaller() },
HomeSetting(
R.string.install_amiibo_keys,
R.string.install_amiibo_keys_description,
R.drawable.ic_nfc
) { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) },
HomeSetting(
R.string.select_games_folder,
R.string.select_games_folder_description,
R.drawable.ic_add
) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
HomeSetting(
R.string.import_export_saves,
R.string.import_export_saves_description,
R.drawable.ic_save
) { ImportExportSavesFragment().show(parentFragmentManager, ImportExportSavesFragment.TAG) },
HomeSetting(
R.string.install_prod_keys,
R.string.install_prod_keys_description,
R.drawable.ic_unlock
) { mainActivity.getProdKey.launch(arrayOf("*/*")) },
HomeSetting(
R.string.about,
R.string.about_description,
R.drawable.ic_info_outline
) {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
parentFragmentManager.primaryNavigationFragment?.findNavController()
?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment)
}
)
if (!BuildConfig.PREMIUM) {
optionsList.add(
0,
HomeSetting(
R.string.get_early_access,
R.string.get_early_access_description,
R.drawable.ic_diamond
) {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
parentFragmentManager.primaryNavigationFragment?.findNavController()
?.navigate(R.id.action_homeSettingsFragment_to_earlyAccessFragment)
}
)
}
binding.homeSettingsList.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = HomeSettingAdapter(requireActivity() as AppCompatActivity, optionsList)
}
setInsets()
}
override fun onStart() {
super.onStart()
exitTransition = null
homeViewModel.setNavigationVisibility(visible = true, animated = true)
homeViewModel.setStatusBarShadeVisibility(visible = true)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun openFileManager() {
// First, try to open the user data folder directly
try {
startActivity(getFileManagerIntentOnDocumentProvider(Intent.ACTION_VIEW))
return
} catch (_: ActivityNotFoundException) {
}
try {
startActivity(getFileManagerIntentOnDocumentProvider("android.provider.action.BROWSE"))
return
} catch (_: ActivityNotFoundException) {
}
// Just try to open the file manager, try the package name used on "normal" phones
try {
startActivity(getFileManagerIntent("com.google.android.documentsui"))
showNoLinkNotification()
return
} catch (_: ActivityNotFoundException) {
}
try {
// Next, try the AOSP package name
startActivity(getFileManagerIntent("com.android.documentsui"))
showNoLinkNotification()
return
} catch (_: ActivityNotFoundException) {
}
Toast.makeText(
requireContext(),
resources.getString(R.string.no_file_manager),
Toast.LENGTH_LONG
).show()
}
private fun getFileManagerIntent(packageName: String): Intent {
// Fragile, but some phones don't expose the system file manager in any better way
val intent = Intent(Intent.ACTION_MAIN)
intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
return intent
}
private fun getFileManagerIntentOnDocumentProvider(action: String): Intent {
val authority = "${requireContext().packageName}.user"
val intent = Intent(action)
intent.addCategory(Intent.CATEGORY_DEFAULT)
intent.data = DocumentsContract.buildRootUri(authority, DocumentProvider.ROOT_ID)
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
return intent
}
private fun showNoLinkNotification() {
val builder = NotificationCompat.Builder(
requireContext(),
getString(R.string.notice_notification_channel_id)
)
.setSmallIcon(R.drawable.ic_stat_notification_logo)
.setContentTitle(getString(R.string.notification_no_directory_link))
.setContentText(getString(R.string.notification_no_directory_link_description))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
// TODO: Make the click action for this notification lead to a help article
with(NotificationManagerCompat.from(requireContext())) {
if (ActivityCompat.checkSelfPermission(
requireContext(),
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
Toast.makeText(
requireContext(),
resources.getString(R.string.notification_permission_not_granted),
Toast.LENGTH_LONG
).show()
return
}
notify(0, builder.build())
}
}
private fun driverInstaller() {
// Get the driver name for the dialog message.
var driverName = GpuDriverHelper.customDriverName
if (driverName == null) {
driverName = getString(R.string.system_gpu_driver)
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.select_gpu_driver_title))
.setMessage(driverName)
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int ->
GpuDriverHelper.installDefaultDriver(requireContext())
Toast.makeText(
requireContext(),
R.string.select_gpu_driver_use_default,
Toast.LENGTH_SHORT
).show()
}
.setPositiveButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int ->
mainActivity.getDriver.launch(arrayOf("application/zip"))
}
.show()
}
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
val spacingNavigationRail =
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
val leftInsets = barInsets.left + cutoutInsets.left
val rightInsets = barInsets.right + cutoutInsets.right
binding.scrollViewSettings.updatePadding(
top = barInsets.top,
bottom = barInsets.bottom
)
val mlpScrollSettings = binding.scrollViewSettings.layoutParams as MarginLayoutParams
mlpScrollSettings.leftMargin = leftInsets
mlpScrollSettings.rightMargin = rightInsets
binding.scrollViewSettings.layoutParams = mlpScrollSettings
binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation)
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail)
} else {
binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail)
}
windowInsets
}
}

View File

@@ -0,0 +1,234 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.app.Dialog
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.features.DocumentProvider
import org.yuzu.yuzu_emu.getPublicFilesDir
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.FilenameFilter
import java.io.IOException
import java.io.InputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class ImportExportSavesFragment : DialogFragment() {
private val context = YuzuApplication.appContext
private val savesFolder =
"${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
// Get first subfolder in saves folder (should be the user folder)
private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
private var lastZipCreated: File? = null
private lateinit var startForResultExportSave: ActivityResultLauncher<Intent>
private lateinit var documentPicker: ActivityResultLauncher<Array<String>>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val activityResultRegistry = requireActivity().activityResultRegistry
startForResultExportSave = activityResultRegistry.register(
"startForResultExportSaveKey",
ActivityResultContracts.StartActivityForResult()
) {
File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
}
documentPicker = activityResultRegistry.register(
"documentPickerKey",
ActivityResultContracts.OpenDocument()
) {
it?.let { uri -> importSave(uri) }
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return if (savesFolderRoot == "") {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.import_export_saves)
.setMessage(R.string.import_export_saves_no_profile)
.setPositiveButton(android.R.string.ok, null)
.show()
} else {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.import_export_saves)
.setPositiveButton(R.string.export_saves) { _, _ ->
exportSave()
}
.setNeutralButton(R.string.import_saves) { _, _ ->
documentPicker.launch(arrayOf("application/zip"))
}
.show()
}
}
/**
* Zips the save files located in the given folder path and creates a new zip file with the current date and time.
* @return true if the zip file is successfully created, false otherwise.
*/
private fun zipSave(): Boolean {
try {
val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp")
tempFolder.mkdirs()
val saveFolder = File(savesFolderRoot)
val outputZipFile = File(
tempFolder, "yuzu saves - ${LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))}.zip"
)
outputZipFile.createNewFile()
ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos ->
saveFolder.walkTopDown().forEach { file ->
val zipFileName =
file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/")
if (zipFileName == "")
return@forEach
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
zos.putNextEntry(entry)
if (file.isFile)
file.inputStream().use { fis -> fis.copyTo(zos) }
}
}
lastZipCreated = outputZipFile
} catch (e: Exception) {
return false
}
return true
}
/**
* Extracts the save files located in the given zip file and copies them to the saves folder.
* @exception IOException if the file was being created outside of the target directory
*/
private fun unzip(zipStream: InputStream, destDir: File): Boolean {
val zis = ZipInputStream(BufferedInputStream(zipStream))
var entry: ZipEntry? = zis.nextEntry
while (entry != null) {
val entryName = entry.name
val entryFile = File(destDir, entryName)
if (!entryFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) {
zis.close()
throw IOException("Entry is outside of the target dir: " + entryFile.name)
}
if (entry.isDirectory) {
entryFile.mkdirs()
} else {
entryFile.parentFile?.mkdirs()
entryFile.createNewFile()
entryFile.outputStream().use { fos -> zis.copyTo(fos) }
}
entry = zis.nextEntry
}
zis.close()
return true
}
/**
* Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
*/
private fun exportSave() {
CoroutineScope(Dispatchers.IO).launch {
val wasZipCreated = zipSave()
val lastZipFile = lastZipCreated
if (!wasZipCreated || lastZipFile == null) {
withContext(Dispatchers.Main) {
Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show()
}
return@launch
}
withContext(Dispatchers.Main) {
val file = DocumentFile.fromSingleUri(
context, DocumentsContract.buildDocumentUri(
DocumentProvider.AUTHORITY,
"${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}"
)
)!!
val intent = Intent(Intent.ACTION_SEND)
.setDataAndType(file.uri, "application/zip")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(Intent.EXTRA_STREAM, file.uri)
startForResultExportSave.launch(Intent.createChooser(intent, "Share save file"))
}
}
}
/**
* Imports the save files contained in the zip file, and replaces any existing ones with the new save file.
* @param zipUri The Uri of the zip file containing the save file(s) to import.
*/
private fun importSave(zipUri: Uri) {
val inputZip = context.contentResolver.openInputStream(zipUri)
// A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
var validZip = false
val savesFolder = File(savesFolderRoot)
val cacheSaveDir = File("${context.cacheDir.path}/saves/")
cacheSaveDir.mkdir()
if (inputZip == null) {
Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
.show()
return
}
val filterTitleId =
FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
try {
CoroutineScope(Dispatchers.IO).launch {
unzip(inputZip, cacheSaveDir)
cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
File(savesFolder, savePath).deleteRecursively()
File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true)
validZip = true
}
withContext(Dispatchers.Main) {
if (!validZip) {
Toast.makeText(
context,
context.getString(R.string.save_file_invalid_zip_structure),
Toast.LENGTH_LONG
).show()
return@withContext
}
Toast.makeText(
context,
context.getString(R.string.save_file_imported_success),
Toast.LENGTH_LONG
).show()
}
cacheSaveDir.deleteRecursively()
}
} catch (e: Exception) {
Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
.show()
}
}
companion object {
const val TAG = "ImportExportSavesFragment"
}
}

View File

@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.app.Dialog
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R
class PermissionDeniedDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(requireContext())
.setPositiveButton(R.string.home_settings) { _: DialogInterface?, _: Int ->
openSettings()
}
.setNegativeButton(android.R.string.cancel, null)
.setTitle(R.string.permission_denied)
.setMessage(R.string.permission_denied_description)
.show()
}
private fun openSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", requireActivity().packageName, null)
intent.data = uri
startActivity(intent)
}
companion object {
const val TAG = "PermissionDeniedDialogFragment"
}
}

View File

@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
class ResetSettingsDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val settingsActivity = requireActivity() as SettingsActivity
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.reset_all_settings)
.setMessage(R.string.reset_all_settings_description)
.setPositiveButton(android.R.string.ok) { _, _ ->
settingsActivity.onSettingsReset()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
companion object {
const val TAG = "ResetSettingsDialogFragment"
}
}

View File

@@ -0,0 +1,236 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.preference.PreferenceManager
import info.debatty.java.stringsimilarity.Jaccard
import info.debatty.java.stringsimilarity.JaroWinkler
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.adapters.GameAdapter
import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding
import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
import org.yuzu.yuzu_emu.model.Game
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.utils.FileUtil
import org.yuzu.yuzu_emu.utils.Log
import java.util.Locale
class SearchFragment : Fragment() {
private var _binding: FragmentSearchBinding? = null
private val binding get() = _binding!!
private val gamesViewModel: GamesViewModel by activityViewModels()
private val homeViewModel: HomeViewModel by activityViewModels()
private lateinit var preferences: SharedPreferences
companion object {
private const val SEARCH_TEXT = "SearchText"
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSearchBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
homeViewModel.setNavigationVisibility(visible = true, animated = false)
preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
if (savedInstanceState != null) {
binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT))
}
binding.gridGamesSearch.apply {
layoutManager = AutofitGridLayoutManager(
requireContext(),
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
)
adapter = GameAdapter(requireActivity() as AppCompatActivity)
}
binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() }
binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int ->
if (text.toString().isNotEmpty()) {
binding.clearButton.visibility = View.VISIBLE
} else {
binding.clearButton.visibility = View.INVISIBLE
}
filterAndSearch()
}
gamesViewModel.apply {
searchFocused.observe(viewLifecycleOwner) { searchFocused ->
if (searchFocused) {
focusSearch()
gamesViewModel.setSearchFocused(false)
}
}
games.observe(viewLifecycleOwner) { filterAndSearch() }
searchedGames.observe(viewLifecycleOwner) {
(binding.gridGamesSearch.adapter as GameAdapter).submitList(it)
if (it.isEmpty()) {
binding.noResultsView.visibility = View.VISIBLE
} else {
binding.noResultsView.visibility = View.GONE
}
}
}
binding.clearButton.setOnClickListener { binding.searchText.setText("") }
binding.searchBackground.setOnClickListener { focusSearch() }
setInsets()
filterAndSearch()
}
private inner class ScoredGame(val score: Double, val item: Game)
private fun filterAndSearch() {
val baseList = gamesViewModel.games.value!!
val filteredList: List<Game> = when (binding.chipGroup.checkedChipId) {
R.id.chip_recently_played -> {
baseList.filter {
val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L)
lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
}
}
R.id.chip_recently_added -> {
baseList.filter {
val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
}
}
R.id.chip_homebrew -> {
baseList.filter {
Log.error("Guh - ${it.path}")
FileUtil.hasExtension(it.path, "nro")
|| FileUtil.hasExtension(it.path, "nso")
}
}
R.id.chip_retail -> baseList.filter {
FileUtil.hasExtension(it.path, "xci")
|| FileUtil.hasExtension(it.path, "nsp")
}
else -> baseList
}
if (binding.searchText.text.toString().isEmpty()
&& binding.chipGroup.checkedChipId != View.NO_ID
) {
gamesViewModel.setSearchedGames(filteredList)
return
}
val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler()
val sortedList: List<Game> = filteredList.mapNotNull { game ->
val title = game.title.lowercase(Locale.getDefault())
val score = searchAlgorithm.similarity(searchTerm, title)
if (score > 0.03) {
ScoredGame(score, game)
} else {
null
}
}.sortedByDescending { it.score }.map { it.item }
gamesViewModel.setSearchedGames(sortedList)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (_binding != null) {
outState.putString(SEARCH_TEXT, binding.searchText.text.toString())
}
}
private fun focusSearch() {
if (_binding != null) {
binding.searchText.requestFocus()
val imm =
requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
}
}
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med)
val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
val spacingNavigationRail =
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip)
binding.constraintSearch.updatePadding(
left = barInsets.left + cutoutInsets.left,
top = barInsets.top,
right = barInsets.right + cutoutInsets.right
)
binding.gridGamesSearch.updatePadding(
top = extraListSpacing,
bottom = barInsets.bottom + spacingNavigation + extraListSpacing
)
binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom)
val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
binding.frameSearch.updatePadding(left = spacingNavigationRail)
binding.gridGamesSearch.updatePadding(left = spacingNavigationRail)
binding.noResultsView.updatePadding(left = spacingNavigationRail)
binding.chipGroup.updatePadding(
left = chipSpacing + spacingNavigationRail,
right = chipSpacing
)
mlpDivider.leftMargin = chipSpacing + spacingNavigationRail
mlpDivider.rightMargin = chipSpacing
} else {
binding.frameSearch.updatePadding(right = spacingNavigationRail)
binding.gridGamesSearch.updatePadding(right = spacingNavigationRail)
binding.noResultsView.updatePadding(right = spacingNavigationRail)
binding.chipGroup.updatePadding(
left = chipSpacing,
right = chipSpacing + spacingNavigationRail
)
mlpDivider.leftMargin = chipSpacing
mlpDivider.rightMargin = chipSpacing + spacingNavigationRail
}
binding.divider.layoutParams = mlpDivider
windowInsets
}
}

View File

@@ -0,0 +1,329 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.preference.PreferenceManager
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.transition.MaterialFadeThrough
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.adapters.SetupAdapter
import org.yuzu.yuzu_emu.databinding.FragmentSetupBinding
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.model.SetupPage
import org.yuzu.yuzu_emu.ui.main.MainActivity
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.GameHelper
import java.io.File
class SetupFragment : Fragment() {
private var _binding: FragmentSetupBinding? = null
private val binding get() = _binding!!
private val homeViewModel: HomeViewModel by activityViewModels()
private lateinit var mainActivity: MainActivity
private lateinit var hasBeenWarned: BooleanArray
companion object {
const val KEY_NEXT_VISIBILITY = "NextButtonVisibility"
const val KEY_BACK_VISIBILITY = "BackButtonVisibility"
const val KEY_HAS_BEEN_WARNED = "HasBeenWarned"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
exitTransition = MaterialFadeThrough()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSetupBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
mainActivity = requireActivity() as MainActivity
homeViewModel.setNavigationVisibility(visible = false, animated = false)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (binding.viewPager2.currentItem > 0) {
pageBackward()
} else {
requireActivity().finish()
}
}
})
requireActivity().window.navigationBarColor =
ContextCompat.getColor(requireContext(), android.R.color.transparent)
val pages = mutableListOf<SetupPage>()
pages.apply {
add(
SetupPage(
R.drawable.ic_yuzu_title,
R.string.welcome,
R.string.welcome_description,
0,
true,
R.string.get_started,
{ pageForward() },
false
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(
SetupPage(
R.drawable.ic_notification,
R.string.notifications,
R.string.notifications_description,
0,
false,
R.string.give_permission,
{ permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
true,
R.string.notification_warning,
R.string.notification_warning_description,
0,
{
NotificationManagerCompat.from(requireContext())
.areNotificationsEnabled()
}
)
)
}
add(
SetupPage(
R.drawable.ic_key,
R.string.keys,
R.string.keys_description,
R.drawable.ic_add,
true,
R.string.select_keys,
{ mainActivity.getProdKey.launch(arrayOf("*/*")) },
true,
R.string.install_prod_keys_warning,
R.string.install_prod_keys_warning_description,
R.string.install_prod_keys_warning_help,
{ File(DirectoryInitialization.userDirectory + "/keys/prod.keys").exists() }
)
)
add(
SetupPage(
R.drawable.ic_controller,
R.string.games,
R.string.games_description,
R.drawable.ic_add,
true,
R.string.add_games,
{ mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
true,
R.string.add_games_warning,
R.string.add_games_warning_description,
R.string.add_games_warning_help,
{
val preferences =
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty()
}
)
)
add(
SetupPage(
R.drawable.ic_check,
R.string.done,
R.string.done_description,
R.drawable.ic_arrow_forward,
false,
R.string.text_continue,
{ finishSetup() },
false
)
)
}
binding.viewPager2.apply {
adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages)
offscreenPageLimit = 2
isUserInputEnabled = false
}
binding.viewPager2.registerOnPageChangeCallback(object : OnPageChangeCallback() {
var previousPosition: Int = 0
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
if (position == 1 && previousPosition == 0) {
showView(binding.buttonNext)
showView(binding.buttonBack)
} else if (position == 0 && previousPosition == 1) {
hideView(binding.buttonBack)
hideView(binding.buttonNext)
} else if (position == pages.size - 1 && previousPosition == pages.size - 2) {
hideView(binding.buttonNext)
} else if (position == pages.size - 2 && previousPosition == pages.size - 1) {
showView(binding.buttonNext)
}
previousPosition = position
}
})
binding.buttonNext.setOnClickListener {
val index = binding.viewPager2.currentItem
val currentPage = pages[index]
// Checks if the user has completed the task on the current page
if (currentPage.hasWarning) {
if (currentPage.taskCompleted.invoke()) {
pageForward()
return@setOnClickListener
}
if (!hasBeenWarned[index]) {
SetupWarningDialogFragment.newInstance(
currentPage.warningTitleId,
currentPage.warningDescriptionId,
currentPage.warningHelpLinkId,
index
).show(childFragmentManager, SetupWarningDialogFragment.TAG)
return@setOnClickListener
}
}
pageForward()
}
binding.buttonBack.setOnClickListener { pageBackward() }
if (savedInstanceState != null) {
val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY)
val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY)
hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!!
if (nextIsVisible) {
binding.buttonNext.visibility = View.VISIBLE
}
if (backIsVisible) {
binding.buttonBack.visibility = View.VISIBLE
}
} else {
hasBeenWarned = BooleanArray(pages.size)
}
setInsets()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible)
outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible)
outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (!it && !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
PermissionDeniedDialogFragment().show(
childFragmentManager,
PermissionDeniedDialogFragment.TAG
)
}
}
private fun finishSetup() {
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit()
.putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false)
.apply()
mainActivity.finishSetup(binding.root.findNavController())
}
private fun showView(view: View) {
view.apply {
alpha = 0f
visibility = View.VISIBLE
isClickable = true
}.animate().apply {
duration = 300
alpha(1f)
}.start()
}
private fun hideView(view: View) {
if (view.visibility == View.INVISIBLE) {
return
}
view.apply {
alpha = 1f
isClickable = false
}.animate().apply {
duration = 300
alpha(0f)
}.withEndAction {
view.visibility = View.INVISIBLE
}
}
fun pageForward() {
binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1
}
fun pageBackward() {
binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1
}
fun setPageWarned(page: Int) {
hasBeenWarned[page] = true
}
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
view.setPadding(
barInsets.left + cutoutInsets.left,
barInsets.top + cutoutInsets.top,
barInsets.right + cutoutInsets.right,
barInsets.bottom + cutoutInsets.bottom
)
windowInsets
}
}

View File

@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.fragments
import android.app.Dialog
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.yuzu.yuzu_emu.R
class SetupWarningDialogFragment : DialogFragment() {
private var titleId: Int = 0
private var descriptionId: Int = 0
private var helpLinkId: Int = 0
private var page: Int = 0
private lateinit var setupFragment: SetupFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
titleId = requireArguments().getInt(TITLE)
descriptionId = requireArguments().getInt(DESCRIPTION)
helpLinkId = requireArguments().getInt(HELP_LINK)
page = requireArguments().getInt(PAGE)
setupFragment = requireParentFragment() as SetupFragment
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = MaterialAlertDialogBuilder(requireContext())
.setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int ->
setupFragment.pageForward()
setupFragment.setPageWarned(page)
}
.setNegativeButton(R.string.warning_cancel, null)
if (titleId != 0) {
builder.setTitle(titleId)
} else {
builder.setTitle("")
}
if (descriptionId != 0) {
builder.setMessage(descriptionId)
}
if (helpLinkId != 0) {
builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int ->
val helpLink = resources.getString(R.string.install_prod_keys_warning_help)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink))
startActivity(intent)
}
}
return builder.show()
}
companion object {
const val TAG = "SetupWarningDialogFragment"
private const val TITLE = "Title"
private const val DESCRIPTION = "Description"
private const val HELP_LINK = "HelpLink"
private const val PAGE = "Page"
fun newInstance(
titleId: Int,
descriptionId: Int,
helpLinkId: Int,
page: Int
): SetupWarningDialogFragment {
val dialog = SetupWarningDialogFragment()
val bundle = Bundle()
bundle.apply {
putInt(TITLE, titleId)
putInt(DESCRIPTION, descriptionId)
putInt(HELP_LINK, helpLinkId)
putInt(PAGE, page)
}
dialog.arguments = bundle
return dialog
}
}
}

View File

@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.layout
import android.content.Context
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Recycler
import org.yuzu.yuzu_emu.R
/**
* Cut down version of the solution provided here
* https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count
*/
class AutofitGridLayoutManager(
context: Context,
columnWidth: Int
) : GridLayoutManager(context, 1) {
private var columnWidth = 0
private var isColumnWidthChanged = true
private var lastWidth = 0
private var lastHeight = 0
init {
setColumnWidth(checkedColumnWidth(context, columnWidth))
}
private fun checkedColumnWidth(context: Context, columnWidth: Int): Int {
var newColumnWidth = columnWidth
if (newColumnWidth <= 0) {
newColumnWidth = context.resources.getDimensionPixelSize(R.dimen.spacing_xtralarge)
}
return newColumnWidth
}
private fun setColumnWidth(newColumnWidth: Int) {
if (newColumnWidth > 0 && newColumnWidth != columnWidth) {
columnWidth = newColumnWidth
isColumnWidthChanged = true
}
}
override fun onLayoutChildren(recycler: Recycler, state: RecyclerView.State) {
val width = width
val height = height
if (columnWidth > 0 && width > 0 && height > 0 && (isColumnWidthChanged || lastWidth != width || lastHeight != height)) {
val totalSpace: Int = if (orientation == VERTICAL) {
width - paddingRight - paddingLeft
} else {
height - paddingTop - paddingBottom
}
val spanCount = 1.coerceAtLeast(totalSpace / columnWidth)
setSpanCount(spanCount)
isColumnWidthChanged = false
}
lastWidth = width
lastHeight = height
super.onLayoutChildren(recycler, state)
}
}

View File

@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import java.util.HashSet
@Parcelize
@Serializable
class Game(
val title: String,
val description: String,
val regions: String,
val path: String,
val gameId: String,
val company: String
) : Parcelable {
val keyAddedToLibraryTime get() = "${gameId}_AddedToLibraryTime"
val keyLastPlayedTime get() = "${gameId}_LastPlayed"
override fun equals(other: Any?): Boolean {
if (other !is Game)
return false
return title == other.title
&& description == other.description
&& regions == other.regions
&& path == other.path
&& gameId == other.gameId
&& company == other.company
}
companion object {
val extensions: Set<String> = HashSet(
listOf(".xci", ".nsp", ".nca", ".nro")
)
}
}

View File

@@ -0,0 +1,109 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.model
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.preference.PreferenceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.utils.GameHelper
import java.util.Locale
class GamesViewModel : ViewModel() {
private val _games = MutableLiveData<List<Game>>(emptyList())
val games: LiveData<List<Game>> get() = _games
private val _searchedGames = MutableLiveData<List<Game>>(emptyList())
val searchedGames: LiveData<List<Game>> get() = _searchedGames
private val _isReloading = MutableLiveData(false)
val isReloading: LiveData<Boolean> get() = _isReloading
private val _shouldSwapData = MutableLiveData(false)
val shouldSwapData: LiveData<Boolean> get() = _shouldSwapData
private val _shouldScrollToTop = MutableLiveData(false)
val shouldScrollToTop: LiveData<Boolean> get() = _shouldScrollToTop
private val _searchFocused = MutableLiveData(false)
val searchFocused: LiveData<Boolean> get() = _searchFocused
init {
// Ensure keys are loaded so that ROM metadata can be decrypted.
NativeLibrary.reloadKeys()
// Retrieve list of cached games
val storedGames = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
.getStringSet(GameHelper.KEY_GAMES, emptySet())
if (storedGames!!.isNotEmpty()) {
val deserializedGames = mutableSetOf<Game>()
storedGames.forEach {
val game: Game = Json.decodeFromString(it)
val gameExists =
DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(game.path))
?.exists()
if (gameExists == true) {
deserializedGames.add(game)
}
}
setGames(deserializedGames.toList())
}
reloadGames(false)
}
fun setGames(games: List<Game>) {
val sortedList = games.sortedWith(
compareBy(
{ it.title.lowercase(Locale.getDefault()) },
{ it.path }
)
)
_games.postValue(sortedList)
}
fun setSearchedGames(games: List<Game>) {
_searchedGames.postValue(games)
}
fun setShouldSwapData(shouldSwap: Boolean) {
_shouldSwapData.postValue(shouldSwap)
}
fun setShouldScrollToTop(shouldScroll: Boolean) {
_shouldScrollToTop.postValue(shouldScroll)
}
fun setSearchFocused(searchFocused: Boolean) {
_searchFocused.postValue(searchFocused)
}
fun reloadGames(directoryChanged: Boolean) {
if (isReloading.value == true)
return
_isReloading.postValue(true)
viewModelScope.launch {
withContext(Dispatchers.IO) {
NativeLibrary.resetRomMetadata()
setGames(GameHelper.getGames())
_isReloading.postValue(false)
if (directoryChanged) {
setShouldSwapData(true)
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.model
data class HomeSetting(
val titleId: Int,
val descriptionId: Int,
val iconId: Int,
val onClick: () -> Unit
)

View File

@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.model
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class HomeViewModel : ViewModel() {
private val _navigationVisible = MutableLiveData<Pair<Boolean, Boolean>>()
val navigationVisible: LiveData<Pair<Boolean, Boolean>> get() = _navigationVisible
private val _statusBarShadeVisible = MutableLiveData(true)
val statusBarShadeVisible: LiveData<Boolean> get() = _statusBarShadeVisible
var navigatedToSetup = false
init {
_navigationVisible.value = Pair(false, false)
}
fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
if (_navigationVisible.value?.first == visible) {
return
}
_navigationVisible.value = Pair(visible, animated)
}
fun setStatusBarShadeVisibility(visible: Boolean) {
if (_statusBarShadeVisible.value == visible) {
return
}
_statusBarShadeVisible.value = visible
}
}

View File

@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.model
import android.net.Uri
import android.provider.DocumentsContract
class MinimalDocumentFile(val filename: String, mimeType: String, val uri: Uri) {
val isDirectory: Boolean = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
}

View File

@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.model
data class SetupPage(
val iconId: Int,
val titleId: Int,
val descriptionId: Int,
val buttonIconId: Int,
val leftAlignedIcon: Boolean,
val buttonTextId: Int,
val buttonAction: () -> Unit,
val hasWarning: Boolean,
val warningTitleId: Int = 0,
val warningDescriptionId: Int = 0,
val warningHelpLinkId: Int = 0,
val taskCompleted: () -> Boolean = { true }
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.overlay
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import android.view.MotionEvent
import org.yuzu.yuzu_emu.NativeLibrary.ButtonState
/**
* Custom [BitmapDrawable] that is capable
* of storing it's own ID.
*
* @param res [Resources] instance.
* @param defaultStateBitmap [Bitmap] to use with the default state Drawable.
* @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable.
* @param buttonId Identifier for this type of button.
*/
class InputOverlayDrawableButton(
res: Resources,
defaultStateBitmap: Bitmap,
pressedStateBitmap: Bitmap,
val buttonId: Int
) {
// The ID value what motion event is tracking
var trackId: Int
// The drawable position on the screen
private var buttonPositionX = 0
private var buttonPositionY = 0
val width: Int
val height: Int
private val defaultStateBitmap: BitmapDrawable
private val pressedStateBitmap: BitmapDrawable
private var pressedState = false
private var previousTouchX = 0
private var previousTouchY = 0
var controlPositionX = 0
var controlPositionY = 0
init {
this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap)
this.pressedStateBitmap = BitmapDrawable(res, pressedStateBitmap)
trackId = -1
width = this.defaultStateBitmap.intrinsicWidth
height = this.defaultStateBitmap.intrinsicHeight
}
/**
* Updates button status based on the motion event.
*
* @return true if value was changed
*/
fun updateStatus(event: MotionEvent): Boolean {
val pointerIndex = event.actionIndex
val xPosition = event.getX(pointerIndex).toInt()
val yPosition = event.getY(pointerIndex).toInt()
val pointerId = event.getPointerId(pointerIndex)
val motionEvent = event.action and MotionEvent.ACTION_MASK
val isActionDown =
motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
val isActionUp =
motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
if (isActionDown) {
if (!bounds.contains(xPosition, yPosition)) {
return false
}
pressedState = true
trackId = pointerId
return true
}
if (isActionUp) {
if (trackId != pointerId) {
return false
}
pressedState = false
trackId = -1
return true
}
return false
}
fun setPosition(x: Int, y: Int) {
buttonPositionX = x
buttonPositionY = y
}
fun draw(canvas: Canvas?) {
currentStateBitmapDrawable.draw(canvas!!)
}
private val currentStateBitmapDrawable: BitmapDrawable
get() = if (pressedState) pressedStateBitmap else defaultStateBitmap
fun onConfigureTouch(event: MotionEvent): Boolean {
val pointerIndex = event.actionIndex
val fingerPositionX = event.getX(pointerIndex).toInt()
val fingerPositionY = event.getY(pointerIndex).toInt()
when (event.action) {
MotionEvent.ACTION_DOWN -> {
previousTouchX = fingerPositionX
previousTouchY = fingerPositionY
controlPositionX = fingerPositionX - (width / 2)
controlPositionY = fingerPositionY - (height / 2)
}
MotionEvent.ACTION_MOVE -> {
controlPositionX += fingerPositionX - previousTouchX
controlPositionY += fingerPositionY - previousTouchY
setBounds(
controlPositionX,
controlPositionY,
width + controlPositionX,
height + controlPositionY
)
previousTouchX = fingerPositionX
previousTouchY = fingerPositionY
}
}
return true
}
fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
defaultStateBitmap.setBounds(left, top, right, bottom)
pressedStateBitmap.setBounds(left, top, right, bottom)
}
fun setOpacity(value: Int) {
defaultStateBitmap.alpha = value
pressedStateBitmap.alpha = value
}
val status: Int
get() = if (pressedState) ButtonState.PRESSED else ButtonState.RELEASED
val bounds: Rect
get() = defaultStateBitmap.bounds
}

View File

@@ -0,0 +1,274 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.overlay
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import android.view.MotionEvent
import org.yuzu.yuzu_emu.NativeLibrary.ButtonState
/**
* Custom [BitmapDrawable] that is capable
* of storing it's own ID.
*
* @param res [Resources] instance.
* @param defaultStateBitmap [Bitmap] of the default state.
* @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction.
* @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction.
* @param buttonUp Identifier for the up button.
* @param buttonDown Identifier for the down button.
* @param buttonLeft Identifier for the left button.
* @param buttonRight Identifier for the right button.
*/
class InputOverlayDrawableDpad(
res: Resources,
defaultStateBitmap: Bitmap,
pressedOneDirectionStateBitmap: Bitmap,
pressedTwoDirectionsStateBitmap: Bitmap,
buttonUp: Int,
buttonDown: Int,
buttonLeft: Int,
buttonRight: Int
) {
/**
* Gets one of the InputOverlayDrawableDpad's button IDs.
*
* @return the requested InputOverlayDrawableDpad's button ID.
*/
// The ID identifying what type of button this Drawable represents.
val upId: Int
val downId: Int
val leftId: Int
val rightId: Int
var trackId: Int
val width: Int
val height: Int
private val defaultStateBitmap: BitmapDrawable
private val pressedOneDirectionStateBitmap: BitmapDrawable
private val pressedTwoDirectionsStateBitmap: BitmapDrawable
private var previousTouchX = 0
private var previousTouchY = 0
private var controlPositionX = 0
private var controlPositionY = 0
private var upButtonState = false
private var downButtonState = false
private var leftButtonState = false
private var rightButtonState = false
init {
this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap)
this.pressedOneDirectionStateBitmap = BitmapDrawable(res, pressedOneDirectionStateBitmap)
this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap)
width = this.defaultStateBitmap.intrinsicWidth
height = this.defaultStateBitmap.intrinsicHeight
upId = buttonUp
downId = buttonDown
leftId = buttonLeft
rightId = buttonRight
trackId = -1
}
fun updateStatus(event: MotionEvent, dpad_slide: Boolean): Boolean {
val pointerIndex = event.actionIndex
val xPosition = event.getX(pointerIndex).toInt()
val yPosition = event.getY(pointerIndex).toInt()
val pointerId = event.getPointerId(pointerIndex)
val motionEvent = event.action and MotionEvent.ACTION_MASK
val isActionDown =
motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
val isActionUp =
motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
if (isActionDown) {
if (!bounds.contains(xPosition, yPosition)) {
return false
}
trackId = pointerId
}
if (isActionUp) {
if (trackId != pointerId) {
return false
}
trackId = -1
upButtonState = false
downButtonState = false
leftButtonState = false
rightButtonState = false
return true
}
if (trackId == -1) {
return false
}
if (!dpad_slide && !isActionDown) {
return false
}
for (i in 0 until event.pointerCount) {
if (trackId != event.getPointerId(i)) {
continue
}
var touchX = event.getX(i)
var touchY = event.getY(i)
var maxY = bounds.bottom.toFloat()
var maxX = bounds.right.toFloat()
touchX -= bounds.centerX().toFloat()
maxX -= bounds.centerX().toFloat()
touchY -= bounds.centerY().toFloat()
maxY -= bounds.centerY().toFloat()
val axisX = touchX / maxX
val axisY = touchY / maxY
val oldUpState = upButtonState
val oldDownState = downButtonState
val oldLeftState = leftButtonState
val oldRightState = rightButtonState
upButtonState = axisY < -VIRT_AXIS_DEADZONE
downButtonState = axisY > VIRT_AXIS_DEADZONE
leftButtonState = axisX < -VIRT_AXIS_DEADZONE
rightButtonState = axisX > VIRT_AXIS_DEADZONE
return oldUpState != upButtonState || oldDownState != downButtonState || oldLeftState != leftButtonState || oldRightState != rightButtonState
}
return false
}
fun draw(canvas: Canvas) {
val px = controlPositionX + width / 2
val py = controlPositionY + height / 2
// Pressed up
if (upButtonState && !leftButtonState && !rightButtonState) {
pressedOneDirectionStateBitmap.draw(canvas)
return
}
// Pressed down
if (downButtonState && !leftButtonState && !rightButtonState) {
canvas.save()
canvas.rotate(180f, px.toFloat(), py.toFloat())
pressedOneDirectionStateBitmap.draw(canvas)
canvas.restore()
return
}
// Pressed left
if (leftButtonState && !upButtonState && !downButtonState) {
canvas.save()
canvas.rotate(270f, px.toFloat(), py.toFloat())
pressedOneDirectionStateBitmap.draw(canvas)
canvas.restore()
return
}
// Pressed right
if (rightButtonState && !upButtonState && !downButtonState) {
canvas.save()
canvas.rotate(90f, px.toFloat(), py.toFloat())
pressedOneDirectionStateBitmap.draw(canvas)
canvas.restore()
return
}
// Pressed up left
if (upButtonState && leftButtonState && !rightButtonState) {
pressedTwoDirectionsStateBitmap.draw(canvas)
return
}
// Pressed up right
if (upButtonState && !leftButtonState && rightButtonState) {
canvas.save()
canvas.rotate(90f, px.toFloat(), py.toFloat())
pressedTwoDirectionsStateBitmap.draw(canvas)
canvas.restore()
return
}
// Pressed down right
if (downButtonState && !leftButtonState && rightButtonState) {
canvas.save()
canvas.rotate(180f, px.toFloat(), py.toFloat())
pressedTwoDirectionsStateBitmap.draw(canvas)
canvas.restore()
return
}
// Pressed down left
if (downButtonState && leftButtonState && !rightButtonState) {
canvas.save()
canvas.rotate(270f, px.toFloat(), py.toFloat())
pressedTwoDirectionsStateBitmap.draw(canvas)
canvas.restore()
return
}
// Not pressed
defaultStateBitmap.draw(canvas)
}
val upStatus: Int
get() = if (upButtonState) ButtonState.PRESSED else ButtonState.RELEASED
val downStatus: Int
get() = if (downButtonState) ButtonState.PRESSED else ButtonState.RELEASED
val leftStatus: Int
get() = if (leftButtonState) ButtonState.PRESSED else ButtonState.RELEASED
val rightStatus: Int
get() = if (rightButtonState) ButtonState.PRESSED else ButtonState.RELEASED
fun onConfigureTouch(event: MotionEvent): Boolean {
val pointerIndex = event.actionIndex
val fingerPositionX = event.getX(pointerIndex).toInt()
val fingerPositionY = event.getY(pointerIndex).toInt()
when (event.action) {
MotionEvent.ACTION_DOWN -> {
previousTouchX = fingerPositionX
previousTouchY = fingerPositionY
}
MotionEvent.ACTION_MOVE -> {
controlPositionX += fingerPositionX - previousTouchX
controlPositionY += fingerPositionY - previousTouchY
setBounds(
controlPositionX,
controlPositionY,
width + controlPositionX,
height + controlPositionY
)
previousTouchX = fingerPositionX
previousTouchY = fingerPositionY
}
}
return true
}
fun setPosition(x: Int, y: Int) {
controlPositionX = x
controlPositionY = y
}
fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
defaultStateBitmap.setBounds(left, top, right, bottom)
pressedOneDirectionStateBitmap.setBounds(left, top, right, bottom)
pressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom)
}
fun setOpacity(value: Int) {
defaultStateBitmap.alpha = value
pressedOneDirectionStateBitmap.alpha = value
pressedTwoDirectionsStateBitmap.alpha = value
}
val bounds: Rect
get() = defaultStateBitmap.bounds
companion object {
const val VIRT_AXIS_DEADZONE = 0.5f
}
}

View File

@@ -0,0 +1,282 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.overlay
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import android.view.MotionEvent
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.utils.EmulationMenuSettings
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Custom [BitmapDrawable] that is capable
* of storing it's own ID.
*
* @param res [Resources] instance.
* @param bitmapOuter [Bitmap] which represents the outer non-movable part of the joystick.
* @param bitmapInnerDefault [Bitmap] which represents the default inner movable part of the joystick.
* @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick.
* @param rectOuter [Rect] which represents the outer joystick bounds.
* @param rectInner [Rect] which represents the inner joystick bounds.
* @param joystickId The ID value what type of joystick this Drawable represents.
* @param buttonId The ID value what type of button this Drawable represents.
*/
class InputOverlayDrawableJoystick(
res: Resources,
bitmapOuter: Bitmap,
bitmapInnerDefault: Bitmap,
bitmapInnerPressed: Bitmap,
rectOuter: Rect,
rectInner: Rect,
val joystickId: Int,
val buttonId: Int
) {
// The ID value what motion event is tracking
var trackId = -1
var xAxis = 0f
private var yAxis = 0f
val width: Int
val height: Int
private var opacity: Int = 0
private var virtBounds: Rect
private var origBounds: Rect
private val outerBitmap: BitmapDrawable
private val defaultStateInnerBitmap: BitmapDrawable
private val pressedStateInnerBitmap: BitmapDrawable
private var previousTouchX = 0
private var previousTouchY = 0
var controlPositionX = 0
var controlPositionY = 0
private val boundsBoxBitmap: BitmapDrawable
private var pressedState = false
// TODO: Add button support
val buttonStatus: Int
get() =
NativeLibrary.ButtonState.RELEASED
var bounds: Rect
get() = outerBitmap.bounds
set(bounds) {
outerBitmap.bounds = bounds
}
// Nintendo joysticks have y axis inverted
val realYAxis: Float
get() = -yAxis
private val currentStateBitmapDrawable: BitmapDrawable
get() = if (pressedState) pressedStateInnerBitmap else defaultStateInnerBitmap
init {
outerBitmap = BitmapDrawable(res, bitmapOuter)
defaultStateInnerBitmap = BitmapDrawable(res, bitmapInnerDefault)
pressedStateInnerBitmap = BitmapDrawable(res, bitmapInnerPressed)
boundsBoxBitmap = BitmapDrawable(res, bitmapOuter)
width = bitmapOuter.width
height = bitmapOuter.height
bounds = rectOuter
defaultStateInnerBitmap.bounds = rectInner
pressedStateInnerBitmap.bounds = rectInner
virtBounds = bounds
origBounds = outerBitmap.copyBounds()
boundsBoxBitmap.alpha = 0
boundsBoxBitmap.bounds = virtBounds
setInnerBounds()
}
fun draw(canvas: Canvas?) {
outerBitmap.draw(canvas!!)
currentStateBitmapDrawable.draw(canvas)
boundsBoxBitmap.draw(canvas)
}
fun updateStatus(event: MotionEvent): Boolean {
val pointerIndex = event.actionIndex
val xPosition = event.getX(pointerIndex).toInt()
val yPosition = event.getY(pointerIndex).toInt()
val pointerId = event.getPointerId(pointerIndex)
val motionEvent = event.action and MotionEvent.ACTION_MASK
val isActionDown =
motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
val isActionUp =
motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
if (isActionDown) {
if (!bounds.contains(xPosition, yPosition)) {
return false
}
pressedState = true
outerBitmap.alpha = 0
boundsBoxBitmap.alpha = opacity
if (EmulationMenuSettings.joystickRelCenter) {
virtBounds.offset(
xPosition - virtBounds.centerX(),
yPosition - virtBounds.centerY()
)
}
boundsBoxBitmap.bounds = virtBounds
trackId = pointerId
}
if (isActionUp) {
if (trackId != pointerId) {
return false
}
pressedState = false
xAxis = 0.0f
yAxis = 0.0f
outerBitmap.alpha = opacity
boundsBoxBitmap.alpha = 0
virtBounds = Rect(
origBounds.left,
origBounds.top,
origBounds.right,
origBounds.bottom
)
bounds = Rect(
origBounds.left,
origBounds.top,
origBounds.right,
origBounds.bottom
)
setInnerBounds()
trackId = -1
return true
}
if (trackId == -1) return false
for (i in 0 until event.pointerCount) {
if (trackId != event.getPointerId(i)) {
continue
}
var touchX = event.getX(i)
var touchY = event.getY(i)
var maxY = virtBounds.bottom.toFloat()
var maxX = virtBounds.right.toFloat()
touchX -= virtBounds.centerX().toFloat()
maxX -= virtBounds.centerX().toFloat()
touchY -= virtBounds.centerY().toFloat()
maxY -= virtBounds.centerY().toFloat()
val axisX = touchX / maxX
val axisY = touchY / maxY
val oldXAxis = xAxis
val oldYAxis = yAxis
// Clamp the circle pad input to a circle
val angle = atan2(axisY.toDouble(), axisX.toDouble()).toFloat()
var radius = sqrt((axisX * axisX + axisY * axisY).toDouble()).toFloat()
if (radius > 1.0f) {
radius = 1.0f
}
xAxis = cos(angle.toDouble()).toFloat() * radius
yAxis = sin(angle.toDouble()).toFloat() * radius
setInnerBounds()
return oldXAxis != xAxis && oldYAxis != yAxis
}
return false
}
fun onConfigureTouch(event: MotionEvent): Boolean {
val pointerIndex = event.actionIndex
val fingerPositionX = event.getX(pointerIndex).toInt()
val fingerPositionY = event.getY(pointerIndex).toInt()
when (event.action) {
MotionEvent.ACTION_DOWN -> {
previousTouchX = fingerPositionX
previousTouchY = fingerPositionY
controlPositionX = fingerPositionX - (width / 2)
controlPositionY = fingerPositionY - (height / 2)
}
MotionEvent.ACTION_MOVE -> {
controlPositionX += fingerPositionX - previousTouchX
controlPositionY += fingerPositionY - previousTouchY
bounds = Rect(
controlPositionX,
controlPositionY,
outerBitmap.intrinsicWidth + controlPositionX,
outerBitmap.intrinsicHeight + controlPositionY
)
virtBounds = Rect(
controlPositionX,
controlPositionY,
outerBitmap.intrinsicWidth + controlPositionX,
outerBitmap.intrinsicHeight + controlPositionY
)
setInnerBounds()
bounds = Rect(
Rect(
controlPositionX,
controlPositionY,
outerBitmap.intrinsicWidth + controlPositionX,
outerBitmap.intrinsicHeight + controlPositionY
)
)
previousTouchX = fingerPositionX
previousTouchY = fingerPositionY
}
}
origBounds = outerBitmap.copyBounds()
return true
}
private fun setInnerBounds() {
var x = virtBounds.centerX() + (xAxis * (virtBounds.width() / 2)).toInt()
var y = virtBounds.centerY() + (yAxis * (virtBounds.height() / 2)).toInt()
if (x > virtBounds.centerX() + virtBounds.width() / 2) x =
virtBounds.centerX() + virtBounds.width() / 2
if (x < virtBounds.centerX() - virtBounds.width() / 2) x =
virtBounds.centerX() - virtBounds.width() / 2
if (y > virtBounds.centerY() + virtBounds.height() / 2) y =
virtBounds.centerY() + virtBounds.height() / 2
if (y < virtBounds.centerY() - virtBounds.height() / 2) y =
virtBounds.centerY() - virtBounds.height() / 2
val width = pressedStateInnerBitmap.bounds.width() / 2
val height = pressedStateInnerBitmap.bounds.height() / 2
defaultStateInnerBitmap.setBounds(
x - width,
y - height,
x + width,
y + height
)
pressedStateInnerBitmap.bounds = defaultStateInnerBitmap.bounds
}
fun setPosition(x: Int, y: Int) {
controlPositionX = x
controlPositionY = y
}
fun setOpacity(value: Int) {
opacity = value
defaultStateInnerBitmap.alpha = value
pressedStateInnerBitmap.alpha = value
if (trackId == -1) {
outerBitmap.alpha = value
boundsBoxBitmap.alpha = 0
} else {
outerBitmap.alpha = 0
boundsBoxBitmap.alpha = value
}
}
}

View File

@@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.color.MaterialColors
import com.google.android.material.transition.MaterialFadeThrough
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.adapters.GameAdapter
import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding
import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel
class GamesFragment : Fragment() {
private var _binding: FragmentGamesBinding? = null
private val binding get() = _binding!!
private val gamesViewModel: GamesViewModel by activityViewModels()
private val homeViewModel: HomeViewModel by activityViewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialFadeThrough()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentGamesBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
homeViewModel.setNavigationVisibility(visible = true, animated = false)
binding.gridGames.apply {
layoutManager = AutofitGridLayoutManager(
requireContext(),
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
)
adapter = GameAdapter(requireActivity() as AppCompatActivity)
}
binding.swipeRefresh.apply {
// Add swipe down to refresh gesture
setOnRefreshListener {
gamesViewModel.reloadGames(false)
}
// Set theme color to the refresh animation's background
setProgressBackgroundColorSchemeColor(
MaterialColors.getColor(
binding.swipeRefresh,
com.google.android.material.R.attr.colorPrimary
)
)
setColorSchemeColors(
MaterialColors.getColor(
binding.swipeRefresh,
com.google.android.material.R.attr.colorOnPrimary
)
)
// Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn
post {
if (_binding == null) {
return@post
}
binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value!!
}
}
gamesViewModel.apply {
// Watch for when we get updates to any of our games lists
isReloading.observe(viewLifecycleOwner) { isReloading ->
binding.swipeRefresh.isRefreshing = isReloading
}
games.observe(viewLifecycleOwner) {
(binding.gridGames.adapter as GameAdapter).submitList(it)
if (it.isEmpty()) {
binding.noticeText.visibility = View.VISIBLE
} else {
binding.noticeText.visibility = View.GONE
}
}
shouldSwapData.observe(viewLifecycleOwner) { shouldSwapData ->
if (shouldSwapData) {
(binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value!!)
gamesViewModel.setShouldSwapData(false)
}
}
// Check if the user reselected the games menu item and then scroll to top of the list
shouldScrollToTop.observe(viewLifecycleOwner) { shouldScroll ->
if (shouldScroll) {
scrollToTop()
gamesViewModel.setShouldScrollToTop(false)
}
}
}
setInsets()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private fun scrollToTop() {
if (_binding != null) {
binding.gridGames.smoothScrollToPosition(0)
}
}
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large)
val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
val spacingNavigationRail =
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
binding.gridGames.updatePadding(
top = barInsets.top + extraListSpacing,
bottom = barInsets.bottom + spacingNavigation + extraListSpacing
)
binding.swipeRefresh.setProgressViewEndTarget(
false,
barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
)
val leftInsets = barInsets.left + cutoutInsets.left
val rightInsets = barInsets.right + cutoutInsets.right
val mlpSwipe = binding.swipeRefresh.layoutParams as MarginLayoutParams
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
mlpSwipe.leftMargin = leftInsets + spacingNavigationRail
mlpSwipe.rightMargin = rightInsets
} else {
mlpSwipe.leftMargin = leftInsets
mlpSwipe.rightMargin = rightInsets + spacingNavigationRail
}
binding.swipeRefresh.layoutParams = mlpSwipe
binding.noticeText.updatePadding(bottom = spacingNavigation)
windowInsets
}
}

View File

@@ -0,0 +1,418 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.ui.main
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.view.WindowManager
import android.view.animation.PathInterpolator
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceManager
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationBarView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.activities.EmulationActivity
import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
import org.yuzu.yuzu_emu.features.settings.model.Settings
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import org.yuzu.yuzu_emu.model.GamesViewModel
import org.yuzu.yuzu_emu.model.HomeViewModel
import org.yuzu.yuzu_emu.utils.*
import java.io.IOException
class MainActivity : AppCompatActivity(), ThemeProvider {
private lateinit var binding: ActivityMainBinding
private val homeViewModel: HomeViewModel by viewModels()
private val gamesViewModel: GamesViewModel by viewModels()
override var themeId: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
ThemeHelper.setTheme(this)
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
WindowCompat.setDecorFitsSystemWindows(window, false)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
window.statusBarColor =
ContextCompat.getColor(applicationContext, android.R.color.transparent)
window.navigationBarColor =
ContextCompat.getColor(applicationContext, android.R.color.transparent)
binding.statusBarShade.setBackgroundColor(
ThemeHelper.getColorWithOpacity(
MaterialColors.getColor(
binding.root,
com.google.android.material.R.attr.colorSurface
),
ThemeHelper.SYSTEM_BAR_ALPHA
)
)
if (InsetsHelper.getSystemGestureType(applicationContext) != InsetsHelper.GESTURE_NAVIGATION) {
binding.navigationBarShade.setBackgroundColor(
ThemeHelper.getColorWithOpacity(
MaterialColors.getColor(
binding.root,
com.google.android.material.R.attr.colorSurface
),
ThemeHelper.SYSTEM_BAR_ALPHA
)
)
}
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
setUpNavigation(navHostFragment.navController)
(binding.navigationView as NavigationBarView).setOnItemReselectedListener {
when (it.itemId) {
R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true)
R.id.searchFragment -> gamesViewModel.setSearchFocused(true)
R.id.homeSettingsFragment -> SettingsActivity.launch(
this,
SettingsFile.FILE_NAME_CONFIG,
""
)
}
}
// Prevents navigation from being drawn for a short time on recreation if set to hidden
if (!homeViewModel.navigationVisible.value?.first!!) {
binding.navigationView.visibility = View.INVISIBLE
binding.statusBarShade.visibility = View.INVISIBLE
}
homeViewModel.navigationVisible.observe(this) {
showNavigation(it.first, it.second)
}
homeViewModel.statusBarShadeVisible.observe(this) { visible ->
showStatusBarShade(visible)
}
// Dismiss previous notifications (should not happen unless a crash occurred)
EmulationActivity.stopForegroundService(this)
setInsets()
}
fun finishSetup(navController: NavController) {
navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment)
(binding.navigationView as NavigationBarView).setupWithNavController(navController)
showNavigation(visible = true, animated = true)
}
private fun setUpNavigation(navController: NavController) {
val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext)
.getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)
if (firstTimeSetup && !homeViewModel.navigatedToSetup) {
navController.navigate(R.id.firstTimeSetupFragment)
homeViewModel.navigatedToSetup = true
} else {
(binding.navigationView as NavigationBarView).setupWithNavController(navController)
}
}
private fun showNavigation(visible: Boolean, animated: Boolean) {
if (!animated) {
if (visible) {
binding.navigationView.visibility = View.VISIBLE
} else {
binding.navigationView.visibility = View.INVISIBLE
}
return
}
val smallLayout = resources.getBoolean(R.bool.small_layout)
binding.navigationView.animate().apply {
if (visible) {
binding.navigationView.visibility = View.VISIBLE
duration = 300
interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
if (smallLayout) {
binding.navigationView.translationY =
binding.navigationView.height.toFloat() * 2
translationY(0f)
} else {
if (ViewCompat.getLayoutDirection(binding.navigationView) == ViewCompat.LAYOUT_DIRECTION_LTR) {
binding.navigationView.translationX =
binding.navigationView.width.toFloat() * -2
translationX(0f)
} else {
binding.navigationView.translationX =
binding.navigationView.width.toFloat() * 2
translationX(0f)
}
}
} else {
duration = 300
interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
if (smallLayout) {
translationY(binding.navigationView.height.toFloat() * 2)
} else {
if (ViewCompat.getLayoutDirection(binding.navigationView) == ViewCompat.LAYOUT_DIRECTION_LTR) {
translationX(binding.navigationView.width.toFloat() * -2)
} else {
translationX(binding.navigationView.width.toFloat() * 2)
}
}
}
}.withEndAction {
if (!visible) {
binding.navigationView.visibility = View.INVISIBLE
}
}.start()
}
private fun showStatusBarShade(visible: Boolean) {
binding.statusBarShade.animate().apply {
if (visible) {
binding.statusBarShade.visibility = View.VISIBLE
binding.statusBarShade.translationY = binding.statusBarShade.height.toFloat() * -2
duration = 300
translationY(0f)
interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
} else {
duration = 300
translationY(binding.navigationView.height.toFloat() * -2)
interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
}
}.withEndAction {
if (!visible) {
binding.statusBarShade.visibility = View.INVISIBLE
}
}.start()
}
override fun onResume() {
ThemeHelper.setCorrectTheme(this)
super.onResume()
}
override fun onDestroy() {
EmulationActivity.stopForegroundService(this)
super.onDestroy()
}
private fun setInsets() =
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
val mlpStatusShade = binding.statusBarShade.layoutParams as MarginLayoutParams
mlpStatusShade.height = insets.top
binding.statusBarShade.layoutParams = mlpStatusShade
// The only situation where we care to have a nav bar shade is when it's at the bottom
// of the screen where scrolling list elements can go behind it.
val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
mlpNavShade.height = insets.bottom
binding.navigationBarShade.layoutParams = mlpNavShade
windowInsets
}
override fun setTheme(resId: Int) {
super.setTheme(resId)
themeId = resId
}
val getGamesDirectory =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
if (result == null)
return@registerForActivityResult
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.takePersistableUriPermission(
result,
takeFlags
)
// When a new directory is picked, we currently will reset the existing games
// database. This effectively means that only one game directory is supported.
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit()
.putString(GameHelper.KEY_GAME_PATH, result.toString())
.apply()
Toast.makeText(
applicationContext,
R.string.games_dir_selected,
Toast.LENGTH_LONG
).show()
gamesViewModel.reloadGames(true)
}
val getProdKey =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null)
return@registerForActivityResult
if (!FileUtil.hasExtension(result.toString(), "keys")) {
Toast.makeText(
applicationContext,
R.string.invalid_keys_file,
Toast.LENGTH_SHORT
).show()
return@registerForActivityResult
}
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.takePersistableUriPermission(
result,
takeFlags
)
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
if (FileUtil.copyUriToInternalStorage(
applicationContext,
result,
dstPath,
"prod.keys"
)
) {
if (NativeLibrary.reloadKeys()) {
Toast.makeText(
applicationContext,
R.string.install_keys_success,
Toast.LENGTH_SHORT
).show()
gamesViewModel.reloadGames(true)
} else {
Toast.makeText(
applicationContext,
R.string.install_keys_failure,
Toast.LENGTH_LONG
).show()
}
}
}
val getAmiiboKey =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null)
return@registerForActivityResult
if (!FileUtil.hasExtension(result.toString(), "bin")) {
Toast.makeText(
applicationContext,
R.string.invalid_keys_file,
Toast.LENGTH_SHORT
).show()
return@registerForActivityResult
}
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.takePersistableUriPermission(
result,
takeFlags
)
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
if (FileUtil.copyUriToInternalStorage(
applicationContext,
result,
dstPath,
"key_retail.bin"
)
) {
if (NativeLibrary.reloadKeys()) {
Toast.makeText(
applicationContext,
R.string.install_keys_success,
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
applicationContext,
R.string.install_amiibo_keys_failure,
Toast.LENGTH_LONG
).show()
}
}
}
val getDriver =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
if (result == null)
return@registerForActivityResult
val takeFlags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
contentResolver.takePersistableUriPermission(
result,
takeFlags
)
val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
progressBinding.progressBar.isIndeterminate = true
val installationDialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.installing_driver)
.setView(progressBinding.root)
.show()
lifecycleScope.launch {
withContext(Dispatchers.IO) {
// Ignore file exceptions when a user selects an invalid zip
try {
GpuDriverHelper.installCustomDriver(applicationContext, result)
} catch (_: IOException) {
}
withContext(Dispatchers.Main) {
installationDialog.dismiss()
val driverName = GpuDriverHelper.customDriverName
if (driverName != null) {
Toast.makeText(
applicationContext,
getString(
R.string.select_gpu_driver_install_success,
driverName
),
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
applicationContext,
R.string.select_gpu_driver_error,
Toast.LENGTH_LONG
).show()
}
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.ui.main
interface ThemeProvider {
/**
* Provides theme ID by overriding an activity's 'setTheme' method and returning that result
*/
var themeId: Int
}

View File

@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.utils
class BiMap<K, V> {
private val forward: MutableMap<K, V> = HashMap()
private val backward: MutableMap<V, K> = HashMap()
@Synchronized
fun add(key: K, value: V) {
forward[key] = value
backward[value] = key
}
@Synchronized
fun getForward(key: K): V? {
return forward[key]
}
@Synchronized
fun getBackward(key: V): K? {
return backward[key]
}
}

View File

@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.utils
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
/**
* Some controllers have incorrect mappings. This class has special-case fixes for them.
*/
class ControllerMappingHelper {
/**
* Some controllers report extra button presses that can be ignored.
*/
fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean {
return if (isDualShock4(inputDevice)) {
// The two analog triggers generate analog motion events as well as a keycode.
// We always prefer to use the analog values, so throw away the button press
keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2
} else false
}
/**
* Scale an axis to be zero-centered with a proper range.
*/
fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float {
if (isDualShock4(inputDevice)) {
// Android doesn't have correct mappings for this controller's triggers. It reports them
// as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0]
// Scale them to properly zero-centered with a range of [0.0, 1.0].
if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) {
return (value + 1) / 2.0f
}
} else if (isXboxOneWireless(inputDevice)) {
// Same as the DualShock 4, the mappings are missing.
if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) {
return (value + 1) / 2.0f
}
if (axis == MotionEvent.AXIS_GENERIC_1) {
// This axis is stuck at ~.5. Ignore it.
return 0.0f
}
} else if (isMogaPro2Hid(inputDevice)) {
// This controller has a broken axis that reports a constant value. Ignore it.
if (axis == MotionEvent.AXIS_GENERIC_1) {
return 0.0f
}
}
return value
}
// Sony DualShock 4 controller
private fun isDualShock4(inputDevice: InputDevice): Boolean {
return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc
}
// Microsoft Xbox One controller
private fun isXboxOneWireless(inputDevice: InputDevice): Boolean {
return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0
}
// Moga Pro 2 HID
private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean {
return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271
}
}

View File

@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.utils
import android.content.Context
import org.yuzu.yuzu_emu.NativeLibrary
import java.io.IOException
object DirectoryInitialization {
private var userPath: String? = null
var areDirectoriesReady: Boolean = false
fun start(context: Context) {
if (!areDirectoriesReady) {
initializeInternalStorage(context)
NativeLibrary.initializeEmulation()
areDirectoriesReady = true
}
}
val userDirectory: String?
get() {
check(areDirectoriesReady) { "Directory initialization is not ready!" }
return userPath
}
private fun initializeInternalStorage(context: Context) {
try {
userPath = context.getExternalFilesDir(null)!!.canonicalPath
NativeLibrary.setAppDirectory(userPath!!)
} catch (e: IOException) {
e.printStackTrace()
}
}
}

View File

@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.utils
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.model.MinimalDocumentFile
import java.io.File
import java.util.*
class DocumentsTree {
private var root: DocumentsNode? = null
fun setRoot(rootUri: Uri?) {
root = null
root = DocumentsNode()
root!!.uri = rootUri
root!!.isDirectory = true
}
fun openContentUri(filepath: String, openMode: String?): Int {
val node = resolvePath(filepath) ?: return -1
return FileUtil.openContentUri(YuzuApplication.appContext, node.uri.toString(), openMode)
}
fun getFileSize(filepath: String): Long {
val node = resolvePath(filepath)
return if (node == null || node.isDirectory) {
0
} else FileUtil.getFileSize(YuzuApplication.appContext, node.uri.toString())
}
fun exists(filepath: String): Boolean {
return resolvePath(filepath) != null
}
private fun resolvePath(filepath: String): DocumentsNode? {
val tokens = StringTokenizer(filepath, File.separator, false)
var iterator = root
while (tokens.hasMoreTokens()) {
val token = tokens.nextToken()
if (token.isEmpty()) continue
iterator = find(iterator, token)
if (iterator == null) return null
}
return iterator
}
private fun find(parent: DocumentsNode?, filename: String): DocumentsNode? {
if (parent!!.isDirectory && !parent.loaded) {
structTree(parent)
}
return parent.children[filename]
}
/**
* Construct current level directory tree
* @param parent parent node of this level
*/
private fun structTree(parent: DocumentsNode) {
val documents = FileUtil.listFiles(YuzuApplication.appContext, parent.uri!!)
for (document in documents) {
val node = DocumentsNode(document)
node.parent = parent
parent.children[node.name] = node
}
parent.loaded = true
}
private class DocumentsNode {
var parent: DocumentsNode? = null
val children: MutableMap<String?, DocumentsNode> = HashMap()
var name: String? = null
var uri: Uri? = null
var loaded = false
var isDirectory = false
constructor()
constructor(document: MinimalDocumentFile) {
name = document.filename
uri = document.uri
isDirectory = document.isDirectory
loaded = !isDirectory
}
private constructor(document: DocumentFile, isCreateDir: Boolean) {
name = document.name
uri = document.uri
isDirectory = isCreateDir
loaded = true
}
private fun rename(name: String) {
if (parent == null) {
return
}
parent!!.children.remove(this.name)
this.name = name
parent!!.children[name] = this
}
}
companion object {
fun isNativePath(path: String): Boolean {
return if (path.isNotEmpty()) {
path[0] == '/'
} else false
}
}
}

View File

@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.utils
import androidx.preference.PreferenceManager
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.features.settings.model.Settings
object EmulationMenuSettings {
private val preferences =
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
// These must match what is defined in src/core/settings.h
const val LayoutOption_Default = 0
const val LayoutOption_SingleScreen = 1
const val LayoutOption_LargeScreen = 2
const val LayoutOption_SideScreen = 3
const val LayoutOption_MobilePortrait = 4
const val LayoutOption_MobileLandscape = 5
var joystickRelCenter: Boolean
get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, true)
set(value) {
preferences.edit()
.putBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, value)
.apply()
}
var dpadSlide: Boolean
get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, true)
set(value) {
preferences.edit()
.putBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, value)
.apply()
}
var hapticFeedback: Boolean
get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, false)
set(value) {
preferences.edit()
.putBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, value)
.apply()
}
var landscapeScreenLayout: Int
get() = preferences.getInt(
Settings.PREF_MENU_SETTINGS_LANDSCAPE,
LayoutOption_MobileLandscape
)
set(value) {
preferences.edit()
.putInt(Settings.PREF_MENU_SETTINGS_LANDSCAPE, value)
.apply()
}
var showFps: Boolean
get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, false)
set(value) {
preferences.edit()
.putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, value)
.apply()
}
var showOverlay: Boolean
get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, true)
set(value) {
preferences.edit()
.putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, value)
.apply()
}
}

View File

@@ -0,0 +1,298 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.utils
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import org.yuzu.yuzu_emu.model.MinimalDocumentFile
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.URLDecoder
object FileUtil {
const val PATH_TREE = "tree"
const val DECODE_METHOD = "UTF-8"
const val APPLICATION_OCTET_STREAM = "application/octet-stream"
const val TEXT_PLAIN = "text/plain"
/**
* Create a file from directory with filename.
* @param context Application context
* @param directory parent path for file.
* @param filename file display name.
* @return boolean
*/
fun createFile(context: Context?, directory: String?, filename: String): DocumentFile? {
var decodedFilename = filename
try {
val directoryUri = Uri.parse(directory)
val parent = DocumentFile.fromTreeUri(context!!, directoryUri) ?: return null
decodedFilename = URLDecoder.decode(decodedFilename, DECODE_METHOD)
var mimeType = APPLICATION_OCTET_STREAM
if (decodedFilename.endsWith(".txt")) {
mimeType = TEXT_PLAIN
}
val exists = parent.findFile(decodedFilename)
return exists ?: parent.createFile(mimeType, decodedFilename)
} catch (e: Exception) {
Log.error("[FileUtil]: Cannot create file, error: " + e.message)
}
return null
}
/**
* Create a directory from directory with filename.
* @param context Application context
* @param directory parent path for directory.
* @param directoryName directory display name.
* @return boolean
*/
fun createDir(context: Context?, directory: String?, directoryName: String?): DocumentFile? {
var decodedDirectoryName = directoryName
try {
val directoryUri = Uri.parse(directory)
val parent = DocumentFile.fromTreeUri(context!!, directoryUri) ?: return null
decodedDirectoryName = URLDecoder.decode(decodedDirectoryName, DECODE_METHOD)
val isExist = parent.findFile(decodedDirectoryName)
return isExist ?: parent.createDirectory(decodedDirectoryName)
} catch (e: Exception) {
Log.error("[FileUtil]: Cannot create file, error: " + e.message)
}
return null
}
/**
* Open content uri and return file descriptor to JNI.
* @param context Application context
* @param path Native content uri path
* @param openMode will be one of "r", "r", "rw", "wa", "rwa"
* @return file descriptor
*/
@JvmStatic
fun openContentUri(context: Context, path: String, openMode: String?): Int {
try {
val uri = Uri.parse(path)
val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, openMode!!)
if (parcelFileDescriptor == null) {
Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path")
return -1
}
val fileDescriptor = parcelFileDescriptor.detachFd()
parcelFileDescriptor.close()
return fileDescriptor
} catch (e: Exception) {
Log.error("[FileUtil]: Cannot open content uri, error: " + e.message)
}
return -1
}
/**
* Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow
* This function will be faster than DoucmentFile.listFiles
* @param context Application context
* @param uri Directory uri.
* @return CheapDocument lists.
*/
fun listFiles(context: Context, uri: Uri): Array<MinimalDocumentFile> {
val resolver = context.contentResolver
val columns = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
var c: Cursor? = null
val results: MutableList<MinimalDocumentFile> = ArrayList()
try {
val docId: String = if (isRootTreeUri(uri)) {
DocumentsContract.getTreeDocumentId(uri)
} else {
DocumentsContract.getDocumentId(uri)
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId)
c = resolver.query(childrenUri, columns, null, null, null)
while (c!!.moveToNext()) {
val documentId = c.getString(0)
val documentName = c.getString(1)
val documentMimeType = c.getString(2)
val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
val document = MinimalDocumentFile(documentName, documentMimeType, documentUri)
results.add(document)
}
} catch (e: Exception) {
Log.error("[FileUtil]: Cannot list file error: " + e.message)
} finally {
closeQuietly(c)
}
return results.toTypedArray()
}
/**
* Check whether given path exists.
* @param path Native content uri path
* @return bool
*/
fun exists(context: Context, path: String?): Boolean {
var c: Cursor? = null
try {
val mUri = Uri.parse(path)
val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
c = context.contentResolver.query(mUri, columns, null, null, null)
return c!!.count > 0
} catch (e: Exception) {
Log.info("[FileUtil] Cannot find file from given path, error: " + e.message)
} finally {
closeQuietly(c)
}
return false
}
/**
* Check whether given path is a directory
* @param path content uri path
* @return bool
*/
fun isDirectory(context: Context, path: String): Boolean {
val resolver = context.contentResolver
val columns = arrayOf(
DocumentsContract.Document.COLUMN_MIME_TYPE
)
var isDirectory = false
var c: Cursor? = null
try {
val mUri = Uri.parse(path)
c = resolver.query(mUri, columns, null, null, null)
c!!.moveToNext()
val mimeType = c.getString(0)
isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
} catch (e: Exception) {
Log.error("[FileUtil]: Cannot list files, error: " + e.message)
} finally {
closeQuietly(c)
}
return isDirectory
}
/**
* Get file display name from given path
* @param path content uri path
* @return String display name
*/
fun getFilename(context: Context, path: String): String {
val resolver = context.contentResolver
val columns = arrayOf(
DocumentsContract.Document.COLUMN_DISPLAY_NAME
)
var filename = ""
var c: Cursor? = null
try {
val mUri = Uri.parse(path)
c = resolver.query(mUri, columns, null, null, null)
c!!.moveToNext()
filename = c.getString(0)
} catch (e: Exception) {
Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
} finally {
closeQuietly(c)
}
return filename
}
fun getFilesName(context: Context, path: String): Array<String> {
val uri = Uri.parse(path)
val files: MutableList<String> = ArrayList()
for (file in listFiles(context, uri)) {
files.add(file.filename)
}
return files.toTypedArray()
}
/**
* Get file size from given path.
* @param path content uri path
* @return long file size
*/
@JvmStatic
fun getFileSize(context: Context, path: String): Long {
val resolver = context.contentResolver
val columns = arrayOf(
DocumentsContract.Document.COLUMN_SIZE
)
var size: Long = 0
var c: Cursor? = null
try {
val mUri = Uri.parse(path)
c = resolver.query(mUri, columns, null, null, null)
c!!.moveToNext()
size = c.getLong(0)
} catch (e: Exception) {
Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
} finally {
closeQuietly(c)
}
return size
}
fun copyUriToInternalStorage(
context: Context,
sourceUri: Uri?,
destinationParentPath: String,
destinationFilename: String
): Boolean {
var input: InputStream? = null
var output: FileOutputStream? = null
try {
input = context.contentResolver.openInputStream(sourceUri!!)
output = FileOutputStream("$destinationParentPath/$destinationFilename")
val buffer = ByteArray(1024)
var len: Int
while (input!!.read(buffer).also { len = it } != -1) {
output.write(buffer, 0, len)
}
output.flush()
return true
} catch (e: Exception) {
Log.error("[FileUtil]: Cannot copy file, error: " + e.message)
} finally {
if (input != null) {
try {
input.close()
} catch (e: IOException) {
Log.error("[FileUtil]: Cannot close input file, error: " + e.message)
}
}
if (output != null) {
try {
output.close()
} catch (e: IOException) {
Log.error("[FileUtil]: Cannot close output file, error: " + e.message)
}
}
}
return false
}
fun isRootTreeUri(uri: Uri): Boolean {
val paths = uri.pathSegments
return paths.size == 2 && PATH_TREE == paths[0]
}
fun closeQuietly(closeable: AutoCloseable?) {
if (closeable != null) {
try {
closeable.close()
} catch (rethrown: RuntimeException) {
throw rethrown
} catch (ignored: Exception) {
}
}
}
fun hasExtension(path: String, extension: String): Boolean {
return path.substring(path.lastIndexOf(".") + 1).contains(extension)
}
}

View File

@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.utils
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.activities.EmulationActivity
/**
* A service that shows a permanent notification in the background to avoid the app getting
* cleared from memory by the system.
*/
class ForegroundService : Service() {
companion object {
const val EMULATION_RUNNING_NOTIFICATION = 0x1000
const val ACTION_STOP = "stop"
}
private fun showRunningNotification() {
// Intent is used to resume emulation if the notification is clicked
val contentIntent = PendingIntent.getActivity(
this,
0,
Intent(this, EmulationActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
val builder =
NotificationCompat.Builder(this, getString(R.string.emulation_notification_channel_id))
.setSmallIcon(R.drawable.ic_stat_notification_logo)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.emulation_notification_running))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setVibrate(null)
.setSound(null)
.setContentIntent(contentIntent)
startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build())
}
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onCreate() {
showRunningNotification()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) {
return START_NOT_STICKY;
}
if (intent.action == ACTION_STOP) {
NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelfResult(startId)
}
return START_STICKY
}
override fun onDestroy() {
NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION)
}
}

View File

@@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
package org.yuzu.yuzu_emu.utils
import android.content.SharedPreferences
import android.net.Uri
import androidx.preference.PreferenceManager
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.model.Game
import java.util.*
object GameHelper {
const val KEY_GAME_PATH = "game_path"
const val KEY_GAMES = "Games"
private lateinit var preferences: SharedPreferences
fun getGames(): List<Game> {
val games = mutableListOf<Game>()
val context = YuzuApplication.appContext
val gamesDir =
PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "")
val gamesUri = Uri.parse(gamesDir)
preferences = PreferenceManager.getDefaultSharedPreferences(context)
// Ensure keys are loaded so that ROM metadata can be decrypted.
NativeLibrary.reloadKeys()
val children = FileUtil.listFiles(context, gamesUri)
for (file in children) {
if (!file.isDirectory) {
val filename = file.uri.toString()
val extensionStart = filename.lastIndexOf('.')
if (extensionStart > 0) {
val fileExtension = filename.substring(extensionStart)
// Check that the file has an extension we care about before trying to read out of it.
if (Game.extensions.contains(fileExtension.lowercase(Locale.getDefault()))) {
games.add(getGame(filename))
}
}
}
}
// Cache list of games found on disk
val serializedGames = mutableSetOf<String>()
games.forEach {
serializedGames.add(Json.encodeToString(it))
}
preferences.edit()
.remove(KEY_GAMES)
.putStringSet(KEY_GAMES, serializedGames)
.apply()
return games.toList()
}
private fun getGame(filePath: String): Game {
var name = NativeLibrary.getTitle(filePath)
// If the game's title field is empty, use the filename.
if (name.isEmpty()) {
name = filePath.substring(filePath.lastIndexOf("/") + 1)
}
var gameId = NativeLibrary.getGameId(filePath)
// If the game's ID field is empty, use the filename without extension.
if (gameId.isEmpty()) {
gameId = filePath.substring(
filePath.lastIndexOf("/") + 1,
filePath.lastIndexOf(".")
)
}
val newGame = Game(
name,
NativeLibrary.getDescription(filePath).replace("\n", " "),
NativeLibrary.getRegions(filePath),
filePath,
gameId,
NativeLibrary.getCompany(filePath)
)
val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L)
if (addedTime == 0L) {
preferences.edit()
.putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis())
.apply()
}
return newGame
}
}

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