Merge pull request #22511 from hrydgard/android-inputbox-fixes

Claude code review: Android inputbox fixes
This commit is contained in:
Henrik Rydgård authored and GitHub committed 2026-10-12 13:20:25 +02:00
commit 8d8009840e
11 files changed
+247 -90

No files matched your search

+48 -14
View File
@@ -83,6 +83,7 @@ struct JNIEnv {};
#include "Common/Input/KeyCodes.h"
#include "Common/Profiler/Profiler.h"
#include "Common/Math/math_util.h"
#include "Common/Data/Encoding/Utf8.h"
#include "Common/Data/Text/Parsers.h"
#include "Common/VR/PPSSPPVR.h"
#include "Common/GPU/Vulkan/VulkanLoader.h"
@@ -177,8 +178,15 @@ static std::atomic<bool> renderer_inited{false};
static bool sustainedPerfSupported = false;
static std::string g_installerName;
// Written on the Java UI thread, read on the render thread.
static std::mutex permissionsLock;
static std::map<SystemPermission, PermissionStatus> permissions;
static void SetPermissionStatus(SystemPermission permission, PermissionStatus status) {
std::lock_guard<std::mutex> guard(permissionsLock);
permissions[permission] = status;
}
static GraphicsContext *graphicsContext;
#define MessageBox(a, b, c, d) __android_log_print(ANDROID_LOG_INFO, APP_NAME, "%s %s", (b), (c));
@@ -520,12 +528,30 @@ std::vector<std::string> Android_GetNativeCrashHistory(int maxEntries) {
return crashHistory;
}
// GetStringUTFChars returns Java's modified UTF-8, where characters outside the BMP (emoji) become two 3-byte
// surrogates, so we go through UTF-16.
std::string GetJavaString(JNIEnv *env, jstring jstr) {
if (!jstr)
return "";
const char *str = env->GetStringUTFChars(jstr, nullptr);
std::string cpp_string = std::string(str);
env->ReleaseStringUTFChars(jstr, str);
const jsize len = env->GetStringLength(jstr);
const jchar *chars = env->GetStringChars(jstr, nullptr);
if (!chars)
return "";
std::string cpp_string;
cpp_string.reserve(len);
char buf[4];
for (jsize i = 0; i < len; i++) {
uint32_t c = chars[i];
if (c >= 0xD800 && c < 0xDC00 && i + 1 < len && chars[i + 1] >= 0xDC00 && chars[i + 1] < 0xE000) {
c = 0x10000 + ((c - 0xD800) << 10) + (chars[i + 1] - 0xDC00);
i++;
} else if (c >= 0xD800 && c < 0xE000) {
// Unpaired surrogate.
c = 0xFFFD;
}
cpp_string.append(buf, UTF8::encode(buf, c));
}
env->ReleaseStringChars(jstr, chars);
return cpp_string;
}
@@ -966,7 +992,8 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string
return true;
case SystemRequestType::INPUT_TEXT_MODAL:
{
std::string serialized = StringFromFormat("%d:@:%s:@:%s", requestId, param1.c_str(), param2.c_str());
// The flags go before the strings, so the free text comes last.
std::string serialized = StringFromFormat("%d:@:%d:@:%s:@:%s", requestId, param3 ? 1 : 0, param1.c_str(), param2.c_str());
PushCommand("inputbox", serialized);
return true;
}
@@ -1038,7 +1065,8 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string
extern "C" void JNICALL Java_org_ppsspp_ppsspp_NativeApp_sendRequestResult(JNIEnv *env, jclass, jint jrequestID, jboolean result, jstring jvalue, jint jintValue) {
std::string value = jvalue ? GetJavaString(env, jvalue) : "(no value)";
INFO_LOG(Log::System, "Received result of request %d from Java: %d: %d '%s'", jrequestID, (int)result, jintValue, value.c_str());
// Don't log the value, it can be a password.
INFO_LOG(Log::System, "Received result of request %d from Java: %d: %d (%d chars)", jrequestID, (int)result, jintValue, (int)value.size());
if (result) {
g_requestManager.PostSystemSuccess(jrequestID, value, jintValue);
} else {
@@ -1061,7 +1089,9 @@ PermissionStatus System_GetPermissionStatus(SystemPermission permission) {
if (androidVersion < 23) {
return PERMISSION_STATUS_GRANTED;
} else {
return permissions[permission];
std::lock_guard<std::mutex> guard(permissionsLock);
auto iter = permissions.find(permission);
return iter != permissions.end() ? iter->second : PERMISSION_STATUS_UNKNOWN;
}
}
@@ -1260,28 +1290,28 @@ extern "C" void JNICALL Java_org_ppsspp_ppsspp_NativeApp_sendMessageFromJava(JNI
} else if (msg == "permission_pending") {
if (prm == "storage") {
INFO_LOG(Log::System, "STORAGE PERMISSION: PENDING");
permissions[SYSTEM_PERMISSION_STORAGE] = PERMISSION_STATUS_PENDING;
SetPermissionStatus(SYSTEM_PERMISSION_STORAGE, PERMISSION_STATUS_PENDING);
} else if (prm == "local_network") {
INFO_LOG(Log::System, "LOCAL NETWORK PERMISSION: PENDING");
permissions[SYSTEM_PERMISSION_LOCAL_NETWORK] = PERMISSION_STATUS_PENDING;
SetPermissionStatus(SYSTEM_PERMISSION_LOCAL_NETWORK, PERMISSION_STATUS_PENDING);
}
} else if (msg == "permission_denied") {
if (prm == "storage") {
INFO_LOG(Log::System, "STORAGE PERMISSION: DENIED");
permissions[SYSTEM_PERMISSION_STORAGE] = PERMISSION_STATUS_DENIED;
SetPermissionStatus(SYSTEM_PERMISSION_STORAGE, PERMISSION_STATUS_DENIED);
} else if (prm == "local_network") {
INFO_LOG(Log::System, "LOCAL NETWORK PERMISSION: DENIED");
permissions[SYSTEM_PERMISSION_LOCAL_NETWORK] = PERMISSION_STATUS_DENIED;
SetPermissionStatus(SYSTEM_PERMISSION_LOCAL_NETWORK, PERMISSION_STATUS_DENIED);
} else {
WARN_LOG(Log::System, "UNKNOWN PERMISSION GRANTED: %s", prm.c_str());
}
} else if (msg == "permission_granted") {
if (prm == "storage") {
INFO_LOG(Log::System, "STORAGE PERMISSION: GRANTED");
permissions[SYSTEM_PERMISSION_STORAGE] = PERMISSION_STATUS_GRANTED;
SetPermissionStatus(SYSTEM_PERMISSION_STORAGE, PERMISSION_STATUS_GRANTED);
} else if (prm == "local_network") {
INFO_LOG(Log::System, "LOCAL NETWORK PERMISSION: GRANTED");
permissions[SYSTEM_PERMISSION_LOCAL_NETWORK] = PERMISSION_STATUS_GRANTED;
SetPermissionStatus(SYSTEM_PERMISSION_LOCAL_NETWORK, PERMISSION_STATUS_GRANTED);
} else {
WARN_LOG(Log::System, "UNKNOWN PERMISSION GRANTED: %s", prm.c_str());
}
@@ -1515,10 +1545,14 @@ static void ProcessFrameCommands() {
if (!frameCommands.empty()) {
INFO_LOG(Log::System, "Processing %zu frame commands", frameCommands.size());
for (const FrameCommand &frameCmd : frameCommands) {
DEBUG_LOG(Log::System, "frameCommand '%s' '%s'", frameCmd.command.c_str(), frameCmd.params.c_str());
// Not logging the params, an inputbox one can contain a password.
DEBUG_LOG(Log::System, "frameCommand '%s'", frameCmd.command.c_str());
// The params can contain any text, like a title the game gave its OSK.
std::string params;
ConvertUTF8ToJavaModifiedUTF8(&params, frameCmd.params);
jstring cmd = env->NewStringUTF(frameCmd.command.c_str());
jstring param = env->NewStringUTF(frameCmd.params.c_str());
jstring param = env->NewStringUTF(params.c_str());
env->CallVoidMethod(activity, postCommand, cmd, param);
env->DeleteLocalRef(cmd);
env->DeleteLocalRef(param);
@@ -23,6 +23,11 @@ public class ImageResultProxyActivity extends AppCompatActivity {
private String copyAndDownscaleToCache(Uri selectedImage) {
Log.i(TAG, "Selected image: " + selectedImage);
if (selectedImage == null) {
// Some pickers only return a ClipData.
Log.e(TAG, "No image URI in the result");
return null;
}
File cacheDir = getExternalCacheDir();
if (cacheDir == null) {
Log.e(TAG, "External cache directory not available");
@@ -104,7 +109,9 @@ public class ImageResultProxyActivity extends AppCompatActivity {
String returnPath = tempFile.getAbsolutePath();
Log.i(TAG, "Return path: " + returnPath);
return returnPath;
} catch (IOException e) {
} catch (IOException | RuntimeException | OutOfMemoryError e) {
// We're in the app's process, so anything uncaught here (SecurityException from a provider, a huge bitmap)
// takes the whole app down.
Log.e(TAG, "Error processing image: " + e.getMessage());
e.printStackTrace();
return null;
@@ -125,8 +125,8 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
private final ArrayList<InputDeviceState> inputPlayers = new ArrayList<>();
private PowerSaveModeReceiver mPowerSaveModeReceiver = null;
// The open input box, if any.
private AlertDialog inputDialog = null;
// The open input boxes. There can be two, if a game's OSK pops up over a UI one.
private final ArrayList<AlertDialog> inputDialogs = new ArrayList<>();
private NativeSurfaceManager surfaceManager = null;
private static LocationHelper mLocationHelper;
private static InfraredHelper mInfraredHelper;
@@ -999,10 +999,10 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
mPowerSaveModeReceiver = null;
}
// Otherwise its window leaks, and its request is never answered. Dismissing answers it as cancelled.
if (inputDialog != null) {
inputDialog.dismiss();
inputDialog = null;
for (AlertDialog dlg : new ArrayList<>(inputDialogs)) {
dlg.dismiss();
}
inputDialogs.clear();
// After finish(), Android is free to deliver onDestroy late, after it has already created and resumed a
// new activity if the user was quick to relaunch. That one found initialized still set and adopted the
@@ -1480,7 +1480,7 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
}
// The return value is sent to C++ via requestID.
public void inputBox(final int requestId, final String title, String defaultText, String defaultAction) {
public void inputBox(final int requestId, final String title, String defaultText, boolean passwordMasking, String defaultAction) {
final FrameLayout fl = new FrameLayout(this);
final EditText input = new EditText(this);
input.setGravity(Gravity.CENTER);
@@ -1489,7 +1489,11 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
editBoxLayout.setMargins(2, 20, 2, 20);
fl.addView(input, editBoxLayout);
input.setInputType(InputType.TYPE_CLASS_TEXT);
if (passwordMasking) {
input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
} else {
input.setInputType(InputType.TYPE_CLASS_TEXT);
}
input.setImeOptions(EditorInfo.IME_ACTION_DONE);
input.setText(defaultText);
input.setFocusableInTouchMode(true);
@@ -1497,22 +1501,25 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
input.selectAll();
//input.setSelection(input.getText().length());
// Answered exactly once: OK and Done are followed by a dismiss.
final boolean[] answered = { false };
AlertDialog.Builder builder = createDialogBuilder()
.setView(fl)
.setTitle(title)
.setPositiveButton(defaultAction, (d, which) -> {
Log.i(TAG, "input box successful");
answered[0] = true;
NativeApp.sendRequestResult(requestId, true, input.getText().toString(), 0);
// Dismiss happens automatically.
})
.setNegativeButton("Cancel", (d, which) -> d.cancel());
builder.setOnDismissListener( d -> {
Log.i(TAG, "input box dismissed");
// This will be ignored if we already sent a success.
NativeApp.sendRequestResult(requestId, false, "", 0);
if (inputDialog == d) {
inputDialog = null;
if (!answered[0]) {
answered[0] = true;
NativeApp.sendRequestResult(requestId, false, "", 0);
}
inputDialogs.remove(d);
// Also comes after onDestroy dismissed it.
if (!isDestroyed()) {
updateSystemUiVisibility();
@@ -1523,7 +1530,10 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
input.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE) {
Log.i(TAG, "input box successful via Keyboard Done");
NativeApp.sendRequestResult(requestId, true, input.getText().toString(), 0);
if (!answered[0]) {
answered[0] = true;
NativeApp.sendRequestResult(requestId, true, input.getText().toString(), 0);
}
// We must dismiss the dialog manually here
dlg.dismiss();
@@ -1538,10 +1548,15 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
}
try {
dlg.show();
inputDialog = dlg;
inputDialogs.add(dlg);
input.requestFocus();
} catch (Exception e) {
NativeApp.reportException(e, "AlertDialog");
// The dismiss listener won't run, and the native side (maybe a game's OSK) waits for an answer.
if (!answered[0]) {
answered[0] = true;
NativeApp.sendRequestResult(requestId, false, "", 0);
}
}
}
@@ -1694,12 +1709,13 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
} else if (command.equals("inputbox")) {
String title = "Input";
String defString = "";
String[] param = params.split(":@:", 3);
String[] param = params.split(":@:", 4);
int requestID = Integer.parseInt(param[0]);
if (param.length > 1 && !param[1].isEmpty())
title = param[1];
if (param.length > 2)
defString = param[2];
boolean passwordMasking = param.length > 1 && param[1].equals("1");
if (param.length > 2 && !param[2].isEmpty())
title = param[2];
if (param.length > 3)
defString = param[3];
// Workaround for issue #13363 to fix Split/Second game start - it requires text input
// but we don't support it on VR devices.
if (isVRDevice()) {
@@ -1707,8 +1723,9 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
NativeApp.sendRequestResult(requestID, false, defString, 0);
return true;
}
Log.i(TAG, "Launching inputbox: #" + requestID + " " + title + " " + defString);
inputBox(requestID, title, defString, "OK");
// Don't log defString, it can be a password.
Log.i(TAG, "Launching inputbox: #" + requestID + " " + title);
inputBox(requestID, title, defString, passwordMasking, "OK");
return true;
} else if (command.equals("vibrate")) {
int milliseconds = -1;
@@ -2009,6 +2026,19 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
}
}
// For commands that carry a native request ID (always first in the parameter), answers it as failed, since the
// native side waits for an answer.
private static void failRequestCommand(String command, String parameter) {
if (!command.equals("inputbox") && !command.equals("browse_image") && !command.startsWith("browse_file") && !command.equals("browse_folder")) {
return;
}
try {
NativeApp.sendRequestResult(Integer.parseInt(parameter.split(":@:", 2)[0]), false, "", 0);
} catch (NumberFormatException e) {
Log.e(TAG, "Bad request ID for command '" + command + "'");
}
}
// called by the C++ code through JNI. Dispatch anything we can't directly handle
// on the gfx thread to the UI thread.
@Keep
@@ -2018,18 +2048,13 @@ public class PpssppActivity extends AppCompatActivity implements SensorEventList
// Posted before this activity was destroyed, but runs after.
if (isDestroyed()) {
Log.w(TAG, "Dropping command '" + command + "' for a destroyed activity");
if (command.equals("inputbox")) {
// The native side waits for an answer.
try {
NativeApp.sendRequestResult(Integer.parseInt(parameter.split(":@:", 2)[0]), false, "", 0);
} catch (NumberFormatException e) {
Log.e(TAG, "Bad inputbox parameter: " + parameter);
}
}
failRequestCommand(command, parameter);
return;
}
if (!processCommand(command, parameter)) {
Log.e(TAG, "processCommand failed: cmd: '" + command + "' param: '" + parameter + "'");
// Not logging the parameter, an inputbox one can contain a password.
Log.e(TAG, "processCommand failed: cmd: '" + command + "'");
failRequestCommand(command, parameter);
}
});
}