Android: Multiple fixes
- Fix possible crash when applying settings worker thread (no JNIEnv). - Fix settings not applying until restarting the app. - Support analog controller - auto-binding of axixes. Currently no touchscreen controller for the joysticks. - Add option to auto-hide the touchscreen controller.
This commit is contained in:
parent
c7b457de9e
commit
24ffe6f67e
|
@ -3,7 +3,6 @@ set(SRCS
|
|||
android_host_interface.h
|
||||
android_settings_interface.cpp
|
||||
android_settings_interface.h
|
||||
main.cpp
|
||||
)
|
||||
|
||||
add_library(duckstation-native SHARED ${SRCS})
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
#include "core/system.h"
|
||||
#include "frontend-common/opengl_host_display.h"
|
||||
#include "frontend-common/vulkan_host_display.h"
|
||||
#include "frontend-common/imgui_styles.h"
|
||||
#include <android/native_window_jni.h>
|
||||
#include <cmath>
|
||||
#include <imgui.h>
|
||||
|
@ -195,8 +196,18 @@ void AndroidHostInterface::RunOnEmulationThread(std::function<void()> function,
|
|||
|
||||
void AndroidHostInterface::EmulationThreadEntryPoint(ANativeWindow* initial_surface, SystemBootParameters boot_params)
|
||||
{
|
||||
JNIEnv* thread_env;
|
||||
if (s_jvm->AttachCurrentThread(&thread_env, nullptr) != JNI_OK)
|
||||
{
|
||||
Log_ErrorPrintf("Failed to attach JNI to thread");
|
||||
m_emulation_thread_start_result.store(false);
|
||||
m_emulation_thread_started.Signal();
|
||||
return;
|
||||
}
|
||||
|
||||
CreateImGuiContext();
|
||||
m_surface = initial_surface;
|
||||
ApplySettings();
|
||||
|
||||
// Boot system.
|
||||
if (!BootSystem(boot_params))
|
||||
|
@ -205,6 +216,7 @@ void AndroidHostInterface::EmulationThreadEntryPoint(ANativeWindow* initial_surf
|
|||
DestroyImGuiContext();
|
||||
m_emulation_thread_start_result.store(false);
|
||||
m_emulation_thread_started.Signal();
|
||||
s_jvm->DetachCurrentThread();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -256,6 +268,7 @@ void AndroidHostInterface::EmulationThreadEntryPoint(ANativeWindow* initial_surf
|
|||
|
||||
DestroySystem();
|
||||
DestroyImGuiContext();
|
||||
s_jvm->DetachCurrentThread();
|
||||
}
|
||||
|
||||
bool AndroidHostInterface::AcquireHostDisplay()
|
||||
|
@ -297,31 +310,6 @@ void AndroidHostInterface::ReleaseHostDisplay()
|
|||
m_display.reset();
|
||||
}
|
||||
|
||||
std::unique_ptr<AudioStream> AndroidHostInterface::CreateAudioStream(AudioBackend backend)
|
||||
{
|
||||
std::unique_ptr<AudioStream> stream;
|
||||
|
||||
switch (m_settings.audio_backend)
|
||||
{
|
||||
case AudioBackend::Cubeb:
|
||||
stream = AudioStream::CreateCubebAudioStream();
|
||||
break;
|
||||
|
||||
default:
|
||||
stream = AudioStream::CreateNullAudioStream();
|
||||
break;
|
||||
}
|
||||
|
||||
if (!stream)
|
||||
{
|
||||
ReportFormattedError("Failed to create %s audio stream, falling back to null",
|
||||
Settings::GetAudioBackendName(m_settings.audio_backend));
|
||||
stream = AudioStream::CreateNullAudioStream();
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
void AndroidHostInterface::SurfaceChanged(ANativeWindow* surface, int format, int width, int height)
|
||||
{
|
||||
Log_InfoPrintf("SurfaceChanged %p %d %d %d", surface, format, width, height);
|
||||
|
@ -351,9 +339,16 @@ void AndroidHostInterface::CreateImGuiContext()
|
|||
{
|
||||
ImGui::CreateContext();
|
||||
|
||||
ImGui::GetIO().IniFilename = nullptr;
|
||||
// ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
|
||||
// ImGui::GetIO().BackendFlags |= ImGuiBackendFlags_HasGamepad;
|
||||
const float framebuffer_scale = 2.0f;
|
||||
|
||||
auto& io = ImGui::GetIO();
|
||||
io.IniFilename = nullptr;
|
||||
io.DisplayFramebufferScale.x = framebuffer_scale;
|
||||
io.DisplayFramebufferScale.y = framebuffer_scale;
|
||||
ImGui::GetStyle().ScaleAllSizes(framebuffer_scale);
|
||||
|
||||
ImGui::StyleColorsDarker();
|
||||
ImGui::AddRobotoRegularFont(15.0f * framebuffer_scale);
|
||||
}
|
||||
|
||||
void AndroidHostInterface::DestroyImGuiContext()
|
||||
|
@ -397,6 +392,22 @@ void AndroidHostInterface::SetControllerButtonState(u32 index, s32 button_code,
|
|||
false);
|
||||
}
|
||||
|
||||
void AndroidHostInterface::SetControllerAxisState(u32 index, s32 button_code, float value)
|
||||
{
|
||||
if (!IsEmulationThreadRunning())
|
||||
return;
|
||||
|
||||
RunOnEmulationThread(
|
||||
[this, index, button_code, value]() {
|
||||
Controller* controller = m_system->GetController(index);
|
||||
if (!controller)
|
||||
return;
|
||||
|
||||
controller->SetAxisState(button_code, value);
|
||||
},
|
||||
false);
|
||||
}
|
||||
|
||||
void AndroidHostInterface::RefreshGameList(bool invalidate_cache, bool invalidate_database)
|
||||
{
|
||||
m_game_list->SetSearchDirectoriesFromSettings(m_settings_interface);
|
||||
|
@ -544,6 +555,25 @@ DEFINE_JNI_ARGS_METHOD(jint, AndroidHostInterface_getControllerButtonCode, jobje
|
|||
return code.value_or(-1);
|
||||
}
|
||||
|
||||
DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_setControllerAxisState, jobject obj, jint index, jint button_code,
|
||||
jfloat value)
|
||||
{
|
||||
AndroidHelpers::GetNativeClass(env, obj)->SetControllerAxisState(index, button_code, value);
|
||||
}
|
||||
|
||||
DEFINE_JNI_ARGS_METHOD(jint, AndroidHostInterface_getControllerAxisCode, jobject unused, jstring controller_type,
|
||||
jstring axis_name)
|
||||
{
|
||||
std::optional<ControllerType> type =
|
||||
Settings::ParseControllerTypeName(AndroidHelpers::JStringToString(env, controller_type).c_str());
|
||||
if (!type)
|
||||
return -1;
|
||||
|
||||
std::optional<s32> code =
|
||||
Controller::GetAxisCodeByName(type.value(), AndroidHelpers::JStringToString(env, axis_name));
|
||||
return code.value_or(-1);
|
||||
}
|
||||
|
||||
DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_refreshGameList, jobject obj, jboolean invalidate_cache, jboolean invalidate_database)
|
||||
{
|
||||
AndroidHelpers::GetNativeClass(env, obj)->RefreshGameList(invalidate_cache, invalidate_database);
|
||||
|
|
|
@ -43,6 +43,7 @@ public:
|
|||
|
||||
void SetControllerType(u32 index, std::string_view type_name);
|
||||
void SetControllerButtonState(u32 index, s32 button_code, bool pressed);
|
||||
void SetControllerAxisState(u32 index, s32 button_code, float value);
|
||||
|
||||
void RefreshGameList(bool invalidate_cache, bool invalidate_database);
|
||||
void ApplySettings();
|
||||
|
@ -54,7 +55,6 @@ protected:
|
|||
|
||||
bool AcquireHostDisplay() override;
|
||||
void ReleaseHostDisplay() override;
|
||||
std::unique_ptr<AudioStream> CreateAudioStream(AudioBackend backend) override;
|
||||
|
||||
private:
|
||||
void EmulationThreadEntryPoint(ANativeWindow* initial_surface, SystemBootParameters boot_params);
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
#include "core/host_interface.h"
|
||||
#include <jni.h>
|
||||
|
||||
#define DEFINE_JNI_METHOD(return_type, name, ...) \
|
||||
extern "C" JNIEXPORT return_type JNICALL Java_com_github_stenzek_duckstation_##name(__VA_ARGS__)
|
||||
|
||||
DEFINE_JNI_METHOD(bool, createSystem)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DEFINE_JNI_METHOD(bool, bootSystem, const char* filename, const char* state_filename)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DEFINE_JNI_METHOD(void, runFrame) {}
|
|
@ -23,7 +23,9 @@ public class AndroidHostInterface
|
|||
// TODO: Find a better place for this.
|
||||
public native void setControllerType(int index, String typeName);
|
||||
public native void setControllerButtonState(int index, int buttonCode, boolean pressed);
|
||||
public native void setControllerAxisState(int index, int axisCode, float value);
|
||||
public static native int getControllerButtonCode(String controllerType, String buttonName);
|
||||
public static native int getControllerAxisCode(String controllerType, String axisName);
|
||||
|
||||
public native void refreshGameList(boolean invalidateCache, boolean invalidateDatabase);
|
||||
public native GameListEntry[] getGameListEntries();
|
||||
|
|
|
@ -38,6 +38,9 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
|
|||
editor.putBoolean(key, value);
|
||||
editor.apply();
|
||||
}
|
||||
private String getStringSetting(String key, String defaultValue) {
|
||||
return mPreferences.getString(key, defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Touchscreen controller overlay
|
||||
|
@ -154,15 +157,17 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
|
|||
}
|
||||
});
|
||||
|
||||
// Hook up controller input.
|
||||
final String controllerType = getStringSetting("Controller1/Type", "DigitalController");
|
||||
Log.i("EmulationActivity", "Controller type: " + controllerType);
|
||||
mContentView.initControllerKeyMapping(controllerType);
|
||||
|
||||
// Create touchscreen controller.
|
||||
FrameLayout activityLayout = findViewById(R.id.frameLayout);
|
||||
mTouchscreenController = new TouchscreenControllerView(this);
|
||||
activityLayout.addView(mTouchscreenController);
|
||||
mTouchscreenController.init(0, "DigitalController", AndroidHostInterface.getInstance());
|
||||
setTouchscreenControllerVisibility(true);
|
||||
|
||||
// Hook up controller input.
|
||||
mContentView.initControllerKeyMapping("DigitalController");
|
||||
mTouchscreenController.init(0, controllerType);
|
||||
setTouchscreenControllerVisibility(getBooleanSetting("Controller1/EnableTouchscreenController", true));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -181,6 +186,7 @@ public class EmulationActivity extends AppCompatActivity implements SurfaceHolde
|
|||
getMenuInflater().inflate(R.menu.menu_emulation, menu);
|
||||
menu.findItem(R.id.show_controller).setChecked(mTouchscreenControllerVisible);
|
||||
menu.findItem(R.id.enable_speed_limiter).setChecked(getBooleanSetting("Main/SpeedLimiterEnabled", true));
|
||||
menu.findItem(R.id.show_controller).setChecked(getBooleanSetting("Controller1/EnableTouchscreenController", true));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,8 +4,10 @@ import android.content.Context;
|
|||
import android.util.ArrayMap;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.Log;
|
||||
import android.util.Pair;
|
||||
import android.view.InputDevice;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.SurfaceView;
|
||||
|
||||
public class EmulationSurfaceView extends SurfaceView {
|
||||
|
@ -48,7 +50,49 @@ public class EmulationSurfaceView extends SurfaceView {
|
|||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onGenericMotionEvent(MotionEvent event) {
|
||||
final int source = event.getSource();
|
||||
if ((source & InputDevice.SOURCE_JOYSTICK) == 0)
|
||||
return super.onGenericMotionEvent(event);
|
||||
|
||||
final InputDevice device = event.getDevice();
|
||||
for (int axis : AXISES) {
|
||||
Integer mapping = mControllerAxisMapping.containsKey(axis) ? mControllerAxisMapping.get(axis) : null;
|
||||
Pair<Integer, Integer> buttonMapping = mControllerAxisButtonMapping.containsKey(axis) ? mControllerAxisButtonMapping.get(axis) : null;
|
||||
if (mapping == null && buttonMapping == null)
|
||||
continue;
|
||||
|
||||
final float axisValue = event.getAxisValue(axis);
|
||||
float emuValue;
|
||||
|
||||
final InputDevice.MotionRange range = device.getMotionRange(axis, source);
|
||||
if (range != null) {
|
||||
final float transformedValue = (axisValue - range.getMin()) / range.getRange();
|
||||
emuValue = (transformedValue * 2.0f) - 1.0f;
|
||||
} else {
|
||||
emuValue = axisValue;
|
||||
}
|
||||
Log.d("EmulationSurfaceView", String.format("axis %d value %f emuvalue %f", axis, axisValue, emuValue));
|
||||
if (mapping != null) {
|
||||
AndroidHostInterface.getInstance().setControllerAxisState(0, mapping, emuValue);
|
||||
} else {
|
||||
final float DEAD_ZONE = 0.25f;
|
||||
AndroidHostInterface.getInstance().setControllerButtonState(0, buttonMapping.first, (emuValue <= -DEAD_ZONE));
|
||||
AndroidHostInterface.getInstance().setControllerButtonState(0, buttonMapping.second, (emuValue >= DEAD_ZONE));
|
||||
Log.d("EmulationSurfaceView", String.format("using emuValue %f for buttons %d %d", emuValue, buttonMapping.first, buttonMapping.second));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private ArrayMap<Integer, Integer> mControllerKeyMapping;
|
||||
private ArrayMap<Integer, Integer> mControllerAxisMapping;
|
||||
private ArrayMap<Integer, Pair<Integer, Integer>> mControllerAxisButtonMapping;
|
||||
static final int[] AXISES = new int[]{MotionEvent.AXIS_X, MotionEvent.AXIS_Y, MotionEvent.AXIS_RX,
|
||||
MotionEvent.AXIS_RY, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ,
|
||||
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y};
|
||||
|
||||
private void addControllerKeyMapping(int keyCode, String controllerType, String buttonName) {
|
||||
int mapping = AndroidHostInterface.getControllerButtonCode(controllerType, buttonName);
|
||||
|
@ -58,8 +102,31 @@ public class EmulationSurfaceView extends SurfaceView {
|
|||
mControllerKeyMapping.put(keyCode, mapping);
|
||||
}
|
||||
|
||||
private void addControllerAxisMapping(int axis, String controllerType, String axisName, String negativeButtonName, String positiveButtonName) {
|
||||
if (axisName != null) {
|
||||
int mapping = AndroidHostInterface.getControllerAxisCode(controllerType, axisName);
|
||||
Log.i("EmulationSurfaceView", String.format("Map axis %d to %d (%s)", axis, mapping, axisName));
|
||||
if (mapping >= 0) {
|
||||
mControllerAxisMapping.put(axis, mapping);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (negativeButtonName != null && positiveButtonName != null) {
|
||||
final int negativeMapping = AndroidHostInterface.getControllerButtonCode(controllerType, negativeButtonName);
|
||||
final int positiveMapping = AndroidHostInterface.getControllerButtonCode(controllerType, positiveButtonName);
|
||||
Log.i("EmulationSurfaceView", String.format("Map axis %d to %d %d (button %s %s)", axis, negativeMapping, positiveMapping,
|
||||
negativeButtonName, positiveButtonName));
|
||||
if (negativeMapping >= 0 && positiveMapping >= 0) {
|
||||
mControllerAxisButtonMapping.put(axis, new Pair<Integer, Integer>(negativeMapping, positiveMapping));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void initControllerKeyMapping(String controllerType) {
|
||||
mControllerKeyMapping = new ArrayMap<>();
|
||||
mControllerAxisMapping = new ArrayMap<>();
|
||||
mControllerAxisButtonMapping = new ArrayMap<>();
|
||||
|
||||
// TODO: Don't hardcode...
|
||||
addControllerKeyMapping(KeyEvent.KEYCODE_DPAD_UP, controllerType, "Up");
|
||||
|
@ -76,6 +143,14 @@ public class EmulationSurfaceView extends SurfaceView {
|
|||
addControllerKeyMapping(KeyEvent.KEYCODE_BUTTON_X, controllerType, "Square");
|
||||
addControllerKeyMapping(KeyEvent.KEYCODE_BUTTON_R1, controllerType, "R1");
|
||||
addControllerKeyMapping(KeyEvent.KEYCODE_BUTTON_R2, controllerType, "R2");
|
||||
addControllerAxisMapping(MotionEvent.AXIS_X, controllerType, "LeftX", null, null);
|
||||
addControllerAxisMapping(MotionEvent.AXIS_Y, controllerType, "LeftY", null, null);
|
||||
addControllerAxisMapping(MotionEvent.AXIS_RX, controllerType, "RightX", null, null);
|
||||
addControllerAxisMapping(MotionEvent.AXIS_RY, controllerType, "RightY", null, null);
|
||||
addControllerAxisMapping(MotionEvent.AXIS_Z, controllerType, "L2", "L2", "L2");
|
||||
addControllerAxisMapping(MotionEvent.AXIS_RZ, controllerType, "R2", "R2", "R2");
|
||||
addControllerAxisMapping(MotionEvent.AXIS_HAT_X, controllerType, null, "Left", "Right");
|
||||
addControllerAxisMapping(MotionEvent.AXIS_HAT_Y, controllerType, null, "Up", "Down");
|
||||
}
|
||||
|
||||
private boolean handleControllerKey(int keyCode, boolean pressed) {
|
||||
|
@ -84,6 +159,7 @@ public class EmulationSurfaceView extends SurfaceView {
|
|||
|
||||
final int mapping = mControllerKeyMapping.get(keyCode);
|
||||
AndroidHostInterface.getInstance().setControllerButtonState(0, mapping, pressed);
|
||||
Log.d("EmulationSurfaceView", String.format("handleControllerKey %d -> %d %d", keyCode, mapping, pressed ? 1 : 0));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,6 @@ import android.widget.FrameLayout;
|
|||
public class TouchscreenControllerView extends FrameLayout implements TouchscreenControllerButtonView.ButtonStateChangedListener {
|
||||
private int mControllerIndex;
|
||||
private String mControllerType;
|
||||
private AndroidHostInterface mHostInterface;
|
||||
|
||||
public TouchscreenControllerView(Context context) {
|
||||
super(context);
|
||||
|
@ -27,14 +26,9 @@ public class TouchscreenControllerView extends FrameLayout implements Touchscree
|
|||
super(context, attrs, defStyle);
|
||||
}
|
||||
|
||||
public void init(int controllerIndex, String controllerType,
|
||||
AndroidHostInterface hostInterface) {
|
||||
public void init(int controllerIndex, String controllerType) {
|
||||
mControllerIndex = controllerIndex;
|
||||
mControllerType = controllerType;
|
||||
mHostInterface = hostInterface;
|
||||
|
||||
if (mHostInterface != null)
|
||||
mHostInterface.setControllerType(controllerIndex, controllerType);
|
||||
|
||||
LayoutInflater inflater = LayoutInflater.from(getContext());
|
||||
View view = inflater.inflate(R.layout.layout_touchscreen_controller, this, true);
|
||||
|
@ -62,25 +56,22 @@ public class TouchscreenControllerView extends FrameLayout implements Touchscree
|
|||
buttonView.setButtonName(buttonName);
|
||||
buttonView.setButtonStateChangedListener(this);
|
||||
|
||||
if (mHostInterface != null)
|
||||
{
|
||||
int code = mHostInterface.getControllerButtonCode(mControllerType, buttonName);
|
||||
buttonView.setButtonCode(code);
|
||||
Log.i("TouchscreenController", String.format("%s -> %d", buttonName, code));
|
||||
int code = AndroidHostInterface.getInstance().getControllerButtonCode(mControllerType, buttonName);
|
||||
buttonView.setButtonCode(code);
|
||||
Log.i("TouchscreenController", String.format("%s -> %d", buttonName, code));
|
||||
|
||||
if (code < 0) {
|
||||
Log.e("TouchscreenController", String.format("Unknown button name '%s' " +
|
||||
"for '%s'", buttonName, mControllerType));
|
||||
}
|
||||
if (code < 0) {
|
||||
Log.e("TouchscreenController", String.format("Unknown button name '%s' " +
|
||||
"for '%s'", buttonName, mControllerType));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onButtonStateChanged(TouchscreenControllerButtonView view, boolean pressed) {
|
||||
if (mHostInterface == null || view.getButtonCode() < 0)
|
||||
if (view.getButtonCode() < 0)
|
||||
return;
|
||||
|
||||
mHostInterface.setControllerButtonState(mControllerIndex, view.getButtonCode(), pressed);
|
||||
AndroidHostInterface.getInstance().setControllerButtonState(mControllerIndex, view.getButtonCode(), pressed);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -67,4 +67,12 @@
|
|||
<item>15</item>
|
||||
<item>16</item>
|
||||
</string-array>
|
||||
<string-array name="settings_controller_type_entries">
|
||||
<item>Digital Controller (Gamepad)</item>
|
||||
<item>Analog Controller (DualShock)</item>
|
||||
</string-array>
|
||||
<string-array name="settings_controller_type_values">
|
||||
<item>DigitalController</item>
|
||||
<item>AnalogController</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
|
|
@ -41,11 +41,6 @@
|
|||
app:defaultValue="@string/settings_console_region_default"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<EditTextPreference
|
||||
app:key="BIOS/Path"
|
||||
app:title="@string/settings_console_bios_path"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<SwitchPreferenceCompat
|
||||
app:key="BIOS/PatchTTYEnable"
|
||||
app:title="@string/settings_console_tty_output"
|
||||
|
@ -144,4 +139,22 @@
|
|||
|
||||
</PreferenceCategory>
|
||||
|
||||
<PreferenceCategory app:title="Controller">
|
||||
<ListPreference
|
||||
app:key="Controller1/Type"
|
||||
app:title="Controller Type"
|
||||
app:entries="@array/settings_controller_type_entries"
|
||||
app:entryValues="@array/settings_controller_type_values"
|
||||
app:defaultValue="DigitalController"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
<SwitchPreferenceCompat
|
||||
app:key="Controller1/AutoEnableAnalog"
|
||||
app:title="Enable Analog Mode On Reset"
|
||||
app:defaultValue="false" />
|
||||
<SwitchPreferenceCompat
|
||||
app:key="Controller1/EnableTouchscreenController"
|
||||
app:title="Display Touchscreen Controller"
|
||||
app:defaultValue="true" />
|
||||
</PreferenceCategory>
|
||||
|
||||
</PreferenceScreen>
|
||||
|
|
Loading…
Reference in New Issue