8 |
9 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Teppichseite
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # HandheldExp
2 | An **in game menu** for [ES-DE](https://es-de.org/) on Android.
3 |
4 |
5 |
6 | ## 📋 Features
7 |
8 | There is **no root required**. Instead [Shizuku](https://shizuku.rikka.app/) has to be installed. The features of **HandheldExp** are currently the following:
9 |
10 | 1. **Uniform** in game menu across all emulators and apps
11 | 2. Display of **Information** and **Game Art** of current game
12 | 3. In-Game **Manual Viewer**
13 | 4. **Quick Save** and **Quick load** for supported Emulators
14 | 5. **Automatically closing** latest emulator when returning to ES-DE
15 | 6. **Quick Actions** like changing brightness level or Airplane mode
16 | 5. **Device specific** features like changing the **Performance Mode** via menu
17 |
18 | Below you can see **certain demonstrations** of the in game menu:
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | ## 🚀 Steps for the future
27 |
28 | Currently **HandheldExp** is still in an early phase, and therefore there are certain features planned for the future. These are:
29 |
30 | 1. Support **Quick Save/Load** for Emulators which do **not** have **hotkey** support (e.g. Dolphin)
31 | 2. **Saving** device specific settings **per game** (e.g. Performance mode)
32 | 3. Supporting more **device specific features** for various handheld devices
33 |
34 | ## ⚙️ Installation
35 |
36 | The following guide will demonstrate how to **install** and **setup** HandheldExp on your device.
37 |
38 | 1. It is heavily recommended to use a **physical controller** for HandheldExp
39 | 1. Either an external one or one that is built onto the device
40 | 2. The **following apps** have to be installed
41 | 1. [ES-DE](https://es-de.org/#Download) Android as Frontend
42 | 2. [KeyMapper](https://play.google.com/store/apps/details?id=io.github.sds100.keymapper&hl=de) to map the opening and closing button for the Menu
43 | 3. [Shizuku](https://play.google.com/store/apps/details?id=moe.shizuku.privileged.api) to give HandheldExp privileged permissions
44 | 3. Download the **HandhelpExp APK** from GitHub and install it on your device
45 | 4. **Setup ES-DE**
46 | 1. Perform all the required setup steps for your emulators
47 | 2. Scrape your Game Art and optionally your Manuals with ES-DE
48 | 3. Enable custom scripts
49 | 1. Go to ES-DE Menu > Other Settings > Enable custom event scripts
50 | 4. Setup ES-DE as Home Launcher as as described [here](https://gitlab.com/es-de/emulationstation-de/-/blob/master/ANDROID.md?ref_type=heads#running-es-de-as-the-android-home-app)
51 | 5. **Setup the HandhelpExp ES-DE Scripts**
52 | 1. Navigate to your `ES-DE` folder in Android
53 | 2. With the scripts create the folders `game-start` and `game-end`
54 | 3. Place file [game-start](./scripts/es-de/game-start/game_start.sh) into the `game-start` folder
55 | 4. Place file [game-end](./scripts/es-de/game-end/game_end.sh) into the `game-end` folder
56 | 5. Your folder structure should look like this afterwards
57 | ```
58 | .
59 | └── ES-DE/
60 | └── scripts/
61 | └── game-end/
62 | └── game_end.sh
63 | └── game-start/
64 | └── game_start.sh
65 | ```
66 | 6. **Restart ES-DE**
67 | 1. Open the Settings App
68 | 1. Go Apps
69 | 2. Search for ES-DE
70 | 3. Force close the app
71 | 4. Close the Settings App
72 | 7. **Setup Shizuku**
73 | 1. Follow [this guide](https://shizuku.rikka.app/guide/setup/#start-shizuku) to start Shizuku
74 | 8. **Setup KeyMapper**
75 | 1. Open KeyMapper
76 | 2. Follow the instructions to setup KeyMapper
77 | 2. Click on on the Plus symbol
78 | 3. Within the "Trigger" tab setup a key or key combination with that you would like to open and close the HandheldExp Menu
79 | 1. Select if you want to to remap the button
80 | 2. If you have a hardware Home Button, it is recommended to set this as menu toggle button with remapping enabled
81 | 4. With the "Actions" tab click on "Add Trigger"
82 | 1. Select "Send Intent"
83 | 2. Set and description you want
84 | 3. Select "Broadcast receiver"
85 | 4. Enter for the Action field `com.handheld.exp.OVERLAY`
86 | 5. Save your changes
87 | 5. Fully close KeyMapper by swiping it away
88 | 1. On some devices it is required everytime after closing KeyMapper to enable the accessibility service again for KeyMapper in your Settings
89 | 9. **Setup HandheldExp**
90 | 1. Open HandheldExp
91 | 2. Set the location of your ES-DE folder
92 | 3. Set the location of your ES-DE media folder
93 | 1. This is by default the folder `downloaded_media` under the ES-DE folder
94 | 4. Grant overlay permission
95 | 5. Grant Shizuku permission
96 | 6. You can click the button "Open overlay menu" to test if the menu is appearing
97 | 10. **Test if everyhing works**
98 | 1. Open a game via ES-DE
99 | 2. Press the menu toggle button you set in Keymapper
100 | 3. The menu should appear with the respective game information
101 | 11. **Continue with the setup of specific features**
102 | 1. Setup of Quick Save/Load
103 | 2. Optionally Setup of device specific features
104 |
105 | ## 💾 Setup Quick Save/Load
106 |
107 | The following guide will demonstrate how to **setup** Quick Save/Load for HandheldExp.
108 |
109 | 1. **Quick Save** and **Quick Load** have to be setup for **every emulator** individually
110 | 2. As long as the emulator **supports hotkeys** for Quick Save/Load, HandheldExp is able to **integrate** with it
111 | 3. Currently it is **confirmed** that the following emulators **support** Quick Save/Load with HandheldExp
112 | 1. RetroArch, DuckStation, PPSSPP, Flycast, AetherSX2/NetherSX2, Mupen64Plus, Citra PabloMK7, Mandarine, DraStic
113 | 4. The following **guide** will show how to setup Quick Save/Load for **RetroArch**
114 | 1. For other emulators the setup follows a similar principle
115 | 2. Open RetroArch
116 | 3. Navigate to Settings > Input > Hotkeys
117 | 4. Open the HandheldExp menu with the toggle button
118 | 5. Navigate to Other Settings > Load/Save State Setup > Start Setup
119 | 6. The menu will now only react to touch input while you can navigate in RetroArch with your controller
120 | 7. Select in RetroArch the Load State Setting
121 | 1. Click via touch then on "Set Quick Load" in the HandheldExp Menu
122 | 8. Select in RetroArch the Save State Setting
123 | 1. Click via touch then on "Set Quick Save" in the HandheldExp Menu
124 | 9. Click on via touch on "Stop Setup"
125 | 10. The setup for RetroArch is now done
126 |
127 | ## 🤖 Device Specific Features
128 | HandheldExp also supports **features specifically tailored** for **certain devices**. The following **lists the devices** those extra features are supported for:
129 |
130 | ### 🎮 Retroid Pocket 4 Pro / Ayn Odin 2 / Ayn Odin 2 Mini
131 | 1. **Supported extra features**
132 | 1. Performance mode selection via Menu
133 | 2. Fan mode selection via Menu
134 | 3. Trigger mode selection via Menu
135 | 4. Fan and Performance mode will automatically reset when returning back to ES-DE
136 | 2. **Disclaimer**: Extra features might stop working when upgrading to a new firmare for the respective devices or behave unexpectedly
137 |
138 | ### 🎮 Other devices
139 | 1. Currently other devices do not have support for **extra features**
140 | 2. It is **planned** to add extra support for other device such as
141 | 1. Anbernic RG556
142 | 2. Anbernic RG Cube
143 | 3. Other potential Android Handhelds
144 |
145 |
155 |
156 | ## 🎖️ Acknowlegements
157 | 1. [OdinTools](https://github.com/langerhans/OdinTools) for figuring out on how to change Ayn Odin 2 **device specific settings** and gaining access to a **privileged shell executor**
158 | 2. [ES-DE GitLab project](https://gitlab.com/es-de/emulationstation-de) for certain **assets** like fonts or images
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | id("org.jetbrains.kotlin.android")
4 | }
5 |
6 | android {
7 | namespace = "com.handheld.exp"
8 | compileSdk = 34
9 |
10 | defaultConfig {
11 | applicationId = "com.handheld.exp"
12 | minSdk = 24
13 | targetSdk = 34
14 | versionCode = 1
15 | versionName = "1.0"
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | vectorDrawables {
19 | useSupportLibrary = true
20 | }
21 | }
22 |
23 | buildTypes {
24 | release {
25 | isMinifyEnabled = false
26 | proguardFiles(
27 | getDefaultProguardFile("proguard-android-optimize.txt"),
28 | "proguard-rules.pro"
29 | )
30 | }
31 | }
32 | compileOptions {
33 | sourceCompatibility = JavaVersion.VERSION_1_8
34 | targetCompatibility = JavaVersion.VERSION_1_8
35 | }
36 | kotlinOptions {
37 | jvmTarget = "1.8"
38 | }
39 | buildFeatures {
40 | compose = true
41 | }
42 | composeOptions {
43 | kotlinCompilerExtensionVersion = "1.5.1"
44 | }
45 | packaging {
46 | resources {
47 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
48 | }
49 | }
50 | }
51 |
52 | dependencies {
53 | implementation("androidx.core:core-ktx:1.12.0")
54 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
55 | implementation("androidx.activity:activity-compose:1.8.1")
56 | implementation(platform("androidx.compose:compose-bom:2023.08.00"))
57 | implementation("androidx.compose.ui:ui")
58 | implementation("androidx.compose.ui:ui-graphics")
59 | implementation("androidx.compose.ui:ui-tooling-preview")
60 | implementation("androidx.compose.material3:material3")
61 | implementation("androidx.recyclerview:recyclerview:1.3.2")
62 | implementation("androidx.documentfile:documentfile:1.0.1")
63 | testImplementation("junit:junit:4.13.2")
64 | androidTestImplementation("androidx.test.ext:junit:1.1.5")
65 | androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
66 | androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
67 | androidTestImplementation("androidx.compose.ui:ui-test-junit4")
68 | debugImplementation("androidx.compose.ui:ui-tooling")
69 | debugImplementation("androidx.compose.ui:ui-test-manifest")
70 | implementation("com.github.mhiew:android-pdf-viewer:3.2.0-beta.3")
71 | implementation("dev.rikka.shizuku:api:13.1.5")
72 | implementation("dev.rikka.shizuku:provider:13.1.5")
73 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
23 | -keep class com.shockwave.**
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/handheld/exp/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.handheld.exp", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
10 |
12 |
14 |
15 |
16 |
26 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
44 |
48 |
49 |
50 |
51 |
52 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp
2 |
3 | import ItemAdapter
4 | import android.content.ComponentName
5 | import android.content.Intent
6 | import android.content.pm.ActivityInfo
7 | import android.net.Uri
8 | import android.os.Bundle
9 | import android.provider.Settings
10 | import androidx.activity.ComponentActivity
11 | import androidx.recyclerview.widget.LinearLayoutManager
12 | import androidx.recyclerview.widget.RecyclerView
13 | import com.handheld.exp.models.ButtonItem
14 | import com.handheld.exp.models.Item
15 | import com.handheld.exp.models.NavigationItem
16 | import com.handheld.exp.receivers.DataReceiver
17 | import com.handheld.exp.receivers.OverlayServiceReceiver
18 | import com.handheld.exp.utils.DisplayUtils
19 | import com.handheld.exp.utils.HandlerUtils
20 | import com.handheld.exp.utils.PreferenceUtils
21 | import com.handheld.exp.utils.ShizukuUtils
22 |
23 | class MainActivity : ComponentActivity() {
24 |
25 | private val preferenceUtils = PreferenceUtils(this)
26 |
27 | private val setEsDePathItem = ButtonItem(
28 | label = "Set ES-DE Location",
29 | key = "set_es_de_location",
30 | sortKey = "a"
31 | ) {
32 | requestEsDeFolderPathAndPermission()
33 | }
34 |
35 | private val setEsDeMediaPathItem = ButtonItem(
36 | label = "Set ES-DE Media Location",
37 | key = "set_es_de_media_location",
38 | sortKey = "b"
39 | ) {
40 | requestEsDeMediaFolderPathAndPermission()
41 | }
42 |
43 | private val grantOverlayPermissionItem = ButtonItem(
44 | label = "Grant Overlay Permission",
45 | key = "grant_overlay_permission",
46 | sortKey = "c"
47 | ) {
48 | requestDrawOverlayPermission()
49 | }
50 |
51 | private val grantShizukuPermission = ButtonItem(
52 | label = "Grant Shizuku Permission",
53 | key = "grant_shizuku_permission",
54 | sortKey = "e"
55 | ) {
56 | ShizukuUtils.requestPermission()
57 | }
58 |
59 | private val openOverlayItem = ButtonItem(
60 | label = "Open Overlay Menu",
61 | key = "open_overlay",
62 | sortKey = "f"
63 | ) {
64 | onOpenOverlayMenu()
65 | }
66 |
67 | private val menuItems = listOf(
68 | setEsDePathItem,
69 | setEsDeMediaPathItem,
70 | grantOverlayPermissionItem,
71 | grantShizukuPermission,
72 | openOverlayItem
73 | )
74 |
75 | override fun onCreate(savedInstanceState: Bundle?) {
76 | super.onCreate(savedInstanceState)
77 | requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
78 | setContentView(R.layout.main_activity_layout);
79 | createUi()
80 | }
81 |
82 | private fun createUi() {
83 |
84 | DisplayUtils.applyFixedScreenDensity(this, this.windowManager)
85 |
86 | val recyclerView: RecyclerView = findViewById(R.id.mainActivityItemList)
87 |
88 | val navigationHandler = object : ItemAdapter.NavigationHandler {
89 | override fun onNavigateTo(navigationItem: NavigationItem) {
90 | }
91 |
92 | override fun onNavigateBack() {
93 | }
94 | }
95 |
96 | val adapter = ItemAdapter(
97 | menuItems, navigationHandler
98 | )
99 | adapter.setHasStableIds(true)
100 |
101 | recyclerView.adapter = adapter
102 | recyclerView.itemAnimator = null
103 |
104 | recyclerView.layoutManager = LinearLayoutManager(this)
105 | }
106 |
107 | private fun requestDrawOverlayPermission(): Boolean {
108 | val intent = Intent(
109 | Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
110 | Uri.parse("package:$packageName")
111 | )
112 | startActivityForResult(intent, REQUEST_OVERLAY_PERMISSION_CODE)
113 | return false
114 | }
115 |
116 | private fun requestEsDeFolderPathAndPermission() {
117 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
118 | startActivityForResult(intent, REQUEST_ES_DE_FOLDER_PERMISSION_CODE)
119 | }
120 |
121 | private fun requestEsDeMediaFolderPathAndPermission() {
122 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
123 | startActivityForResult(intent, REQUEST_ES_DE_MEDIA_FOLDER_PERMISSION_CODE)
124 | }
125 |
126 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
127 | super.onActivityResult(requestCode, resultCode, data)
128 |
129 | if (requestCode == REQUEST_ES_DE_FOLDER_PERMISSION_CODE && resultCode == RESULT_OK) {
130 | handleFolderPermissionGrant(data, "es_de_folder_uri")
131 | return
132 | }
133 |
134 | if (requestCode == REQUEST_ES_DE_MEDIA_FOLDER_PERMISSION_CODE && resultCode == RESULT_OK) {
135 | handleFolderPermissionGrant(data, "es_de_media_folder_uri")
136 | return
137 | }
138 | }
139 |
140 | private fun handleFolderPermissionGrant(data: Intent?, storageKey: String) {
141 | if (data == null) {
142 | return
143 | }
144 |
145 | val uri = data.data ?: return
146 |
147 | val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
148 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
149 |
150 | contentResolver.takePersistableUriPermission(uri, takeFlags)
151 |
152 | preferenceUtils.setPreference(storageKey, uri.toString())
153 | }
154 |
155 | private fun onOpenOverlayMenu() {
156 | val startOverlayServiceIntent = Intent(OverlayServiceReceiver.OVERLAY_SERVICE_ACTION)
157 | startOverlayServiceIntent.component = ComponentName(
158 | packageName,
159 | "com.handheld.exp.receivers.OverlayServiceReceiver"
160 | )
161 | sendBroadcast(startOverlayServiceIntent)
162 |
163 | HandlerUtils.runDelayed(200){
164 | val openMenuIntent = Intent(DataReceiver.OVERLAY_ACTION)
165 | openMenuIntent.putExtra(DataReceiver.COMMAND_EXTRA, "open")
166 | sendBroadcast(openMenuIntent)
167 | }
168 | }
169 |
170 | companion object {
171 | private const val REQUEST_OVERLAY_PERMISSION_CODE = 0
172 | private const val REQUEST_ES_DE_FOLDER_PERMISSION_CODE = 1
173 | private const val REQUEST_ES_DE_MEDIA_FOLDER_PERMISSION_CODE = 2
174 | }
175 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/OverlayService.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.Notification
5 | import android.app.NotificationChannel
6 | import android.app.NotificationManager
7 | import android.app.Service
8 | import android.content.Intent
9 | import android.content.pm.ActivityInfo
10 | import android.graphics.PixelFormat
11 | import android.os.Build
12 | import android.os.IBinder
13 | import android.view.Gravity
14 | import android.view.LayoutInflater
15 | import android.view.View
16 | import android.view.WindowManager
17 | import android.view.WindowManager.LayoutParams
18 | import androidx.annotation.RequiresApi
19 | import androidx.core.app.NotificationCompat
20 | import com.handheld.exp.models.OverlayState
21 | import com.handheld.exp.modules.ModuleLoader
22 | import com.handheld.exp.receivers.DataReceiver
23 | import com.handheld.exp.utils.DisplayUtils
24 |
25 | class OverlayService : Service() {
26 |
27 | private var dataReceiver: DataReceiver? = null
28 |
29 | private var windowManager: WindowManager? = null
30 | private var overlayView: View? = null
31 |
32 | private var params: LayoutParams? = null
33 |
34 | private val overlayViewModel = OverlayViewModel()
35 |
36 | override fun onBind(intent: Intent?): IBinder? {
37 | return null
38 | }
39 |
40 | @SuppressLint("ForegroundServiceType")
41 | override fun onCreate() {
42 | super.onCreate()
43 |
44 | if (Build.VERSION.SDK_INT >= 26) {
45 | startForeground()
46 | }
47 |
48 | createWindowManager()
49 |
50 | createOverlayView()
51 |
52 | handleOverlayDisplay()
53 |
54 | val moduleLoader = ModuleLoader(this, overlayViewModel, overlayView!!)
55 | moduleLoader.loadAll()
56 |
57 | createDataReceiver()
58 | }
59 |
60 | override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
61 | return START_STICKY
62 | }
63 |
64 | @SuppressLint("ForegroundServiceType")
65 | @RequiresApi(Build.VERSION_CODES.O)
66 | private fun startForeground() {
67 | val channelId = "handheld_exp_notification_channel"
68 | val channel = NotificationChannel(
69 | channelId, "Overlay notification", NotificationManager.IMPORTANCE_LOW
70 | )
71 | (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(
72 | channel
73 | )
74 | val notification: Notification = NotificationCompat.Builder(this, channelId)
75 | .setContentTitle("HandheldExp")
76 | .setContentText("HandheldExp is showing the in-game overlay")
77 | .setOngoing(true)
78 | .build()
79 |
80 | startForeground(1, notification)
81 | }
82 |
83 | private fun createWindowManager() {
84 |
85 | val type =
86 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) LayoutParams.TYPE_APPLICATION_OVERLAY
87 | else LayoutParams.TYPE_PHONE
88 |
89 | params = LayoutParams(
90 | LayoutParams.MATCH_PARENT,
91 | LayoutParams.MATCH_PARENT,
92 | type,
93 | WINDOW_MANAGER_FOCUSED_FLAGS,
94 | PixelFormat.RGBA_8888
95 | )
96 |
97 | windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
98 |
99 | params!!.gravity = Gravity.TOP or Gravity.LEFT
100 | params!!.x = 0
101 | params!!.y = 0
102 |
103 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
104 | params!!.layoutInDisplayCutoutMode =
105 | LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
106 | }
107 |
108 | params!!.screenOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
109 |
110 | DisplayUtils.applyFixedScreenDensity(this, windowManager!!)
111 | }
112 |
113 | private fun createOverlayView() {
114 | overlayView = LayoutInflater.from(this)
115 | .inflate(R.layout.overlay_layout, null)
116 |
117 | overlayView!!.systemUiVisibility =
118 | View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
119 | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
120 | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
121 | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
122 | View.SYSTEM_UI_FLAG_FULLSCREEN or
123 | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
124 |
125 | overlayView?.visibility = View.GONE
126 | windowManager?.addView(overlayView, params)
127 | }
128 |
129 | private fun createDataReceiver() {
130 | dataReceiver = DataReceiver(this, overlayViewModel)
131 | registerReceiver(dataReceiver, dataReceiver!!.gameFilter)
132 | registerReceiver(dataReceiver, dataReceiver!!.overlayFilter)
133 | }
134 |
135 | private fun handleOverlayDisplay() {
136 | overlayViewModel.overlayState.observeForever {
137 | when (it) {
138 | OverlayState.CLOSED -> overlayView?.visibility = View.GONE
139 | OverlayState.OPENED -> {
140 | overlayView?.visibility = View.VISIBLE
141 | setOverlayFlags(WINDOW_MANAGER_FOCUSED_FLAGS)
142 | }
143 | OverlayState.OPENED_ONLY_TOUCH -> {
144 | overlayView?.visibility = View.VISIBLE
145 | setOverlayFlags(WINDOW_MANAGER_ONLY_TOUCH_FLAGS)
146 | }
147 | else -> {}
148 | }
149 | }
150 | }
151 |
152 | private fun setOverlayFlags(flags: Int){
153 | if(params!!.flags == flags){
154 | return;
155 | }
156 |
157 | params!!.flags = flags
158 | windowManager!!.updateViewLayout(overlayView, params)
159 | }
160 |
161 | companion object {
162 | private const val WINDOW_MANAGER_FOCUSED_FLAGS = LayoutParams.FLAG_LAYOUT_IN_SCREEN or
163 | LayoutParams.FLAG_LAYOUT_NO_LIMITS
164 | private const val WINDOW_MANAGER_ONLY_TOUCH_FLAGS = LayoutParams.FLAG_LAYOUT_IN_SCREEN or
165 | LayoutParams.FLAG_LAYOUT_NO_LIMITS or
166 | LayoutParams.FLAG_NOT_FOCUSABLE
167 |
168 | }
169 |
170 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/OverlayViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp
2 |
3 | import androidx.lifecycle.MutableLiveData
4 | import com.handheld.exp.models.AppContext
5 | import com.handheld.exp.models.GameContext
6 | import com.handheld.exp.models.Item
7 | import com.handheld.exp.models.NavigationItem
8 | import com.handheld.exp.models.OverlayState
9 |
10 | class OverlayViewModel {
11 |
12 | val overlayState = MutableLiveData()
13 |
14 | val menuTitle = MutableLiveData()
15 |
16 | val currentGameContext = MutableLiveData()
17 |
18 | val menuItems = MutableLiveData>()
19 | val pathHistory = MutableLiveData>>()
20 |
21 | val currentAppContext = MutableLiveData()
22 |
23 | init {
24 | menuItems.value = mutableListOf()
25 | pathHistory.value = ArrayDeque()
26 |
27 | overlayState.value = OverlayState.CLOSED
28 |
29 | menuTitle.value = ""
30 | }
31 |
32 | fun getCurrentMenuItems(customFilter: (Item, String) -> Boolean = { item, path -> false }): List {
33 | val currentPathStr = pathHistory.value?.lastOrNull()
34 | ?.joinToString(".") { it.key } ?: ""
35 |
36 | return menuItems.value!!
37 | .filter {
38 | val itemPathStr = it.path.joinToString(".")
39 |
40 | itemPathStr == currentPathStr || customFilter(it, currentPathStr)
41 | }
42 | .filter { !it.disabled }
43 | .sortedBy { it.sortKey }
44 | }
45 |
46 | fun getCurrentNavigationItem(): NavigationItem? {
47 | return pathHistory.value?.lastOrNull()
48 | ?.lastOrNull()
49 | }
50 |
51 | fun navigateTo(path: List) {
52 | val items = path
53 | .mapNotNull { pathPart ->
54 | menuItems.value!!.find { item -> item.key == pathPart }
55 | }
56 | .map { it as NavigationItem }
57 |
58 | pathHistory.value!!.add(items)
59 | pathHistory.value = pathHistory.value
60 | }
61 |
62 | fun navigateBack() {
63 | val last = pathHistory.value!!.removeLastOrNull()
64 | if (last == null) {
65 | closeOverlay()
66 | return
67 | }
68 | pathHistory.value = pathHistory.value
69 | }
70 |
71 | fun closeOverlay() {
72 | overlayState.value = OverlayState.CLOSED
73 | }
74 |
75 | fun setOverlay(state: OverlayState) {
76 | overlayState.value = state
77 | }
78 |
79 | fun toggleOverlay() {
80 | if (overlayState.value != OverlayState.CLOSED) {
81 | closeOverlay()
82 | return
83 | }
84 |
85 | setOverlay(OverlayState.OPENED)
86 | }
87 |
88 | fun startGameContext(
89 | gameContext: GameContext
90 | ) {
91 | currentGameContext.value = gameContext
92 | }
93 |
94 | fun endGameContext() {
95 | currentGameContext.value = null
96 | }
97 |
98 | fun isGameContextActive(): Boolean {
99 | return currentGameContext.value != null
100 | }
101 |
102 | fun notifyMenuItemsChanged() {
103 | menuItems.value = menuItems.value
104 | }
105 |
106 | fun startAppContext(appContext: AppContext?) {
107 | currentAppContext.value = appContext
108 | }
109 |
110 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/adapters/ItemAdapter.kt:
--------------------------------------------------------------------------------
1 | import android.annotation.SuppressLint
2 | import android.view.KeyEvent
3 | import android.view.LayoutInflater
4 | import android.view.View
5 | import android.view.ViewGroup
6 | import android.widget.ImageView
7 | import android.widget.TextView
8 | import androidx.recyclerview.widget.RecyclerView
9 | import com.handheld.exp.R
10 | import com.handheld.exp.models.ButtonItem
11 | import com.handheld.exp.models.Item
12 | import com.handheld.exp.models.NavigationItem
13 | import com.handheld.exp.models.OptionItem
14 | import com.handheld.exp.models.TextItem
15 |
16 | class ItemAdapter(
17 | private var items: List,
18 | private val navigationHandler: NavigationHandler
19 | ) : RecyclerView.Adapter() {
20 |
21 | companion object {
22 | private const val VIEW_TYPE_OPTION = 0
23 | private const val VIEW_TYPE_BUTTON = 1
24 | private const val VIEW_TYPE_NAVIGATION = 2
25 | private const val VIEW_TYPE_TEXT = 3
26 | }
27 |
28 | init {
29 | setItems(items)
30 | }
31 |
32 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
33 |
34 | val view = LayoutInflater.from(parent.context)
35 | .inflate(R.layout.common_item_layout, parent, false)
36 |
37 | return when (viewType) {
38 | VIEW_TYPE_OPTION -> OptionViewHolder(view)
39 | VIEW_TYPE_BUTTON -> ButtonViewHolder(view)
40 | VIEW_TYPE_NAVIGATION -> NavigationViewHolder(view)
41 | VIEW_TYPE_TEXT -> TextViewHolder(view)
42 | else -> throw IllegalArgumentException("Invalid view type")
43 | }
44 | }
45 |
46 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
47 | val item = items[position]
48 |
49 | when (holder.itemViewType) {
50 | VIEW_TYPE_OPTION -> {
51 | val optionHolder = holder as OptionViewHolder
52 | optionHolder.bind(item as OptionItem)
53 | }
54 |
55 | VIEW_TYPE_BUTTON -> {
56 | val buttonHolder = holder as ButtonViewHolder
57 | buttonHolder.bind(item as ButtonItem)
58 | }
59 |
60 | VIEW_TYPE_NAVIGATION -> {
61 | val navigationHolder = holder as NavigationViewHolder
62 | navigationHolder.bind(item as NavigationItem)
63 | }
64 |
65 | VIEW_TYPE_TEXT -> {
66 | val textHolder = holder as TextViewHolder
67 | textHolder.bind(item as TextItem)
68 | }
69 | }
70 | }
71 |
72 | override fun getItemCount(): Int {
73 | return items.size
74 | }
75 |
76 | override fun getItemViewType(position: Int): Int {
77 | return when (items[position]) {
78 | is OptionItem -> VIEW_TYPE_OPTION
79 | is ButtonItem -> VIEW_TYPE_BUTTON
80 | is NavigationItem -> VIEW_TYPE_NAVIGATION
81 | is TextItem -> VIEW_TYPE_TEXT
82 | else -> throw IllegalArgumentException("Invalid item type")
83 | }
84 | }
85 |
86 | override fun getItemId(position: Int): Long {
87 | return position.toLong()
88 | }
89 |
90 | fun setItems(items: List) {
91 | this.items = items
92 | notifyDataSetChanged()
93 | }
94 |
95 | abstract inner class ItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
96 | protected val labelTextView: TextView = itemView.findViewById(R.id.label)
97 | protected val selectorView: View = itemView.findViewById(R.id.selector)
98 | protected val selectorTextView: TextView = itemView.findViewById(R.id.value)
99 | protected val arrowView: View = itemView.findViewById(R.id.arrow)
100 | protected val iconView: ImageView = itemView.findViewById(R.id.icon)
101 |
102 | init {
103 | selectorView.visibility = View.GONE
104 | arrowView.visibility = View.GONE
105 | }
106 |
107 | protected fun bind(item: Item){
108 | if(item.icon > -1){
109 | iconView.setImageResource(item.icon);
110 | iconView.visibility = View.VISIBLE
111 | iconView.visibility = View.GONE
112 | }else{
113 | iconView.setImageDrawable(null)
114 | iconView.visibility = View.GONE
115 | }
116 | }
117 |
118 | @SuppressLint("ClickableViewAccessibility")
119 | protected fun setDefaultListeners(
120 | onClick: () -> Unit,
121 | onKeyDown: (keyCode: Int) -> Boolean
122 | ) {
123 | itemView.setOnClickListener {
124 | onClick()
125 | }
126 |
127 | itemView.setOnKeyListener { _, keyCode, event ->
128 | if (event?.action != KeyEvent.ACTION_DOWN) {
129 | return@setOnKeyListener false
130 | }
131 |
132 | when (keyCode) {
133 | KeyEvent.KEYCODE_BACK -> {
134 | navigationHandler.onNavigateBack()
135 | return@setOnKeyListener true
136 | }
137 | }
138 | return@setOnKeyListener onKeyDown(keyCode)
139 | }
140 | }
141 | }
142 |
143 | inner class OptionViewHolder(itemView: View) : ItemViewHolder(itemView) {
144 |
145 | fun bind(optionItem: OptionItem) {
146 | super.bind(optionItem)
147 | selectorView.visibility = View.VISIBLE
148 | labelTextView.text = optionItem.label
149 | selectorTextView.text = optionItem.getOption().label
150 |
151 | setDefaultListeners(
152 | onClick = {
153 | optionItem.nextOption()
154 | optionItem.notifyOnOptionChange()
155 | },
156 | onKeyDown = {
157 | when (it) {
158 | KeyEvent.KEYCODE_DPAD_LEFT -> {
159 | optionItem.prevOption()
160 | optionItem.notifyOnOptionChange()
161 | return@setDefaultListeners true
162 | }
163 |
164 | KeyEvent.KEYCODE_DPAD_RIGHT -> {
165 | optionItem.nextOption()
166 | optionItem.notifyOnOptionChange()
167 | return@setDefaultListeners true
168 | }
169 |
170 | KeyEvent.KEYCODE_BACK -> {
171 | navigationHandler.onNavigateBack()
172 | return@setDefaultListeners true
173 | }
174 |
175 | else -> false
176 | }
177 | }
178 | )
179 | }
180 | }
181 |
182 | inner class ButtonViewHolder(itemView: View) : ItemViewHolder(itemView) {
183 | fun bind(item: ButtonItem) {
184 | super.bind(item)
185 | labelTextView.text = item.label
186 | setDefaultListeners(
187 | onClick = { item.onClick() },
188 | onKeyDown = { false }
189 | )
190 | }
191 | }
192 |
193 | inner class NavigationViewHolder(itemView: View) : ItemViewHolder(itemView) {
194 |
195 | fun bind(navigationItem: NavigationItem) {
196 | super.bind(navigationItem)
197 | labelTextView.text = navigationItem.label
198 | arrowView.visibility = View.VISIBLE
199 |
200 | setDefaultListeners(
201 | onClick = { navigationHandler.onNavigateTo(navigationItem) },
202 | onKeyDown = { false }
203 | )
204 | }
205 | }
206 |
207 | inner class TextViewHolder(itemView: View) : ItemViewHolder(itemView) {
208 | fun bind(textItem: TextItem) {
209 | super.bind(textItem)
210 | labelTextView.text = textItem.label
211 | }
212 | }
213 |
214 | interface NavigationHandler {
215 | fun onNavigateTo(navigationItem: NavigationItem)
216 | fun onNavigateBack()
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/models/AppContext.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp.models
2 |
3 | data class AppContext(
4 | val taskId: Int?,
5 | val name: String,
6 | val packageName: String
7 | ){
8 |
9 | companion object{
10 | public const val ES_DE_PACKAGE_NAME = "org.es_de.frontend"
11 | public const val OWN_PACKAGE_NAME = "com.handheld.exp"
12 | }
13 |
14 | val isEsDe : Boolean
15 | get() = packageName == ES_DE_PACKAGE_NAME
16 |
17 | val isSelf : Boolean
18 | get() = packageName == OWN_PACKAGE_NAME
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/models/ButtonItem.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp.models
2 |
3 | class ButtonItem(
4 | label: String,
5 | key: String,
6 | icon: Int = -1,
7 | path: List = listOf(),
8 | sortKey: String = "",
9 | disabled: Boolean = false,
10 | val onClick: () -> Unit
11 | ) : Item(label, key, icon, path, sortKey, disabled)
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/models/Game.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp.models
2 |
3 | data class Game(
4 | val path: String? = null,
5 | val name: String? = null,
6 | val sortName: String? = null,
7 | val collectionSortName: String? = null,
8 | val desc: String? = null,
9 | val rating: Float? = null,
10 | val releaseDate: String? = null,
11 | val developer: String? = null,
12 | val publisher: String? = null,
13 | val genre: String? = null,
14 | val players: Int? = null,
15 | val favorite: Boolean? = null,
16 | val completed: Boolean? = null,
17 | val kidGame: Boolean? = null,
18 | val hidden: Boolean? = null,
19 | val broken: Boolean? = null,
20 | val noGameCount: Boolean? = null,
21 | val noMultiScrape: Boolean? = null,
22 | val hideMetadata: Boolean? = null,
23 | val playCount: Int? = null,
24 | val controller: String? = null,
25 | val altEmulator: String? = null,
26 | val lastPlayed: String? = null
27 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/models/GameContext.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp.models
2 |
3 | data class GameContext(
4 | val esDeFolderPath: String,
5 | val game: Game,
6 | val system: System,
7 | val gameMedia: GameMedia
8 | )
9 |
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/models/GameMedia.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp.models
2 |
3 | data class GameMedia(
4 | val mixImagePath: String? = null,
5 | val backCoverPath: String? = null,
6 | val coverPath: String? = null,
7 | val marqueePath: String? = null,
8 | val titleScreenPath: String? = null,
9 | val screenshotPath: String? = null,
10 | val manualPath: String? = null
11 | )
12 |
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/models/Item.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp.models
2 |
3 | abstract class Item(
4 | var label: String,
5 | val key: String,
6 | val icon: Int = -1,
7 | val path: List = listOf(),
8 | var sortKey: String = "",
9 | var disabled: Boolean = false
10 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/models/NavigationItem.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp.models
2 |
3 | class NavigationItem(
4 | label: String,
5 | key: String,
6 | icon: Int = -1,
7 | path: List = listOf(),
8 | sortKey: String = "",
9 | disabled: Boolean = false,
10 | ) : Item(label, key, icon, path, sortKey, disabled)
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/models/Option.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp.models
2 |
3 | data class Option(
4 | val label: String,
5 | val key: String
6 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/handheld/exp/models/OptionItem.kt:
--------------------------------------------------------------------------------
1 | package com.handheld.exp.models
2 |
3 | class OptionItem(
4 | label: String,
5 | key: String,
6 | icon: Int = -1,
7 | path: List = listOf(),
8 | sortKey: String = "",
9 | disabled: Boolean = false,
10 | var options: List