better SAF - texture pack support etc.

This commit is contained in:
izzy2lost
2025-09-07 03:45:34 -04:00
parent e1161027f1
commit 56c53405f6
15 changed files with 797 additions and 111 deletions
+176 -27
View File
@@ -95,6 +95,17 @@ static inline bool FileSystemCharacterIsSane(char32_t c, bool strip_slashes)
return true;
}
// Forward declaration from native-lib.cpp (Android JNI helper)
#ifdef __ANDROID__
// JNI helpers implemented in native-lib.cpp
// Resolve a file path under the user-selected SAF data root, e.g. "textures/SLUS-12345/replacements/foo/bar.png"
std::string ResolveSafPathUriJNI(const char* relative_path, bool create);
// List files under a SAF-relative directory non-recursively: returns full relative paths (e.g., "textures/.../file.png").
std::vector<std::string> SafListFilesFlatJNI(const char* relative_dir);
// List files under a SAF-relative directory recursively: returns full relative paths.
std::vector<std::string> SafListRecursiveFilesJNI(const char* relative_dir);
#endif
template <typename T>
static inline void PathAppendString(std::string& dst, const T& src)
{
@@ -970,6 +981,29 @@ std::string Path::CreateFileURL(std::string_view path)
return ret;
}
static bool IsWriteMode(const char* mode)
{
if (!mode) return false;
while (*mode)
{
if (*mode == 'w' || *mode == 'a' || *mode == '+') return true;
mode++;
}
return false;
}
static bool ParseSafPath(const std::string& path, std::string* out_subdir, std::string* out_filename)
{
if (path.rfind("saf://", 0) != 0) return false;
std::string rest = path.substr(6);
// Expect format: subdir/filename
size_t slash = rest.find('/');
if (slash == std::string::npos) return false;
*out_subdir = rest.substr(0, slash);
*out_filename = rest.substr(slash + 1);
return !out_subdir->empty() && !out_filename->empty();
}
std::FILE* FileSystem::OpenCFile(const char* filename, const char* mode, Error* error)
{
#ifdef _WIN32
@@ -1003,8 +1037,26 @@ std::FILE* FileSystem::OpenCFile(const char* filename, const char* mode, Error*
////
std::string _filename(filename);
if (_filename.rfind("content://", 0) == 0) {
fp = fdopen(FileSystem::OpenFDFileContent(_filename.c_str()), "rb");
} else {
// Direct document uri
const char* cmode = IsWriteMode(mode) ? "rw" : "r";
int fd = FileSystem::OpenFDFileContentWithMode(_filename.c_str(), cmode);
fp = (fd >= 0) ? fdopen(fd, mode) : nullptr;
}
#ifdef __ANDROID__
else if (_filename.rfind("saf://", 0) == 0) {
const bool create = IsWriteMode(mode);
std::string rel = _filename.substr(6);
std::string doc = ResolveSafPathUriJNI(rel.c_str(), create);
if (!doc.empty()) {
const char* cmode = IsWriteMode(mode) ? "rw" : "r";
int fd = FileSystem::OpenFDFileContentWithMode(doc.c_str(), cmode);
fp = (fd >= 0) ? fdopen(fd, mode) : nullptr;
} else {
fp = nullptr;
}
}
#endif
else {
fp = std::fopen(_filename.c_str(), mode);
}
////
@@ -1017,18 +1069,33 @@ std::FILE* FileSystem::OpenCFile(const char* filename, const char* mode, Error*
std::FILE* FileSystem::OpenCFileTryIgnoreCase(const char* filename, const char* mode, Error* error)
{
#if defined(_WIN32) || defined(__APPLE__)
return OpenCFile(filename, mode, error);
return OpenCFile(filename, mode, error);
#else
std::FILE* fp;
////
std::FILE* fp = nullptr;
std::string _filename(filename);
if (_filename.rfind("content://", 0) == 0) {
fp = fdopen(FileSystem::OpenFDFileContent(_filename.c_str()), "rb");
} else {
const char* cmode = IsWriteMode(mode) ? "rw" : "r";
int fd = FileSystem::OpenFDFileContentWithMode(_filename.c_str(), cmode);
if (fd >= 0)
fp = fdopen(fd, mode);
}
#ifdef __ANDROID__
else if (_filename.rfind("saf://", 0) == 0) {
const bool create = IsWriteMode(mode);
std::string rel = _filename.substr(6);
std::string doc = ResolveSafPathUriJNI(rel.c_str(), create);
if (!doc.empty()) {
const char* cmode = IsWriteMode(mode) ? "rw" : "r";
int fd = FileSystem::OpenFDFileContentWithMode(doc.c_str(), cmode);
if (fd >= 0)
fp = fdopen(fd, mode);
}
}
#endif
else {
fp = std::fopen(_filename.c_str(), mode);
}
////
const auto cur_errno = errno;
const auto cur_errno = errno;
if (!fp)
{
@@ -1042,8 +1109,25 @@ std::FILE* FileSystem::OpenCFileTryIgnoreCase(const char* filename, const char*
{
////
if (file.FileName.rfind("content://", 0) == 0) {
fp = fdopen(FileSystem::OpenFDFileContent(file.FileName.c_str()), "rb");
} else {
const char* cmode2 = IsWriteMode(mode) ? "rw" : "r";
int fd2 = FileSystem::OpenFDFileContentWithMode(file.FileName.c_str(), cmode2);
if (fd2 >= 0)
fp = fdopen(fd2, mode);
}
#ifdef __ANDROID__
else if (file.FileName.rfind("saf://", 0) == 0) {
const bool create2 = IsWriteMode(mode);
std::string rel2 = file.FileName.substr(6);
std::string doc2 = ResolveSafPathUriJNI(rel2.c_str(), create2);
if (!doc2.empty()) {
const char* cmode2 = IsWriteMode(mode) ? "rw" : "r";
int fd2 = FileSystem::OpenFDFileContentWithMode(doc2.c_str(), cmode2);
if (fd2 >= 0)
fp = fdopen(fd2, mode);
}
}
#endif
else {
fp = std::fopen(file.FileName.c_str(), mode);
}
////
@@ -1073,8 +1157,23 @@ int FileSystem::OpenFDFile(const char* filename, int flags, int mode, Error* err
////
std::string _filename(filename);
if (_filename.rfind("content://", 0) == 0) {
fd = FileSystem::OpenFDFileContent(_filename.c_str());
} else {
const char* cmode = (flags & O_WRONLY) || (flags & O_RDWR) ? "rw" : "r";
fd = FileSystem::OpenFDFileContentWithMode(_filename.c_str(), cmode);
}
#ifdef __ANDROID__
else if (_filename.rfind("saf://", 0) == 0) {
const bool create = (flags & O_WRONLY) || (flags & O_RDWR) || (flags & O_CREAT);
std::string rel = _filename.substr(6);
std::string doc = ResolveSafPathUriJNI(rel.c_str(), create);
if (!doc.empty()) {
const char* cmode = (flags & O_WRONLY) || (flags & O_RDWR) ? "rw" : "r";
fd = FileSystem::OpenFDFileContentWithMode(doc.c_str(), cmode);
} else {
fd = -1;
}
}
#endif
else {
fd = open(_filename.c_str(), flags, mode);
}
////
@@ -1128,18 +1227,33 @@ std::FILE* FileSystem::OpenSharedCFile(const char* filename, const char* mode, F
return nullptr;
#else
std::FILE* fp;
////
std::FILE* fp = nullptr;
std::string _filename(filename);
if (_filename.rfind("content://", 0) == 0) {
fp = fdopen(FileSystem::OpenFDFileContent(_filename.c_str()), "rb");
} else {
const char* cmode = IsWriteMode(mode) ? "rw" : "r";
int fd = FileSystem::OpenFDFileContentWithMode(_filename.c_str(), cmode);
if (fd >= 0)
fp = fdopen(fd, mode);
}
#ifdef __ANDROID__
else if (_filename.rfind("saf://", 0) == 0) {
const bool create = IsWriteMode(mode);
std::string rel = _filename.substr(6);
std::string doc = ResolveSafPathUriJNI(rel.c_str(), create);
if (!doc.empty()) {
const char* cmode = IsWriteMode(mode) ? "rw" : "r";
int fd = FileSystem::OpenFDFileContentWithMode(doc.c_str(), cmode);
if (fd >= 0)
fp = fdopen(fd, mode);
}
}
#endif
else {
fp = std::fopen(_filename.c_str(), mode);
}
////
if (!fp)
Error::SetErrno(error, errno);
return fp;
if (!fp)
Error::SetErrno(error, errno);
return fp;
#endif
}
@@ -1546,13 +1660,44 @@ static u32 RecursiveFindFiles(const char* origin_path, const char* parent_path,
bool FileSystem::FindFiles(const char* path, const char* pattern, u32 flags, FindResultsArray* results, ProgressCallback* cancel)
{
// has a path
if (path[0] == '\0')
return false;
// has a path
if (path[0] == '\0')
return false;
// clear result array
if (!(flags & FILESYSTEM_FIND_KEEP_ARRAY))
results->clear();
// clear result array
if (!(flags & FILESYSTEM_FIND_KEEP_ARRAY))
results->clear();
#ifdef __ANDROID__
// SAF listing: path of the form saf://<relative_path_under_data_root>
if (std::string(path).rfind("saf://", 0) == 0)
{
const std::string rel = std::string(path).substr(6);
std::vector<std::string> files;
if (flags & FILESYSTEM_FIND_RECURSIVE)
files = SafListRecursiveFilesJNI(rel.c_str());
else
files = SafListFilesFlatJNI(rel.c_str());
const bool wildcard = (std::strpbrk(pattern, "*?") != nullptr);
for (const std::string& relpath : files)
{
if (cancel && cancel->IsCancelled()) break;
// Compare only the filename component against the pattern
const std::string_view fname = Path::GetFileName(relpath);
bool match = wildcard ? StringUtil::WildcardMatch(std::string(fname).c_str(), pattern) : (std::strcmp(std::string(fname).c_str(), pattern) == 0);
if (!match) continue;
FILESYSTEM_FIND_DATA out{};
out.Attributes = 0; // files only for now
out.FileName = std::string("saf://") + relpath;
out.Size = -1;
out.CreationTime = 0;
out.ModificationTime = 0;
results->push_back(out);
}
return !results->empty();
}
#endif
// add self if recursive, we don't want to visit it twice
std::vector<std::string> visited;
@@ -2685,3 +2830,7 @@ FileSystem::POSIXLock::~POSIXLock()
}
#endif
+1
View File
@@ -121,6 +121,7 @@ namespace FileSystem
s64 FSize64(std::FILE* fp);
int OpenFDFileContent(const char* filename);
int OpenFDFileContentWithMode(const char* filename, const char* mode);
int OpenFDFile(const char* filename, int flags, int mode, Error* error = nullptr);
/// Sharing modes for OpenSharedCFile().
+2 -1
View File
@@ -10,6 +10,7 @@
#include "Console.h"
static inline std::unique_ptr<zip_t, void (*)(zip_t*)> zip_open_managed(const char* filename, int flags, zip_error_t* ze)
{
zip_source_t* zs = zip_source_file_create(filename, 0, 0, ze);
@@ -139,4 +140,4 @@ static inline std::optional<std::vector<u8>> ReadBinaryFileInZip(zip_t* zip, con
static inline std::optional<std::vector<u8>> ReadBinaryFileInZip(zip_file_t* file, u32 chunk_size = 4096)
{
return ReadFileInZipToContainer<std::vector<u8>>(file, chunk_size);
}
}
+188 -1
View File
@@ -27,6 +27,9 @@
#include "MTGS.h"
#include "SDL3/SDL.h"
#include <future>
#ifdef __ANDROID__
#include "SDL3/SDL.h"
#endif
bool s_execute_exit;
@@ -490,6 +493,18 @@ Java_com_izzy2lost_psx2_NativeApp_setAsyncTextureLoading(JNIEnv *env, jclass cla
}
}
extern "C"
JNIEXPORT void JNICALL
Java_com_izzy2lost_psx2_NativeApp_setPrecacheTextureReplacements(JNIEnv *env, jclass clazz,
jboolean p_enabled) {
s_settings_interface.SetBoolValue("EmuCore/GS", "PrecacheTextureReplacements", p_enabled);
// Apply the settings immediately if emulation is running
if (VMManager::HasValidVM()) {
VMManager::ApplySettings();
}
}
extern "C"
JNIEXPORT void JNICALL
Java_com_izzy2lost_psx2_NativeApp_setShadeBoost(JNIEnv *env, jclass clazz,
@@ -673,7 +688,7 @@ Java_com_izzy2lost_psx2_NativeApp_saveGameSettingsToPath(JNIEnv *env, jclass cla
// Check if file actually exists and has content
if (FileSystem::FileExists(settings_path.c_str())) {
s64 file_size = FileSystem::GetPathFileSize(settings_path.c_str());
printf("PCSX2: File exists with size: %lld bytes\n", file_size);
printf("PCSX2: File exists with size: %lld bytes\n", static_cast<long long>(file_size));
} else {
printf("PCSX2: File does not exist after save attempt\n");
}
@@ -983,6 +998,139 @@ int FileSystem::OpenFDFileContent(const char* filename)
return fd;
}
#ifdef __ANDROID__
// Helpers callable from core for SAF bridging
static jclass GetNativeAppClass(JNIEnv* env)
{
return env->FindClass("com/izzy2lost/psx2/NativeApp");
}
std::string ResolveSafPathUriJNI(const char* relative_path, bool create)
{
JNIEnv* env = reinterpret_cast<JNIEnv*>(SDL_GetAndroidJNIEnv());
if (!env)
return {};
jclass cls = GetNativeAppClass(env);
if (!cls)
return {};
jmethodID mid = env->GetStaticMethodID(cls, "resolveSafPathUri", "(Ljava/lang/String;Z)Ljava/lang/String;");
if (!mid)
return {};
jstring jrel = env->NewStringUTF(relative_path);
jobject juri = env->CallStaticObjectMethod(cls, mid, jrel, (jboolean)create);
env->DeleteLocalRef(jrel);
if (!juri)
return {};
const char* cstr = env->GetStringUTFChars((jstring)juri, nullptr);
std::string out = cstr ? std::string(cstr) : std::string();
if (cstr)
env->ReleaseStringUTFChars((jstring)juri, cstr);
env->DeleteLocalRef(juri);
return out;
}
std::vector<std::string> SafListRecursiveFilesJNI(const char* relative_dir)
{
std::vector<std::string> out;
JNIEnv* env = reinterpret_cast<JNIEnv*>(SDL_GetAndroidJNIEnv());
if (!env)
return out;
jclass cls = GetNativeAppClass(env);
if (!cls)
return out;
jmethodID mid = env->GetStaticMethodID(cls, "listSafRecursiveFiles", "(Ljava/lang/String;)[Ljava/lang/String;");
if (!mid)
return out;
jstring jarg = env->NewStringUTF(relative_dir);
jobjectArray arr = (jobjectArray)env->CallStaticObjectMethod(cls, mid, jarg);
env->DeleteLocalRef(jarg);
if (!arr)
return out;
jsize len = env->GetArrayLength(arr);
out.reserve((size_t)len);
for (jsize i = 0; i < len; i++)
{
jstring js = (jstring)env->GetObjectArrayElement(arr, i);
if (!js) continue;
const char* c = env->GetStringUTFChars(js, nullptr);
if (c)
{
out.emplace_back(c);
env->ReleaseStringUTFChars(js, c);
}
env->DeleteLocalRef(js);
}
env->DeleteLocalRef(arr);
return out;
}
std::vector<std::string> SafListFilesFlatJNI(const char* relative_dir)
{
std::vector<std::string> out;
JNIEnv* env = reinterpret_cast<JNIEnv*>(SDL_GetAndroidJNIEnv());
if (!env)
return out;
jclass cls = GetNativeAppClass(env);
if (!cls)
return out;
jmethodID mid = env->GetStaticMethodID(cls, "listSafFilesFlat", "(Ljava/lang/String;)[Ljava/lang/String;");
if (!mid)
return out;
jstring jarg = env->NewStringUTF(relative_dir);
jobjectArray arr = (jobjectArray)env->CallStaticObjectMethod(cls, mid, jarg);
env->DeleteLocalRef(jarg);
if (!arr)
return out;
jsize len = env->GetArrayLength(arr);
out.reserve((size_t)len);
for (jsize i = 0; i < len; i++)
{
jstring js = (jstring)env->GetObjectArrayElement(arr, i);
if (!js) continue;
const char* c = env->GetStringUTFChars(js, nullptr);
if (c)
{
out.emplace_back(c);
env->ReleaseStringUTFChars(js, c);
}
env->DeleteLocalRef(js);
}
env->DeleteLocalRef(arr);
return out;
}
#endif // __ANDROID__
int FileSystem::OpenFDFileContentWithMode(const char* filename, const char* mode)
{
auto *env = static_cast<JNIEnv *>(SDL_GetAndroidJNIEnv());
if(env == nullptr) {
return -1;
}
jclass NativeApp = env->FindClass("com/izzy2lost/psx2/NativeApp");
jmethodID openContentUriMode = env->GetStaticMethodID(NativeApp, "openContentUriMode", "(Ljava/lang/String;Ljava/lang/String;)I");
jstring j_filename = env->NewStringUTF(filename);
jstring j_mode = env->NewStringUTF(mode);
int fd = env->CallStaticIntMethod(NativeApp, openContentUriMode, j_filename, j_mode);
return fd;
}
std::string ResolveSafChildUriJNI(const char* subdir, const char* filename, bool create)
{
auto *env = static_cast<JNIEnv *>(SDL_GetAndroidJNIEnv());
if(env == nullptr) return {};
jclass NativeApp = env->FindClass("com/izzy2lost/psx2/NativeApp");
jmethodID resolve = env->GetStaticMethodID(NativeApp, "resolveSafChildUri", "(Ljava/lang/String;Ljava/lang/String;Z)Ljava/lang/String;");
jstring j_sub = env->NewStringUTF(subdir);
jstring j_file = env->NewStringUTF(filename);
jstring j_uri = (jstring)env->CallStaticObjectMethod(NativeApp, resolve, j_sub, j_file, (jboolean)create);
if (!j_uri) return {};
const char* cstr = env->GetStringUTFChars(j_uri, nullptr);
std::string ret(cstr);
env->ReleaseStringUTFChars(j_uri, cstr);
env->DeleteLocalRef(j_uri);
return ret;
}
extern "C"
JNIEXPORT jboolean JNICALL
@@ -1445,3 +1593,42 @@ bool Host::InNoGUIMode()
{
return false;
}
// JNI: report if a SAF Data Root is configured
bool HasSafDataRootJNI()
{
JNIEnv* env = static_cast<JNIEnv*>(SDL_GetAndroidJNIEnv());
if (!env) return false;
jclass cls = env->FindClass("com/izzy2lost/psx2/NativeApp");
if (!cls) return false;
jmethodID mid = env->GetStaticMethodID(cls, "hasSafDataRoot", "()Z");
if (!mid) return false;
jboolean res = env->CallStaticBooleanMethod(cls, mid);
return (res == JNI_TRUE);
}
static std::vector<std::string> SafListFilesJNI(const char* subdir)
{
std::vector<std::string> ret;
JNIEnv* env = static_cast<JNIEnv*>(SDL_GetAndroidJNIEnv());
if (!env) return ret;
jclass cls = env->FindClass("com/izzy2lost/psx2/NativeApp");
if (!cls) return ret;
jmethodID mid = env->GetStaticMethodID(cls, "listSafFilenames", "(Ljava/lang/String;)[Ljava/lang/String;");
if (!mid) return ret;
jstring j_sub = env->NewStringUTF(subdir);
jobjectArray arr = (jobjectArray)env->CallStaticObjectMethod(cls, mid, j_sub);
env->DeleteLocalRef(j_sub);
if (!arr) return ret;
jsize n = env->GetArrayLength(arr);
ret.reserve(n);
for (jsize i = 0; i < n; i++) {
jstring s = (jstring)env->GetObjectArrayElement(arr, i);
if (!s) continue;
const char* cs = env->GetStringUTFChars(s, nullptr);
if (cs) ret.emplace_back(cs);
env->ReleaseStringUTFChars(s, cs);
env->DeleteLocalRef(s);
}
env->DeleteLocalRef(arr);
return ret;
}
+38 -30
View File
@@ -75,16 +75,16 @@ INISettingsInterface::~INISettingsInterface()
bool INISettingsInterface::Load()
{
if (m_filename.empty())
return false;
if (m_filename.empty())
return false;
std::unique_lock lock(s_ini_load_save_mutex);
SI_Error err = SI_FAIL;
auto fp = FileSystem::OpenManagedCFile(m_filename.c_str(), "rb");
if (fp)
err = m_ini.LoadFile(fp.get());
std::unique_lock lock(s_ini_load_save_mutex);
SI_Error err = SI_FAIL;
auto fp = FileSystem::OpenManagedCFile(m_filename.c_str(), "rb");
if (fp)
err = m_ini.LoadFile(fp.get());
return (err == SI_OK);
return (err == SI_OK);
}
bool INISettingsInterface::Save(Error* error)
@@ -95,29 +95,37 @@ bool INISettingsInterface::Save(Error* error)
return false;
}
std::unique_lock lock(s_ini_load_save_mutex);
std::string temp_filename;
std::FILE* fp = GetTemporaryFile(&temp_filename, m_filename, "wb", error);
SI_Error err = SI_FAIL;
if (fp)
{
err = m_ini.SaveFile(fp, false);
std::fclose(fp);
std::unique_lock lock(s_ini_load_save_mutex);
SI_Error err = SI_FAIL;
if (m_filename.rfind("saf://", 0) == 0)
{
// Direct write to SAF file; no temp/rename
auto fp = FileSystem::OpenManagedCFile(m_filename.c_str(), "wb", error);
if (fp)
err = m_ini.SaveFile(fp.get(), false);
}
else
{
std::string temp_filename;
std::FILE* fp = GetTemporaryFile(&temp_filename, m_filename, "wb", error);
if (fp)
{
err = m_ini.SaveFile(fp, false);
std::fclose(fp);
if (err != SI_OK)
{
Error::SetStringFmt(error, "INI SaveFile() failed: {}", static_cast<int>(err));
// remove temporary file
FileSystem::DeleteFilePath(temp_filename.c_str());
}
else if (!FileSystem::RenamePath(temp_filename.c_str(), m_filename.c_str(), error))
{
Console.Error("Failed to rename '%s' to '%s'", temp_filename.c_str(), m_filename.c_str());
FileSystem::DeleteFilePath(temp_filename.c_str());
return false;
}
}
if (err != SI_OK)
{
Error::SetStringFmt(error, "INI SaveFile() failed: {}", static_cast<int>(err));
FileSystem::DeleteFilePath(temp_filename.c_str());
}
else if (!FileSystem::RenamePath(temp_filename.c_str(), m_filename.c_str(), error))
{
Console.Error("Failed to rename '%s' to '%s'", temp_filename.c_str(), m_filename.c_str());
FileSystem::DeleteFilePath(temp_filename.c_str());
return false;
}
}
}
if (err != SI_OK)
{
+2 -2
View File
@@ -2259,7 +2259,7 @@ void EmuFolders::LoadConfig(SettingsInterface& si)
Cheats = LoadPathFromSettings(si, DataRoot, "Cheats", "cheats");
Patches = LoadPathFromSettings(si, DataRoot, "Patches", "patches");
Covers = LoadPathFromSettings(si, DataRoot, "Covers", "covers");
GameSettings = LoadPathFromSettings(si, DataRoot, "GameSettings", "gamesettings");
GameSettings = LoadPathFromSettings(si, DataRoot, "GameSettings", "gamesettings");
UserResources = LoadPathFromSettings(si, DataRoot, "UserResources", "resources");
Cache = LoadPathFromSettings(si, DataRoot, "Cache", "cache");
Textures = LoadPathFromSettings(si, DataRoot, "Textures", "textures");
@@ -2276,7 +2276,7 @@ void EmuFolders::LoadConfig(SettingsInterface& si)
Console.WriteLn("Cheats Directory: %s", Cheats.c_str());
Console.WriteLn("Patches Directory: %s", Patches.c_str());
Console.WriteLn("Covers Directory: %s", Covers.c_str());
Console.WriteLn("Game Settings Directory: %s", GameSettings.c_str());
Console.WriteLn("Game Settings Directory: %s", GameSettings.c_str());
Console.WriteLn("Resources Directory: %s", Resources.c_str());
Console.WriteLn("User Resources Directory: %s", UserResources.c_str());
Console.WriteLn("Cache Directory: %s", Cache.c_str());
+25 -20
View File
@@ -42,6 +42,11 @@
#include <csetjmp>
#include <png.h>
#if defined(__ANDROID__)
// includes previously used for fd-based zip sinks; left guarded for future use
#include <unistd.h>
#include <fcntl.h>
#endif
using namespace R5900;
@@ -1040,29 +1045,29 @@ static bool SaveState_AddToZip(zip_t* zf, ArchiveEntryList* srclist, SaveStateSc
bool SaveState_ZipToDisk(std::unique_ptr<ArchiveEntryList> srclist, std::unique_ptr<SaveStateScreenshotData> screenshot, const char* filename)
{
zip_error_t ze = {};
zip_source_t* zs = zip_source_file_create(filename, 0, 0, &ze);
zip_t* zf = nullptr;
if (zs && !(zf = zip_open_from_source(zs, ZIP_CREATE | ZIP_TRUNCATE, &ze)))
{
Console.Error("Failed to open zip file '%s' for save state: %s", filename, zip_error_strerror(&ze));
zip_error_t ze = {};
zip_source_t* zs = zip_source_file_create(filename, 0, 0, &ze);
zip_t* zf = nullptr;
if (zs && !(zf = zip_open_from_source(zs, ZIP_CREATE | ZIP_TRUNCATE, &ze)))
{
Console.Error("Failed to open zip file '%s' for save state: %s", filename, zip_error_strerror(&ze));
// have to clean up source
zip_source_free(zs);
return false;
}
// have to clean up source
zip_source_free(zs);
return false;
}
// discard zip file if we fail saving something
if (!SaveState_AddToZip(zf, srclist.get(), screenshot.get()))
{
Console.Error("Failed to save state to zip file '%s'", filename);
zip_discard(zf);
return false;
}
// discard zip file if we fail saving something
if (!SaveState_AddToZip(zf, srclist.get(), screenshot.get()))
{
Console.Error("Failed to save state to zip file '%s'", filename);
zip_discard(zf);
return false;
}
// force the zip to close, this is the expensive part with libzip.
zip_close(zf);
return true;
// force the zip to close, this is the expensive part with libzip.
zip_close(zf);
return true;
}
bool SaveState_ReadScreenshot(const std::string& filename, u32* out_width, u32* out_height, std::vector<u32>* out_pixels)
+29 -6
View File
@@ -76,6 +76,11 @@
#include "common/Darwin/DarwinMisc.h"
#endif
#ifdef __ANDROID__
// JNI helper implemented in native-lib.cpp to query SAF Data Root presence
bool HasSafDataRootJNI();
#endif
namespace VMManager
{
static void SetDefaultLoggingSettings(SettingsInterface& si);
@@ -543,8 +548,19 @@ bool VMManager::Internal::CheckSettingsVersion()
void VMManager::Internal::LoadStartupSettings()
{
SettingsInterface* bsi = Host::Internal::GetBaseSettingsLayer();
EmuFolders::LoadConfig(*bsi);
EmuFolders::EnsureFoldersExist();
EmuFolders::LoadConfig(*bsi);
EmuFolders::EnsureFoldersExist();
#ifdef __ANDROID__
// Redirect cheats and patches to SAF Data Folder if available
if (HasSafDataRootJNI())
{
EmuFolders::Cheats = "saf://cheats";
EmuFolders::Patches = "saf://patches";
// Route textures and screenshots to SAF; savestates remain on internal due to libzip constraints
EmuFolders::Textures = "saf://textures";
EmuFolders::Snapshots = "saf://snaps";
}
#endif
// We need to create the console window early, otherwise it appears behind the main window.
UpdateLoggingSettings(*bsi);
@@ -767,11 +783,17 @@ bool VMManager::ReloadGameSettings()
std::string VMManager::GetGameSettingsPath(const std::string_view game_serial, u32 game_crc)
{
std::string sanitized_serial(Path::SanitizeFileName(game_serial));
std::string sanitized_serial(Path::SanitizeFileName(game_serial));
return game_serial.empty() ?
Path::Combine(EmuFolders::GameSettings, fmt::format("{:08X}.ini", game_crc)) :
Path::Combine(EmuFolders::GameSettings, fmt::format("{}_{:08X}.ini", sanitized_serial, game_crc));
std::string base = EmuFolders::GameSettings;
#ifdef __ANDROID__
// If the app has a SAF Data Root, direct per-game settings into it via saf:// scheme
if (HasSafDataRootJNI())
base = "saf://gamesettings";
#endif
return game_serial.empty() ?
Path::Combine(base, fmt::format("{:08X}.ini", game_crc)) :
Path::Combine(base, fmt::format("{}_{:08X}.ini", sanitized_serial, game_crc));
}
std::string VMManager::GetDiscOverrideFromGameSettings(const std::string& elf_path)
@@ -3694,3 +3716,4 @@ void VMManager::PollDiscordPresence()
Discord_RunCallbacks();
}
@@ -0,0 +1,143 @@
package com.izzy2lost.psx2;
import android.app.Dialog;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.io.InputStream;
import java.util.ArrayList;
public class CheatsDialogFragment extends DialogFragment {
private ArrayAdapter<String> adapter;
private ArrayList<String> items = new ArrayList<>();
private static final int REQ_IMPORT_PNACH = 1001;
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
Context ctx = requireContext();
View view = LayoutInflater.from(ctx).inflate(R.layout.simple_list, null, false);
ListView lv = view.findViewById(android.R.id.list);
adapter = new ArrayAdapter<>(ctx, android.R.layout.simple_list_item_1, items);
lv.setAdapter(adapter);
lv.setOnItemLongClickListener((parent, v, position, id) -> {
String name = items.get(position);
deleteCheat(name);
return true;
});
refreshList();
return new MaterialAlertDialogBuilder(ctx, com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
.setCustomTitle(UiUtils.centeredDialogTitle(ctx, "Manage Cheats"))
.setView(view)
.setNegativeButton("Close", null)
.setPositiveButton("Import For Game", (d, w) -> startImport())
.create();
}
private void refreshList() {
items.clear();
String[] names = NativeApp.listSafFilenames("cheats");
if (names != null && names.length > 0) {
for (String n : names) if (n != null && n.endsWith(".pnach")) items.add(n);
} else {
java.io.File dir = new java.io.File(requireContext().getExternalFilesDir(null), "cheats");
if (!dir.exists()) dir.mkdirs();
java.io.File[] arr = dir.listFiles((f, n) -> n != null && n.endsWith(".pnach"));
if (arr != null) for (java.io.File f : arr) items.add(f.getName());
}
if (adapter != null) adapter.notifyDataSetChanged();
}
private void startImport() {
try {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
startActivityForResult(intent, REQ_IMPORT_PNACH);
} catch (Throwable t) {
Toast.makeText(requireContext(), "No file picker available", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQ_IMPORT_PNACH && resultCode == android.app.Activity.RESULT_OK && data != null) {
Uri uri = data.getData();
if (uri == null) return;
String serial = null;
try { serial = NativeApp.getCurrentGameSerial(); } catch (Throwable ignored) {}
if (serial == null || serial.isEmpty()) {
Toast.makeText(requireContext(), "Unknown game serial", Toast.LENGTH_SHORT).show();
return;
}
String outName = serial + ".pnach";
android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext());
if (dataRoot != null) {
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(requireContext(), new String[]{"cheats"}, outName, "application/octet-stream");
if (target != null) {
try (InputStream in = requireContext().getContentResolver().openInputStream(uri)) {
if (in != null && SafManager.copyFromStream(requireContext(), in, target.getUri())) {
Toast.makeText(requireContext(), "Imported to Data Folder", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(requireContext(), "Import failed", Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Toast.makeText(requireContext(), "Import failed", Toast.LENGTH_SHORT).show();
}
}
} else {
java.io.File dir = new java.io.File(requireContext().getExternalFilesDir(null), "cheats");
if (!dir.exists()) dir.mkdirs();
java.io.File out = new java.io.File(dir, outName);
try (InputStream in = requireContext().getContentResolver().openInputStream(uri);
java.io.FileOutputStream os = new java.io.FileOutputStream(out)) {
if (in != null) {
byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) os.write(buf, 0, n);
os.flush();
Toast.makeText(requireContext(), "Imported", Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Toast.makeText(requireContext(), "Import failed", Toast.LENGTH_SHORT).show();
}
}
refreshList();
}
}
private void deleteCheat(String name) {
android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext());
boolean ok = false;
if (dataRoot != null) {
androidx.documentfile.provider.DocumentFile f = SafManager.getChild(requireContext(), new String[]{"cheats"}, name);
if (f != null) ok = f.delete();
} else {
java.io.File dir = new java.io.File(requireContext().getExternalFilesDir(null), "cheats");
java.io.File f = new java.io.File(dir, name);
ok = f.delete();
}
if (ok) {
Toast.makeText(requireContext(), "Deleted", Toast.LENGTH_SHORT).show();
refreshList();
} else {
Toast.makeText(requireContext(), "Delete failed", Toast.LENGTH_SHORT).show();
}
}
}
@@ -80,7 +80,7 @@ public class GameSettingsDialogFragment extends DialogFragment {
android.net.Uri dataRoot = SafManager.getDataRootUri(ctx);
if (dataRoot != null) {
String subdir = mImportAsCheats ? "cheats" : "patches";
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{subdir}, gameSerial + ".pnach", "text/plain");
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{subdir}, gameSerial + ".pnach", "application/octet-stream");
if (target != null) {
try (java.io.InputStream in2 = cr.openInputStream(android.net.Uri.fromFile(outFile))) {
SafManager.copyFromStream(ctx, in2, target.getUri());
@@ -428,7 +428,7 @@ public class GameSettingsDialogFragment extends DialogFragment {
android.net.Uri dataRoot = SafManager.getDataRootUri(ctx);
if (dataRoot != null) {
try {
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{"gamesettings"}, fileName, "text/plain");
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{"gamesettings"}, fileName, "application/octet-stream");
if (target != null) {
byte[] data = sb.toString().getBytes("UTF-8");
SafManager.writeBytes(ctx, target.getUri(), data);
@@ -521,6 +521,13 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
SettingsDialogFragment dialog = new SettingsDialogFragment();
dialog.show(fm, "settings_dialog");
});
// Long-press to open Cheats manager
btn_settings.setOnLongClickListener(v -> {
FragmentManager fm2 = getSupportFragmentManager();
CheatsDialogFragment cd = new CheatsDialogFragment();
cd.show(fm2, "cheats_dialog");
return true;
});
}
// Toggle all UI visibility (including controls)
@@ -1742,3 +1749,4 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
}
}
}
@@ -35,7 +35,7 @@ public class NativeApp {
initialize(externalFilesDir.getAbsolutePath(), android.os.Build.VERSION.SDK_INT);
}
public static native void initialize(String path, int apiVer);
public static native void initialize(String path, int apiVer);
public static native String getGameTitle(String path);
public static native String getGameTitleFromUri(String gameUri);
public static native String getGameSerial();
@@ -69,6 +69,7 @@ public class NativeApp {
// Texture loading options for texture packs
public static native void setLoadTextures(boolean enabled);
public static native void setAsyncTextureLoading(boolean enabled);
public static native void setPrecacheTextureReplacements(boolean enabled);
public static native void setBlendingAccuracy(int level);
// Shade Boost (brightness/contrast/saturation)
@@ -154,7 +155,7 @@ public class NativeApp {
public static native void onNativeSurfaceChanged(Surface surface, int w, int h);
public static native void onNativeSurfaceDestroyed();
public static native boolean runVMThread(String path);
public static native boolean runVMThread(String path);
public static native void pause();
public static native void resume();
@@ -166,17 +167,149 @@ public class NativeApp {
public static native byte[] getImageSlot(int slot);
// Call jni
public static int openContentUri(String uriString) {
Context _context = getContext();
if(_context != null) {
ContentResolver _contentResolver = _context.getContentResolver();
try {
ParcelFileDescriptor filePfd = _contentResolver.openFileDescriptor(Uri.parse(uriString), "r");
if (filePfd != null) {
return filePfd.detachFd(); // Take ownership of the fd.
}
} catch (Exception ignored) {}
}
return -1;
}
public static int openContentUri(String uriString) {
Context _context = getContext();
if(_context != null) {
ContentResolver _contentResolver = _context.getContentResolver();
try {
ParcelFileDescriptor filePfd = _contentResolver.openFileDescriptor(Uri.parse(uriString), "r");
if (filePfd != null) {
return filePfd.detachFd(); // Take ownership of the fd.
}
} catch (Exception ignored) {}
}
return -1;
}
// Indicates whether a SAF Data Root has been selected by the user.
public static boolean hasSafDataRoot() {
return SafManager.getDataRootUri(getContext()) != null;
}
// Open a SAF content Uri with the requested mode ("r", "w", or "rw"). Returns a detached FD or -1.
public static int openContentUriMode(String uriString, String mode) {
Context _context = getContext();
if(_context != null) {
ContentResolver _contentResolver = _context.getContentResolver();
try {
ParcelFileDescriptor filePfd = _contentResolver.openFileDescriptor(Uri.parse(uriString), mode);
if (filePfd != null) {
return filePfd.detachFd();
}
} catch (Exception ignored) {}
}
return -1;
}
// Resolve a child document Uri within the SAF Data Root.
// subdir: e.g., "gamesettings", filename: e.g., "SLUS-12345.ini". If create is true, creates file.
public static String resolveSafChildUri(String subdir, String filename, boolean create) {
Uri root = SafManager.getDataRootUri(getContext());
if (root == null) return null;
try {
androidx.documentfile.provider.DocumentFile df;
if (create) {
df = SafManager.createChild(getContext(), new String[]{subdir}, filename, "application/octet-stream");
} else {
df = SafManager.getChild(getContext(), new String[]{subdir}, filename);
}
return (df != null) ? df.getUri().toString() : null;
} catch (Throwable ignored) { }
return null;
}
// Resolve a file path relative to the SAF Data Root. Accepts nested paths like
// "textures/SLUS-12345/replacements/subdir/file.png". If create is true, creates the file.
public static String resolveSafPathUri(String relativePath, boolean create) {
if (relativePath == null) return null;
Uri root = SafManager.getDataRootUri(getContext());
if (root == null) return null;
try {
String[] parts = relativePath.split("/");
if (parts.length == 0) return null;
String[] dirSegs;
String filename;
if (parts.length == 1) {
dirSegs = new String[]{};
filename = parts[0];
} else {
dirSegs = new String[parts.length - 1];
System.arraycopy(parts, 0, dirSegs, 0, parts.length - 1);
filename = parts[parts.length - 1];
}
androidx.documentfile.provider.DocumentFile df;
if (create) {
df = SafManager.createChild(getContext(), dirSegs, filename, "application/octet-stream");
} else {
df = SafManager.getChild(getContext(), dirSegs, filename);
}
return (df != null) ? df.getUri().toString() : null;
} catch (Throwable ignored) { }
return null;
}
// List files under a relative SAF directory recursively. Returns full relative paths from the root.
public static String[] listSafRecursiveFiles(String relativeDir) {
java.util.ArrayList<String> out = new java.util.ArrayList<>();
try {
androidx.documentfile.provider.DocumentFile base = SafManager.getOrCreateDir(getContext(), relativeDir.split("/"));
if (base == null || !base.exists()) return new String[0];
walkDirRecursive(base, relativeDir, out);
} catch (Throwable ignored) { }
return out.toArray(new String[0]);
}
private static void walkDirRecursive(androidx.documentfile.provider.DocumentFile dir, String relPrefix, java.util.ArrayList<String> out) {
androidx.documentfile.provider.DocumentFile[] arr = dir.listFiles();
if (arr == null) return;
for (androidx.documentfile.provider.DocumentFile f : arr) {
if (f == null) continue;
String name = f.getName();
if (name == null || name.isEmpty()) continue;
if (f.isDirectory()) {
walkDirRecursive(f, relPrefix + "/" + name, out);
} else if (f.isFile()) {
out.add(relPrefix + "/" + name);
}
}
}
// List files directly under a relative SAF directory (non-recursive). Returns full relative paths.
public static String[] listSafFilesFlat(String relativeDir) {
java.util.ArrayList<String> out = new java.util.ArrayList<>();
try {
androidx.documentfile.provider.DocumentFile dir = SafManager.getOrCreateDir(getContext(), relativeDir.split("/"));
if (dir == null || !dir.isDirectory()) return new String[0];
androidx.documentfile.provider.DocumentFile[] arr = dir.listFiles();
if (arr != null) {
for (androidx.documentfile.provider.DocumentFile f : arr) {
if (f != null && f.isFile()) {
String name = f.getName();
if (name != null && !name.isEmpty()) out.add(relativeDir + "/" + name);
}
}
}
} catch (Throwable ignored) { }
return out.toArray(new String[0]);
}
// List filenames (files only) under a SAF subdirectory (e.g., "cheats", "patches").
public static String[] listSafFilenames(String subdir) {
try {
androidx.documentfile.provider.DocumentFile dir = SafManager.getOrCreateDir(getContext(), subdir);
if (dir == null || !dir.isDirectory()) return new String[0];
androidx.documentfile.provider.DocumentFile[] arr = dir.listFiles();
java.util.ArrayList<String> out = new java.util.ArrayList<>();
if (arr != null) {
for (androidx.documentfile.provider.DocumentFile f : arr) {
if (f != null && f.isFile()) {
String name = f.getName();
if (name != null && !name.isEmpty()) out.add(name);
}
}
}
return out.toArray(new String[0]);
} catch (Throwable ignored) { }
return new String[0];
}
}
@@ -42,6 +42,7 @@ public class SettingsDialogFragment extends DialogFragment {
boolean noInterlacingPatches = prefs.getBoolean("no_interlacing_patches", true);
boolean loadTextures = prefs.getBoolean("load_textures", false);
boolean asyncTextureLoading = prefs.getBoolean("async_texture_loading", true);
boolean precacheTextures = prefs.getBoolean("precache_textures", false);
boolean hudVisible = prefs.getBoolean("hud_visible", false);
// Debug logging
@@ -58,6 +59,7 @@ public class SettingsDialogFragment extends DialogFragment {
NativeApp.setNoInterlacingPatches(noInterlacingPatches);
NativeApp.setLoadTextures(loadTextures);
NativeApp.setAsyncTextureLoading(asyncTextureLoading);
NativeApp.setPrecacheTextureReplacements(precacheTextures);
NativeApp.setHudVisible(hudVisible);
// Set brighter default brightness (60 instead of 50)
@@ -85,6 +87,7 @@ public class SettingsDialogFragment extends DialogFragment {
MaterialSwitch swNoInterlacing = view.findViewById(R.id.sw_no_interlacing);
MaterialSwitch swLoadTextures = view.findViewById(R.id.sw_load_textures);
MaterialSwitch swAsyncTextureLoading = view.findViewById(R.id.sw_async_texture_loading);
MaterialSwitch swPrecacheTextures = view.findViewById(R.id.sw_precache_textures);
MaterialSwitch swDevHud = view.findViewById(R.id.sw_dev_hud);
View btnPower = view.findViewById(R.id.btn_power);
View btnReboot = view.findViewById(R.id.btn_reboot);
@@ -187,6 +190,7 @@ public class SettingsDialogFragment extends DialogFragment {
boolean savedNoInterlacing = prefs.getBoolean("no_interlacing_patches", true);
boolean savedLoadTextures = prefs.getBoolean("load_textures", false);
boolean savedAsyncTextureLoading = prefs.getBoolean("async_texture_loading", true);
boolean savedPrecacheTextures = prefs.getBoolean("precache_textures", false);
boolean savedHud = prefs.getBoolean("hud_visible", false);
boolean savedCheatsGlobal = prefs.getBoolean("enable_cheats", false);
int savedBlending = prefs.getInt("blending_accuracy", 1);
@@ -213,6 +217,7 @@ public class SettingsDialogFragment extends DialogFragment {
swNoInterlacing.setChecked(savedNoInterlacing);
swLoadTextures.setChecked(savedLoadTextures);
swAsyncTextureLoading.setChecked(savedAsyncTextureLoading);
if (swPrecacheTextures != null) swPrecacheTextures.setChecked(savedPrecacheTextures);
if (swDevHud != null) swDevHud.setChecked(savedHud);
MaterialSwitch swCheatsGlobal = view.findViewById(R.id.sw_enable_cheats_global);
if (swCheatsGlobal != null) swCheatsGlobal.setChecked(savedCheatsGlobal);
@@ -235,7 +240,8 @@ public class SettingsDialogFragment extends DialogFragment {
boolean widescreenPatches = swWidescreen.isChecked();
boolean noInterlacingPatches = swNoInterlacing.isChecked();
boolean loadTextures = swLoadTextures.isChecked();
boolean asyncTextureLoading = swAsyncTextureLoading.isChecked();
boolean asyncTextureLoading = swAsyncTextureLoading.isChecked();
boolean precacheTextureReplacements = swPrecacheTextures != null && swPrecacheTextures.isChecked();
boolean hudVisible = (swDevHud != null && swDevHud.isChecked());
boolean enableCheatsGlobal = swCheatsGlobal != null && swCheatsGlobal.isChecked();
@@ -250,17 +256,20 @@ public class SettingsDialogFragment extends DialogFragment {
.putBoolean("no_interlacing_patches", noInterlacingPatches)
.putBoolean("load_textures", loadTextures)
.putBoolean("async_texture_loading", asyncTextureLoading)
.putBoolean("precache_textures", precacheTextureReplacements)
.putBoolean("hud_visible", hudVisible)
.putBoolean("enable_cheats", enableCheatsGlobal)
.apply();
// Apply in one batch to avoid repeated ApplySettings calls
try {
NativeApp.applyGlobalSettingsBatch(renderer, scale, aspectRatio, blendingLevel,
widescreenPatches, noInterlacingPatches, loadTextures, asyncTextureLoading, hudVisible);
} catch (Throwable t) {
android.util.Log.e("SettingsDialog", "Batch apply failed: " + t.getMessage());
}
try {
NativeApp.applyGlobalSettingsBatch(renderer, scale, aspectRatio, blendingLevel,
widescreenPatches, noInterlacingPatches, loadTextures, asyncTextureLoading, hudVisible);
// Apply precache separately (not part of the batch JNI)
NativeApp.setPrecacheTextureReplacements(precacheTextureReplacements);
} catch (Throwable t) {
android.util.Log.e("SettingsDialog", "Apply failed: " + t.getMessage());
}
// Refresh quick UI (renderer label) if hosting activity is MainActivity
try {
@@ -208,6 +208,14 @@
android:text="Async Texture Loading"
android:layout_marginTop="8dp"
style="@style/Widget.Material3.CompoundButton.MaterialSwitch"/>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/sw_precache_textures"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Precache Texture Replacements"
android:layout_marginTop="8dp"
style="@style/Widget.Material3.CompoundButton.MaterialSwitch"/>
</LinearLayout>
<View
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<ListView
android:id="@android:id/list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:dividerHeight="8dp"/>
</FrameLayout>