Merge remote-tracking branch 'origin/playstore' into dev
This commit is contained in:
commit
a93bd9e64e
|
@ -29,11 +29,18 @@ jobs:
|
|||
key: android-ccache-${{ github.sha }}
|
||||
restore-keys: android-ccache-
|
||||
|
||||
- name: Bump version code
|
||||
uses: chkfung/android-version-actions@v1.2.2
|
||||
with:
|
||||
gradlePath: shell/android-studio/flycast/build.gradle
|
||||
versionCode: ${{ github.run_number }}
|
||||
|
||||
- name: Gradle
|
||||
working-directory: shell/android-studio
|
||||
run: ./gradlew assembleRelease --parallel
|
||||
run: ./gradlew assembleRelease bundleRelease --parallel
|
||||
env:
|
||||
SENTRY_UPLOAD_URL: ${{ secrets.SENTRY_UPLOAD_URL }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
@ -68,7 +75,9 @@ jobs:
|
|||
if: github.repository == 'flyinghead/flycast' && github.event_name == 'push'
|
||||
|
||||
- name: Upload to S3
|
||||
run: aws s3 sync shell/android-studio/flycast/build/outputs/apk/release s3://flycast-builds/android/${GITHUB_REF#refs/}-$GITHUB_SHA --acl public-read --exclude='*.json' --follow-symlinks
|
||||
run: |
|
||||
cp shell/android-studio/flycast/build/outputs/bundle/release/*.aab shell/android-studio/flycast/build/outputs/apk/release/
|
||||
aws s3 sync shell/android-studio/flycast/build/outputs/apk/release s3://flycast-builds/android/${GITHUB_REF#refs/}-$GITHUB_SHA --acl public-read --exclude='*.json' --follow-symlinks
|
||||
if: ${{ steps.aws-credentials.outputs.aws-account-id != '' }}
|
||||
|
||||
- name: Setup Sentry CLI
|
||||
|
|
|
@ -176,7 +176,7 @@ public:
|
|||
}
|
||||
recordStream->requestStart();
|
||||
NOTICE_LOG(AUDIO, "Oboe recorder started. stream capacity: %d frames",
|
||||
stream->getBufferCapacityInFrames());
|
||||
recordStream->getBufferCapacityInFrames());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -944,15 +944,20 @@ void Emulator::start()
|
|||
bool Emulator::checkStatus(bool wait)
|
||||
{
|
||||
try {
|
||||
const std::lock_guard<std::mutex> lock(mutex);
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
if (threadResult.valid())
|
||||
{
|
||||
if (!wait)
|
||||
{
|
||||
auto result = threadResult.wait_for(std::chrono::seconds(0));
|
||||
lock.unlock();
|
||||
auto localResult = threadResult;
|
||||
if (wait) {
|
||||
localResult.wait();
|
||||
}
|
||||
else {
|
||||
auto result = localResult.wait_for(std::chrono::seconds(0));
|
||||
if (result == std::future_status::timeout)
|
||||
return true;
|
||||
}
|
||||
lock.lock();
|
||||
threadResult.get();
|
||||
}
|
||||
return false;
|
||||
|
|
|
@ -179,7 +179,7 @@ private:
|
|||
Terminated,
|
||||
};
|
||||
State state = Uninitialized;
|
||||
std::future<void> threadResult;
|
||||
std::shared_future<void> threadResult;
|
||||
bool resetRequested = false;
|
||||
bool singleStep = false;
|
||||
u64 startTime = 0;
|
||||
|
|
|
@ -17,10 +17,11 @@
|
|||
#include "flashrom.h"
|
||||
#include "oslib/oslib.h"
|
||||
#include "stdclass.h"
|
||||
#include "oslib/storage.h"
|
||||
|
||||
bool MemChip::Load(const std::string& file)
|
||||
{
|
||||
FILE *f = nowide::fopen(file.c_str(), "rb");
|
||||
FILE *f = hostfs::storage().openFile(file, "rb");
|
||||
if (f)
|
||||
{
|
||||
bool rv = std::fread(data + write_protect_size, 1, size - write_protect_size, f) == size - write_protect_size;
|
||||
|
|
|
@ -494,9 +494,9 @@ void GDCartridge::device_start(LoadProgress *progress, std::vector<u8> *digest)
|
|||
u8 buffer[2048];
|
||||
std::string parent = hostfs::storage().getParentPath(settings.content.path);
|
||||
std::string gdrom_path = get_file_basename(settings.content.fileName) + "/" + gdrom_name;
|
||||
gdrom_path = hostfs::storage().getSubPath(parent, gdrom_path);
|
||||
std::unique_ptr<Disc> gdrom;
|
||||
try {
|
||||
gdrom_path = hostfs::storage().getSubPath(parent, gdrom_path);
|
||||
gdrom = std::unique_ptr<Disc>(OpenDisc(gdrom_path + ".chd", digest));
|
||||
}
|
||||
catch (const FlycastException& e)
|
||||
|
@ -504,8 +504,8 @@ void GDCartridge::device_start(LoadProgress *progress, std::vector<u8> *digest)
|
|||
WARN_LOG(NAOMI, "Opening chd failed: %s", e.what());
|
||||
if (gdrom_parent_name != nullptr)
|
||||
{
|
||||
std::string gdrom_parent_path = hostfs::storage().getSubPath(parent, std::string(gdrom_parent_name) + "/" + gdrom_name);
|
||||
try {
|
||||
std::string gdrom_parent_path = hostfs::storage().getSubPath(parent, std::string(gdrom_parent_name) + "/" + gdrom_name);
|
||||
gdrom = std::unique_ptr<Disc>(OpenDisc(gdrom_parent_path + ".chd", digest));
|
||||
} catch (const FlycastException& e) {
|
||||
WARN_LOG(NAOMI, "Opening parent chd failed: %s", e.what());
|
||||
|
|
|
@ -181,9 +181,12 @@ void naomi_cart_LoadBios(const char *filename)
|
|||
std::unique_ptr<Archive> parent_archive;
|
||||
if (game->parent_name != nullptr)
|
||||
{
|
||||
std::string parentPath = hostfs::storage().getParentPath(filename);
|
||||
parentPath = hostfs::storage().getSubPath(parentPath, game->parent_name);
|
||||
parent_archive.reset(OpenArchive(parentPath));
|
||||
try {
|
||||
std::string parentPath = hostfs::storage().getParentPath(filename);
|
||||
parentPath = hostfs::storage().getSubPath(parentPath, game->parent_name);
|
||||
parent_archive.reset(OpenArchive(parentPath));
|
||||
} catch (const FlycastException& e) {
|
||||
}
|
||||
}
|
||||
|
||||
const char *bios = "naomi";
|
||||
|
@ -219,8 +222,11 @@ static void loadMameRom(const std::string& path, const std::string& fileName, Lo
|
|||
if (game->parent_name != nullptr)
|
||||
{
|
||||
std::string parentPath = hostfs::storage().getParentPath(path);
|
||||
parentPath = hostfs::storage().getSubPath(parentPath, game->parent_name);
|
||||
parent_archive.reset(OpenArchive(parentPath));
|
||||
try {
|
||||
parentPath = hostfs::storage().getSubPath(parentPath, game->parent_name);
|
||||
parent_archive.reset(OpenArchive(parentPath));
|
||||
} catch (const FlycastException& e) {
|
||||
}
|
||||
if (parent_archive != nullptr)
|
||||
INFO_LOG(NAOMI, "Opened %s", game->parent_name);
|
||||
else
|
||||
|
|
|
@ -2088,12 +2088,18 @@ void SystemSpCart::Init(LoadProgress *progress, std::vector<u8> *digest)
|
|||
{
|
||||
std::string parent = hostfs::storage().getParentPath(settings.content.path);
|
||||
std::string gdrom_path = get_file_basename(settings.content.fileName) + "/" + std::string(mediaName) + ".chd";
|
||||
gdrom_path = hostfs::storage().getSubPath(parent, gdrom_path);
|
||||
chd = openChd(gdrom_path);
|
||||
try {
|
||||
gdrom_path = hostfs::storage().getSubPath(parent, gdrom_path);
|
||||
chd = openChd(gdrom_path);
|
||||
} catch (const FlycastException& e) {
|
||||
}
|
||||
if (parentName != nullptr && chd == nullptr)
|
||||
{
|
||||
std::string gdrom_parent_path = hostfs::storage().getSubPath(parent, std::string(parentName) + "/" + std::string(mediaName) + ".chd");
|
||||
chd = openChd(gdrom_parent_path);
|
||||
try {
|
||||
std::string gdrom_parent_path = hostfs::storage().getSubPath(parent, std::string(parentName) + "/" + std::string(mediaName) + ".chd");
|
||||
chd = openChd(gdrom_parent_path);
|
||||
} catch (const FlycastException& e) {
|
||||
}
|
||||
}
|
||||
if (chd == nullptr)
|
||||
throw NaomiCartException("SystemSP: Cannot open CompactFlash file " + gdrom_path);
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
#include "ui/gui.h"
|
||||
#include "oslib/oslib.h"
|
||||
#include "oslib/directory.h"
|
||||
#include "oslib/storage.h"
|
||||
#include "debug/gdb_server.h"
|
||||
#include "archive/rzip.h"
|
||||
#include "ui/mainui.h"
|
||||
|
@ -17,6 +18,9 @@
|
|||
#include "serialize.h"
|
||||
#include <time.h>
|
||||
|
||||
static std::string lastStateFile;
|
||||
static time_t lastStateTime;
|
||||
|
||||
struct SavestateHeader
|
||||
{
|
||||
void init()
|
||||
|
@ -121,6 +125,8 @@ void dc_savestate(int index, const u8 *pngData, u32 pngSize)
|
|||
if (settings.network.online)
|
||||
return;
|
||||
|
||||
lastStateFile.clear();
|
||||
|
||||
Serializer ser;
|
||||
dc_serialize(ser);
|
||||
|
||||
|
@ -189,7 +195,7 @@ void dc_loadstate(int index)
|
|||
u32 total_size = 0;
|
||||
|
||||
std::string filename = hostfs::getSavestatePath(index, false);
|
||||
FILE *f = nowide::fopen(filename.c_str(), "rb");
|
||||
FILE *f = hostfs::storage().openFile(filename, "rb");
|
||||
if (f == nullptr)
|
||||
{
|
||||
WARN_LOG(SAVESTATE, "Failed to load state - could not open %s for reading", filename.c_str());
|
||||
|
@ -278,28 +284,39 @@ void dc_loadstate(int index)
|
|||
time_t dc_getStateCreationDate(int index)
|
||||
{
|
||||
std::string filename = hostfs::getSavestatePath(index, false);
|
||||
FILE *f = nowide::fopen(filename.c_str(), "rb");
|
||||
if (f == nullptr)
|
||||
return 0;
|
||||
SavestateHeader header;
|
||||
if (std::fread(&header, sizeof(header), 1, f) != 1 || !header.isValid())
|
||||
if (filename != lastStateFile)
|
||||
{
|
||||
std::fclose(f);
|
||||
struct stat st;
|
||||
if (flycast::stat(filename.c_str(), &st) == 0)
|
||||
return st.st_mtime;
|
||||
lastStateFile = filename;
|
||||
FILE *f = hostfs::storage().openFile(filename, "rb");
|
||||
if (f == nullptr)
|
||||
lastStateTime = 0;
|
||||
else
|
||||
return 0;
|
||||
{
|
||||
SavestateHeader header;
|
||||
if (std::fread(&header, sizeof(header), 1, f) != 1 || !header.isValid())
|
||||
{
|
||||
std::fclose(f);
|
||||
try {
|
||||
hostfs::FileInfo fileInfo = hostfs::storage().getFileInfo(filename);
|
||||
lastStateTime = fileInfo.updateTime;
|
||||
} catch (...) {
|
||||
lastStateTime = 0;
|
||||
}
|
||||
}
|
||||
else {
|
||||
std::fclose(f);
|
||||
lastStateTime = (time_t)header.creationDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fclose(f);
|
||||
return (time_t)header.creationDate;
|
||||
return lastStateTime;
|
||||
}
|
||||
|
||||
void dc_getStateScreenshot(int index, std::vector<u8>& pngData)
|
||||
{
|
||||
pngData.clear();
|
||||
std::string filename = hostfs::getSavestatePath(index, false);
|
||||
FILE *f = nowide::fopen(filename.c_str(), "rb");
|
||||
FILE *f = hostfs::storage().openFile(filename, "rb");
|
||||
if (f == nullptr)
|
||||
return;
|
||||
SavestateHeader header;
|
||||
|
|
|
@ -78,13 +78,16 @@ std::string findFlash(const std::string& prefix, const std::string& names)
|
|||
name = name.replace(percent, 1, prefix);
|
||||
|
||||
std::string fullpath = get_readonly_data_path(name);
|
||||
if (file_exists(fullpath))
|
||||
if (hostfs::storage().exists(fullpath))
|
||||
return fullpath;
|
||||
for (const auto& path : config::ContentPath.get())
|
||||
{
|
||||
fullpath = path + "/" + name;
|
||||
if (file_exists(fullpath))
|
||||
return fullpath;
|
||||
try {
|
||||
fullpath = hostfs::storage().getSubPath(path, name);
|
||||
if (hostfs::storage().exists(fullpath))
|
||||
return fullpath;
|
||||
} catch (const hostfs::StorageException& e) {
|
||||
}
|
||||
}
|
||||
|
||||
start = semicolon;
|
||||
|
@ -103,14 +106,14 @@ std::string getFlashSavePath(const std::string& prefix, const std::string& name)
|
|||
std::string findNaomiBios(const std::string& name)
|
||||
{
|
||||
std::string fullpath = get_readonly_data_path(name);
|
||||
if (file_exists(fullpath))
|
||||
if (hostfs::storage().exists(fullpath))
|
||||
return fullpath;
|
||||
for (const auto& path : config::ContentPath.get())
|
||||
{
|
||||
try {
|
||||
fullpath = hostfs::storage().getSubPath(path, name);
|
||||
hostfs::storage().getFileInfo(fullpath);
|
||||
return fullpath;
|
||||
if (hostfs::storage().exists(fullpath))
|
||||
return fullpath;
|
||||
} catch (const hostfs::StorageException& e) {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -62,6 +62,11 @@ namespace hostfs
|
|||
|
||||
std::string getShaderCachePath(const std::string& filename);
|
||||
void saveScreenshot(const std::string& name, const std::vector<u8>& data);
|
||||
|
||||
#ifdef __ANDROID__
|
||||
void importHomeDirectory();
|
||||
void exportHomeDirectory();
|
||||
#endif
|
||||
}
|
||||
|
||||
static inline void *allocAligned(size_t alignment, size_t size)
|
||||
|
|
|
@ -40,7 +40,8 @@ CustomStorage& customStorage()
|
|||
std::string getParentPath(const std::string& path) override { die("Not implemented"); }
|
||||
std::string getSubPath(const std::string& reference, const std::string& relative) override { die("Not implemented"); }
|
||||
FileInfo getFileInfo(const std::string& path) override { die("Not implemented"); }
|
||||
void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) override {
|
||||
bool exists(const std::string& path) override { die("Not implemented"); }
|
||||
bool addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) override {
|
||||
die("Not implemented");
|
||||
}
|
||||
};
|
||||
|
@ -190,6 +191,7 @@ public:
|
|||
}
|
||||
info.isDirectory = S_ISDIR(st.st_mode);
|
||||
info.size = st.st_size;
|
||||
info.updateTime = st.st_mtime;
|
||||
#else // _WIN32
|
||||
nowide::wstackstring wname;
|
||||
if (wname.convert(path.c_str()))
|
||||
|
@ -199,6 +201,8 @@ public:
|
|||
{
|
||||
info.isDirectory = (fileAttribs.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
|
||||
info.size = fileAttribs.nFileSizeLow + ((u64)fileAttribs.nFileSizeHigh << 32);
|
||||
u64 t = ((u64)fileAttribs.ftLastWriteTime.dwHighDateTime << 32) | fileAttribs.ftLastWriteTime.dwLowDateTime;
|
||||
info.updateTime = t / 10000000 - 11644473600LL; // 100-nano to secs minus (unix epoch - windows epoch)
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -218,6 +222,23 @@ public:
|
|||
return info;
|
||||
}
|
||||
|
||||
bool exists(const std::string& path) override
|
||||
{
|
||||
#ifndef _WIN32
|
||||
struct stat st;
|
||||
return flycast::stat(path.c_str(), &st) == 0;
|
||||
#else // _WIN32
|
||||
nowide::wstackstring wname;
|
||||
if (wname.convert(path.c_str()))
|
||||
{
|
||||
WIN32_FILE_ATTRIBUTE_DATA fileAttribs;
|
||||
if (GetFileAttributesExW(wname.get(), GetFileExInfoStandard, &fileAttribs))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<FileInfo> listRoots()
|
||||
{
|
||||
|
@ -319,6 +340,14 @@ FileInfo AllStorage::getFileInfo(const std::string& path)
|
|||
return stdStorage.getFileInfo(path);
|
||||
}
|
||||
|
||||
bool AllStorage::exists(const std::string& path)
|
||||
{
|
||||
if (customStorage().isKnownPath(path))
|
||||
return customStorage().exists(path);
|
||||
else
|
||||
return stdStorage.exists(path);
|
||||
}
|
||||
|
||||
std::string AllStorage::getDefaultDirectory()
|
||||
{
|
||||
std::string directory;
|
||||
|
@ -363,9 +392,9 @@ AllStorage& storage()
|
|||
return storage;
|
||||
}
|
||||
|
||||
void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath))
|
||||
bool addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath))
|
||||
{
|
||||
customStorage().addStorage(isDirectory, writeAccess, callback);
|
||||
return customStorage().addStorage(isDirectory, writeAccess, callback);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -27,14 +27,18 @@ namespace hostfs
|
|||
struct FileInfo
|
||||
{
|
||||
FileInfo() = default;
|
||||
FileInfo(const std::string& name, const std::string& path, bool isDirectory, size_t size = 0, bool isWritable = false)
|
||||
: name(name), path(path), isDirectory(isDirectory), size(size), isWritable(isWritable) {}
|
||||
FileInfo(const std::string& name, const std::string& path, bool isDirectory,
|
||||
size_t size = 0, bool isWritable = false, u64 updateTime = 0)
|
||||
: name(name), path(path), isDirectory(isDirectory), size(size),
|
||||
isWritable(isWritable), updateTime(updateTime) {
|
||||
}
|
||||
|
||||
std::string name;
|
||||
std::string path;
|
||||
bool isDirectory = false;
|
||||
size_t size = 0;
|
||||
bool isWritable = false;
|
||||
u64 updateTime = 0;
|
||||
};
|
||||
|
||||
class StorageException : public FlycastException
|
||||
|
@ -52,6 +56,7 @@ public:
|
|||
virtual std::string getParentPath(const std::string& path) = 0;
|
||||
virtual std::string getSubPath(const std::string& reference, const std::string& subpath) = 0;
|
||||
virtual FileInfo getFileInfo(const std::string& path) = 0;
|
||||
virtual bool exists(const std::string& path) = 0;
|
||||
|
||||
virtual ~Storage() = default;
|
||||
};
|
||||
|
@ -59,7 +64,7 @@ public:
|
|||
class CustomStorage : public Storage
|
||||
{
|
||||
public:
|
||||
virtual void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) = 0;
|
||||
virtual bool addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) = 0;
|
||||
};
|
||||
|
||||
class AllStorage : public Storage
|
||||
|
@ -72,11 +77,12 @@ public:
|
|||
std::string getParentPath(const std::string& path) override;
|
||||
std::string getSubPath(const std::string& reference, const std::string& subpath) override;
|
||||
FileInfo getFileInfo(const std::string& path) override;
|
||||
bool exists(const std::string& path) override;
|
||||
std::string getDefaultDirectory();
|
||||
};
|
||||
|
||||
AllStorage& storage();
|
||||
void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath));
|
||||
bool addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath));
|
||||
|
||||
// iterate depth-first over the files contained in a folder hierarchy
|
||||
class DirectoryTree
|
||||
|
|
|
@ -107,13 +107,15 @@ bool CustomTexture::Init()
|
|||
|
||||
if (!textures_path.empty())
|
||||
{
|
||||
DIR *dir = flycast::opendir(textures_path.c_str());
|
||||
if (dir != nullptr)
|
||||
{
|
||||
NOTICE_LOG(RENDERER, "Found custom textures directory: %s", textures_path.c_str());
|
||||
custom_textures_available = true;
|
||||
flycast::closedir(dir);
|
||||
loader_thread.Start();
|
||||
try {
|
||||
hostfs::FileInfo fileInfo = hostfs::storage().getFileInfo(textures_path);
|
||||
if (fileInfo.isDirectory)
|
||||
{
|
||||
NOTICE_LOG(RENDERER, "Found custom textures directory: %s", textures_path.c_str());
|
||||
custom_textures_available = true;
|
||||
loader_thread.Start();
|
||||
}
|
||||
} catch (const FlycastException& e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -142,7 +144,7 @@ u8* CustomTexture::LoadCustomTexture(u32 hash, int& width, int& height)
|
|||
if (it == texture_map.end())
|
||||
return nullptr;
|
||||
|
||||
FILE *file = nowide::fopen(it->second.c_str(), "rb");
|
||||
FILE *file = hostfs::storage().openFile(it->second, "rb");
|
||||
if (file == nullptr)
|
||||
return nullptr;
|
||||
int n;
|
||||
|
|
|
@ -766,7 +766,6 @@ static int suspendEventFilter(void *userdata, SDL_Event *event)
|
|||
{
|
||||
if (event->type == SDL_APP_WILLENTERBACKGROUND)
|
||||
{
|
||||
gui_save();
|
||||
if (gameRunning)
|
||||
{
|
||||
try {
|
||||
|
|
|
@ -97,8 +97,8 @@ std::string get_readonly_data_path(const std::string& filename)
|
|||
std::string parent = hostfs::storage().getParentPath(settings.content.path);
|
||||
try {
|
||||
std::string filepath = hostfs::storage().getSubPath(parent, filename);
|
||||
hostfs::FileInfo info = hostfs::storage().getFileInfo(filepath);
|
||||
return info.path;
|
||||
if (hostfs::storage().exists(filepath))
|
||||
return filepath;
|
||||
} catch (const FlycastException&) { }
|
||||
|
||||
// Not found, so we return the user variant
|
||||
|
|
|
@ -146,14 +146,12 @@ void Boxart::fetchBoxart()
|
|||
}
|
||||
}
|
||||
}
|
||||
saveDatabase(true);
|
||||
saveDatabase();
|
||||
});
|
||||
}
|
||||
|
||||
void Boxart::saveDatabase(bool internal)
|
||||
void Boxart::saveDatabase()
|
||||
{
|
||||
if (!internal && fetching.valid())
|
||||
fetching.get();
|
||||
if (!databaseDirty)
|
||||
return;
|
||||
std::string db_name = getSaveDirectory() + DB_NAME;
|
||||
|
@ -216,3 +214,9 @@ void Boxart::loadDatabase()
|
|||
WARN_LOG(COMMON, "Corrupted database file: %s", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
void Boxart::term()
|
||||
{
|
||||
if (fetching.valid())
|
||||
fetching.get();
|
||||
}
|
||||
|
|
|
@ -34,10 +34,11 @@ class Boxart
|
|||
public:
|
||||
GameBoxart getBoxartAndLoad(const GameMedia& media);
|
||||
GameBoxart getBoxart(const GameMedia& media);
|
||||
void saveDatabase(bool internal = false);
|
||||
void term();
|
||||
|
||||
private:
|
||||
void loadDatabase();
|
||||
void saveDatabase();
|
||||
std::string getSaveDirectory() const {
|
||||
return get_writable_data_path("/boxart/");
|
||||
}
|
||||
|
|
|
@ -150,8 +150,11 @@ void GameScanner::fetch_game_list()
|
|||
if (!running)
|
||||
break;
|
||||
}
|
||||
std::string dcbios = hostfs::findFlash("dc_", "%bios.bin;%boot.bin");
|
||||
{
|
||||
LockGuard _(mutex);
|
||||
if (!dcbios.empty())
|
||||
game_list.insert(game_list.begin(), { "Dreamcast BIOS" });
|
||||
game_list.insert(game_list.end(), arcade_game_list.begin(), arcade_game_list.end());
|
||||
}
|
||||
if (running)
|
||||
|
|
176
core/ui/gui.cpp
176
core/ui/gui.cpp
|
@ -1441,7 +1441,7 @@ static void gamepadSettingsPopup(const std::shared_ptr<GamepadDevice>& gamepad)
|
|||
if (gamepad->is_virtual_gamepad())
|
||||
{
|
||||
header("Haptic");
|
||||
OptionSlider("Power", config::VirtualGamepadVibration, 0, 60, "Haptic feedback power");
|
||||
OptionSlider("Power", config::VirtualGamepadVibration, 0, 100, "Haptic feedback power", "%d%%");
|
||||
}
|
||||
else if (gamepad->is_rumble_enabled())
|
||||
{
|
||||
|
@ -1539,7 +1539,7 @@ static void contentpath_warning_popup()
|
|||
if (show_contentpath_selection)
|
||||
{
|
||||
scanner.stop();
|
||||
const char *title = "Select a Content Directory";
|
||||
const char *title = "Select a Content Folder";
|
||||
ImGui::OpenPopup(title);
|
||||
select_file_popup(title, [](bool cancelled, std::string selection)
|
||||
{
|
||||
|
@ -1610,17 +1610,44 @@ static void gui_debug_tab()
|
|||
#endif
|
||||
}
|
||||
|
||||
static void addContentPath(const std::string& path)
|
||||
static void addContentPathCallback(const std::string& path)
|
||||
{
|
||||
auto& contentPath = config::ContentPath.get();
|
||||
if (std::count(contentPath.begin(), contentPath.end(), path) == 0)
|
||||
{
|
||||
scanner.stop();
|
||||
contentPath.push_back(path);
|
||||
if (gui_state == GuiState::Main)
|
||||
// when adding content path from empty game list
|
||||
SaveSettings();
|
||||
scanner.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
static void addContentPath(bool start)
|
||||
{
|
||||
const char *title = "Select a Content Folder";
|
||||
select_file_popup(title, [](bool cancelled, std::string selection) {
|
||||
if (!cancelled)
|
||||
addContentPathCallback(selection);
|
||||
return true;
|
||||
});
|
||||
#ifdef __ANDROID__
|
||||
if (start)
|
||||
{
|
||||
bool supported = hostfs::addStorage(true, false, [](bool cancelled, std::string selection) {
|
||||
if (!cancelled)
|
||||
addContentPathCallback(selection);
|
||||
});
|
||||
if (!supported)
|
||||
ImGui::OpenPopup(title);
|
||||
}
|
||||
#else
|
||||
if (start)
|
||||
ImGui::OpenPopup(title);
|
||||
#endif
|
||||
}
|
||||
|
||||
static float calcComboWidth(const char *biggestLabel) {
|
||||
return ImGui::CalcTextSize(biggestLabel).x + ImGui::GetStyle().FramePadding.x * 2.0f + ImGui::GetFrameHeight();
|
||||
}
|
||||
|
@ -1672,7 +1699,7 @@ static void gui_settings_general()
|
|||
ImVec2 size;
|
||||
size.x = 0.0f;
|
||||
size.y = (ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().FramePadding.y * 2.f)
|
||||
* (config::ContentPath.get().size() + 1) ;//+ ImGui::GetStyle().FramePadding.y * 2.f;
|
||||
* (config::ContentPath.get().size() + 1);
|
||||
|
||||
if (BeginListBox("Content Location", size, ImGuiWindowFlags_NavFlattened))
|
||||
{
|
||||
|
@ -1681,33 +1708,21 @@ static void gui_settings_general()
|
|||
{
|
||||
ImguiID _(config::ContentPath.get()[i].c_str());
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::Text("%s", config::ContentPath.get()[i].c_str());
|
||||
ImGui::SameLine(ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize("X").x - ImGui::GetStyle().FramePadding.x);
|
||||
if (ImGui::Button("X"))
|
||||
float maxW = ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize(ICON_FA_TRASH_CAN).x - ImGui::GetStyle().FramePadding.x * 2
|
||||
- ImGui::GetStyle().ItemSpacing.x;
|
||||
std::string s = middleEllipsis(config::ContentPath.get()[i], maxW);
|
||||
ImGui::Text("%s", s.c_str());
|
||||
ImGui::SameLine(0, maxW - ImGui::CalcTextSize(s.c_str()).x + ImGui::GetStyle().ItemSpacing.x);
|
||||
if (ImGui::Button(ICON_FA_TRASH_CAN))
|
||||
to_delete = i;
|
||||
}
|
||||
#ifdef __ANDROID__
|
||||
|
||||
ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(24, 3));
|
||||
if (ImGui::Button("Add"))
|
||||
{
|
||||
hostfs::addStorage(true, false, [](bool cancelled, std::string selection) {
|
||||
if (!cancelled)
|
||||
addContentPath(selection);
|
||||
});
|
||||
}
|
||||
#else
|
||||
const char *title = "Select a Content Directory";
|
||||
select_file_popup(title, [](bool cancelled, std::string selection) {
|
||||
if (!cancelled)
|
||||
addContentPath(selection);
|
||||
return true;
|
||||
});
|
||||
ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(24, 3));
|
||||
if (ImGui::Button("Add"))
|
||||
ImGui::OpenPopup(title);
|
||||
#endif
|
||||
const bool addContent = ImGui::Button("Add");
|
||||
addContentPath(addContent);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Rescan Content"))
|
||||
|
||||
if (ImGui::Button("Rescan Content"))
|
||||
scanner.refresh();
|
||||
scrollWhenDraggingOnVoid();
|
||||
|
||||
|
@ -1720,31 +1735,40 @@ static void gui_settings_general()
|
|||
}
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ShowHelpMarker("The directories where your games are stored");
|
||||
ShowHelpMarker("The folders where your games are stored");
|
||||
|
||||
size.y = ImGui::GetTextLineHeightWithSpacing() * 1.25f + ImGui::GetStyle().FramePadding.y * 2.0f;
|
||||
|
||||
#if defined(__linux__) && !defined(__ANDROID__)
|
||||
if (BeginListBox("Data Directory", size, ImGuiWindowFlags_NavFlattened))
|
||||
if (BeginListBox("Data Folder", size, ImGuiWindowFlags_NavFlattened))
|
||||
{
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::Text("%s", get_writable_data_path("").c_str());
|
||||
float w = ImGui::GetContentRegionAvail().x - ImGui::GetStyle().FramePadding.x;
|
||||
std::string s = middleEllipsis(get_writable_data_path(""), w);
|
||||
ImGui::Text("%s", s.c_str());
|
||||
ImGui::EndListBox();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ShowHelpMarker("The directory containing BIOS files, as well as saved VMUs and states");
|
||||
ShowHelpMarker("The folder containing BIOS files, as well as saved VMUs and states");
|
||||
#else
|
||||
if (BeginListBox("Home Directory", size, ImGuiWindowFlags_NavFlattened))
|
||||
#if defined(__ANDROID__) || defined(TARGET_MAC)
|
||||
size.y += ImGui::GetTextLineHeightWithSpacing() * 1.25f;
|
||||
#endif
|
||||
if (BeginListBox("Home Folder", size, ImGuiWindowFlags_NavFlattened))
|
||||
{
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::Text("%s", get_writable_config_path("").c_str());
|
||||
float w = ImGui::GetContentRegionAvail().x - ImGui::GetStyle().FramePadding.x;
|
||||
std::string s = middleEllipsis(get_writable_config_path(""), w);
|
||||
ImGui::Text("%s", s.c_str());
|
||||
ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(24, 3));
|
||||
#ifdef __ANDROID__
|
||||
ImGui::SameLine(ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize("Change").x - ImGui::GetStyle().FramePadding.x);
|
||||
if (ImGui::Button("Change"))
|
||||
gui_setState(GuiState::Onboarding);
|
||||
if (ImGui::Button("Import"))
|
||||
hostfs::importHomeDirectory();
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Export"))
|
||||
hostfs::exportHomeDirectory();
|
||||
#endif
|
||||
#ifdef TARGET_MAC
|
||||
ImGui::SameLine(ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize("Reveal in Finder").x - ImGui::GetStyle().FramePadding.x);
|
||||
if (ImGui::Button("Reveal in Finder"))
|
||||
{
|
||||
char temp[512];
|
||||
|
@ -1755,9 +1779,15 @@ static void gui_settings_general()
|
|||
ImGui::EndListBox();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ShowHelpMarker("The directory where Flycast saves configuration files and VMUs. BIOS files should be in a subfolder named \"data\"");
|
||||
ShowHelpMarker("The folder where Flycast saves configuration files and VMUs. BIOS files should be in a subfolder named \"data\"");
|
||||
#endif // !linux
|
||||
#endif // !TARGET_IPHONE
|
||||
#else // TARGET_IPHONE
|
||||
{
|
||||
ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(24, 3));
|
||||
if (ImGui::Button("Rescan Content"))
|
||||
scanner.refresh();
|
||||
}
|
||||
#endif
|
||||
|
||||
OptionCheckbox("Box Art Game List", config::BoxartDisplayMode,
|
||||
"Display game cover art in the game list.");
|
||||
|
@ -2768,7 +2798,7 @@ static void gui_settings_advanced()
|
|||
{
|
||||
ImGui::InputText("Lua Filename", &config::LuaFileName.get(), ImGuiInputTextFlags_CharsNoBlank, nullptr, nullptr);
|
||||
ImGui::SameLine();
|
||||
ShowHelpMarker("Specify lua filename to use. Should be located in Flycast config directory. Defaults to flycast.lua when empty.");
|
||||
ShowHelpMarker("Specify lua filename to use. Should be located in Flycast config folder. Defaults to flycast.lua when empty.");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
@ -3167,27 +3197,10 @@ static void gui_display_content()
|
|||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ScaledVec2(8, 20));
|
||||
|
||||
int counter = 0;
|
||||
if (gui_state != GuiState::SelectDisk && filter.PassFilter("Dreamcast BIOS"))
|
||||
{
|
||||
ImguiID _("bios");
|
||||
bool pressed;
|
||||
if (config::BoxartDisplayMode)
|
||||
{
|
||||
GameMedia game;
|
||||
GameBoxart art = boxart.getBoxartAndLoad(game);
|
||||
ImguiFileTexture tex(art.boxartPath);
|
||||
pressed = gameImageButton(tex, "Dreamcast BIOS", responsiveBoxVec2, "Dreamcast BIOS");
|
||||
}
|
||||
else
|
||||
{
|
||||
pressed = ImGui::Selectable("Dreamcast BIOS");
|
||||
}
|
||||
if (pressed)
|
||||
gui_start_game("");
|
||||
counter++;
|
||||
}
|
||||
bool gameListEmpty = false;
|
||||
{
|
||||
scanner.get_mutex().lock();
|
||||
gameListEmpty = scanner.get_game_list().empty();
|
||||
for (const auto& game : scanner.get_game_list())
|
||||
{
|
||||
if (gui_state == GuiState::SelectDisk)
|
||||
|
@ -3197,6 +3210,9 @@ static void gui_display_content()
|
|||
&& extension != "cdi" && extension != "cue")
|
||||
// Only dreamcast disks
|
||||
continue;
|
||||
if (game.path.empty())
|
||||
// Dreamcast BIOS isn't a disk
|
||||
continue;
|
||||
}
|
||||
std::string gameName = game.name;
|
||||
GameBoxart art;
|
||||
|
@ -3207,7 +3223,7 @@ static void gui_display_content()
|
|||
}
|
||||
if (filter.PassFilter(gameName.c_str()))
|
||||
{
|
||||
ImguiID _(game.path.c_str());
|
||||
ImguiID _(game.path.empty() ? "bios" : game.path);
|
||||
bool pressed = false;
|
||||
if (config::BoxartDisplayMode)
|
||||
{
|
||||
|
@ -3256,7 +3272,28 @@ static void gui_display_content()
|
|||
}
|
||||
scanner.get_mutex().unlock();
|
||||
}
|
||||
bool addContent = false;
|
||||
#if !defined(TARGET_IPHONE)
|
||||
if (gameListEmpty && gui_state != GuiState::SelectDisk)
|
||||
{
|
||||
const char *label = "Your game list is empty";
|
||||
// center horizontally
|
||||
const float w = largeFont->CalcTextSizeA(largeFont->FontSize, FLT_MAX, -1.f, label).x + ImGui::GetStyle().FramePadding.x * 2;
|
||||
ImGui::SameLine((ImGui::GetContentRegionMax().x - w) / 2);
|
||||
if (ImGui::BeginChild("empty", ImVec2(0, 0), ImGuiChildFlags_AutoResizeX | ImGuiChildFlags_AutoResizeY, ImGuiWindowFlags_NavFlattened))
|
||||
{
|
||||
ImGui::PushFont(largeFont);
|
||||
ImGui::NewLine();
|
||||
ImGui::Text("%s", label);
|
||||
ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(20, 8));
|
||||
addContent = ImGui::Button("Add Game Folder");
|
||||
ImGui::PopFont();
|
||||
}
|
||||
ImGui::EndChild();
|
||||
}
|
||||
#endif
|
||||
ImGui::PopStyleVar();
|
||||
addContentPath(addContent);
|
||||
}
|
||||
scrollWhenDraggingOnVoid();
|
||||
windowDragScroll();
|
||||
|
@ -3281,7 +3318,7 @@ static bool systemdir_selected_callback(bool cancelled, std::string selection)
|
|||
if (!make_directory(data_path))
|
||||
{
|
||||
WARN_LOG(BOOT, "Cannot create 'data' directory: %s", data_path.c_str());
|
||||
gui_error("Invalid selection:\nFlycast cannot write to this directory.");
|
||||
gui_error("Invalid selection:\nFlycast cannot write to this folder.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -3292,7 +3329,7 @@ static bool systemdir_selected_callback(bool cancelled, std::string selection)
|
|||
if (file == nullptr)
|
||||
{
|
||||
WARN_LOG(BOOT, "Cannot write in the 'data' directory");
|
||||
gui_error("Invalid selection:\nFlycast cannot write to this directory.");
|
||||
gui_error("Invalid selection:\nFlycast cannot write to this folder.");
|
||||
return false;
|
||||
}
|
||||
fclose(file);
|
||||
|
@ -3320,7 +3357,7 @@ static bool systemdir_selected_callback(bool cancelled, std::string selection)
|
|||
|
||||
static void gui_display_onboarding()
|
||||
{
|
||||
const char *title = "Select Flycast Home Directory";
|
||||
const char *title = "Select Flycast Home Folder";
|
||||
ImGui::OpenPopup(title);
|
||||
select_file_popup(title, &systemdir_selected_callback);
|
||||
}
|
||||
|
@ -3644,7 +3681,7 @@ void gui_term()
|
|||
EventManager::unlisten(Event::Resume, emuEventCallback);
|
||||
EventManager::unlisten(Event::Start, emuEventCallback);
|
||||
EventManager::unlisten(Event::Terminate, emuEventCallback);
|
||||
gui_save();
|
||||
boxart.term();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3680,11 +3717,6 @@ void gui_error(const std::string& what)
|
|||
error_msg = what;
|
||||
}
|
||||
|
||||
void gui_save()
|
||||
{
|
||||
boxart.saveDatabase();
|
||||
}
|
||||
|
||||
void gui_loadState()
|
||||
{
|
||||
const LockGuard lock(guiMutex);
|
||||
|
@ -3742,11 +3774,15 @@ std::string gui_getCurGameBoxartUrl()
|
|||
return art.boxartUrl;
|
||||
}
|
||||
|
||||
void gui_runOnUiThread(std::function<void()> function) {
|
||||
uiThreadRunner.runOnThread(function);
|
||||
}
|
||||
|
||||
void gui_takeScreenshot()
|
||||
{
|
||||
if (!game_started)
|
||||
return;
|
||||
uiThreadRunner.runOnThread([]() {
|
||||
gui_runOnUiThread([]() {
|
||||
std::string date = timeToISO8601(time(nullptr));
|
||||
std::replace(date.begin(), date.end(), '/', '-');
|
||||
std::replace(date.begin(), date.end(), ':', '-');
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
#include "types.h"
|
||||
|
||||
#include <string>
|
||||
#include <functional>
|
||||
|
||||
void gui_init();
|
||||
void gui_initFonts();
|
||||
|
@ -47,11 +48,11 @@ void gui_stop_game(const std::string& message = "");
|
|||
void gui_start_game(const std::string& path);
|
||||
void gui_error(const std::string& what);
|
||||
void gui_setOnScreenKeyboardCallback(void (*callback)(bool show));
|
||||
void gui_save();
|
||||
void gui_loadState();
|
||||
void gui_saveState(bool stopRestart = true);
|
||||
std::string gui_getCurGameBoxartUrl();
|
||||
void gui_takeScreenshot();
|
||||
void gui_runOnUiThread(std::function<void()> function);
|
||||
|
||||
enum class GuiState {
|
||||
Closed,
|
||||
|
|
|
@ -75,7 +75,9 @@ static void addCheat()
|
|||
static void cheatFileSelected(bool cancelled, std::string path)
|
||||
{
|
||||
if (!cancelled)
|
||||
cheatManager.loadCheatFile(path);
|
||||
gui_runOnUiThread([path]() {
|
||||
cheatManager.loadCheatFile(path);
|
||||
});
|
||||
}
|
||||
|
||||
void gui_cheats()
|
||||
|
|
|
@ -54,6 +54,7 @@ void select_file_popup(const char *prompt, StringCallback callback,
|
|||
{
|
||||
fullScreenWindow(true);
|
||||
ImguiStyleVar _(ImGuiStyleVar_WindowRounding, 0);
|
||||
ImguiStyleVar _1(ImGuiStyleVar_FramePadding, ImVec2(4, 3)); // default
|
||||
|
||||
if (ImGui::BeginPopup(prompt, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize ))
|
||||
{
|
||||
|
@ -123,7 +124,7 @@ void select_file_popup(const char *prompt, StringCallback callback,
|
|||
|
||||
if (!select_current_directory.empty() && select_current_directory != "/")
|
||||
{
|
||||
if (ImGui::Selectable(".. Up to Parent Directory"))
|
||||
if (ImGui::Selectable(".. Up to Parent Folder"))
|
||||
{
|
||||
subfolders_read = false;
|
||||
select_current_directory = hostfs::storage().getParentPath(select_current_directory);
|
||||
|
@ -161,7 +162,7 @@ void select_file_popup(const char *prompt, StringCallback callback,
|
|||
ImGui::EndChild();
|
||||
if (!selectFile)
|
||||
{
|
||||
if (ImGui::Button("Select Current Directory", ScaledVec2(0, 30)))
|
||||
if (ImGui::Button("Select Current Folder", ScaledVec2(0, 30)))
|
||||
{
|
||||
if (callback(false, select_current_directory))
|
||||
{
|
||||
|
@ -809,12 +810,7 @@ std::future<ImguiStateTexture::LoadedPic> ImguiStateTexture::asyncLoad;
|
|||
bool ImguiStateTexture::exists()
|
||||
{
|
||||
std::string path = hostfs::getSavestatePath(config::SavestateSlot, false);
|
||||
try {
|
||||
hostfs::storage().getFileInfo(path);
|
||||
return true;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
return hostfs::storage().exists(path);
|
||||
}
|
||||
|
||||
ImTextureID ImguiStateTexture::getId()
|
||||
|
@ -1014,3 +1010,33 @@ bool Toast::draw()
|
|||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string middleEllipsis(const std::string& s, float width)
|
||||
{
|
||||
float tw = ImGui::CalcTextSize(s.c_str()).x;
|
||||
if (tw <= width)
|
||||
return s;
|
||||
std::string ellipsis;
|
||||
char buf[5];
|
||||
ImTextCharToUtf8(buf, ImGui::GetFont()->EllipsisChar);
|
||||
for (int i = 0; i < ImGui::GetFont()->EllipsisCharCount; i++)
|
||||
ellipsis += buf;
|
||||
|
||||
int l = s.length() / 2;
|
||||
int d = l;
|
||||
|
||||
while (true)
|
||||
{
|
||||
std::string ss = s.substr(0, l / 2) + ellipsis + s.substr(s.length() - l / 2 - (l & 1));
|
||||
tw = ImGui::CalcTextSize(ss.c_str()).x;
|
||||
if (tw == width)
|
||||
return ss;
|
||||
d /= 2;
|
||||
if (d == 0)
|
||||
return ss;
|
||||
if (tw > width)
|
||||
l -= d;
|
||||
else
|
||||
l += d;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -310,3 +310,5 @@ private:
|
|||
u64 endTime = 0;
|
||||
std::mutex mutex;
|
||||
};
|
||||
|
||||
std::string middleEllipsis(const std::string& s, float width);
|
||||
|
|
|
@ -19,13 +19,13 @@ def getSentryUrl = { ->
|
|||
android {
|
||||
namespace 'com.flycast.emulator'
|
||||
ndkVersion '23.2.8568313'
|
||||
compileSdk 29
|
||||
compileSdk 33
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.flycast.emulator"
|
||||
minSdk 16
|
||||
//noinspection ExpiredTargetSdkVersion
|
||||
targetSdk 29
|
||||
targetSdk 33
|
||||
versionCode 8
|
||||
versionName getVersionName()
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
@ -43,19 +43,26 @@ android {
|
|||
// avoid Error: Google Play requires that apps target API level 31 or higher.
|
||||
abortOnError false
|
||||
}
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file("../debug.keystore")
|
||||
}
|
||||
release {
|
||||
storeFile file("../playstore.jks")
|
||||
storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias 'uploadkey'
|
||||
keyPassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.debug
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
|
@ -86,4 +93,9 @@ dependencies {
|
|||
implementation libs.slf4j.android
|
||||
implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'], exclude: [])
|
||||
implementation libs.documentfile
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.5.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
androidTestImplementation 'androidx.test:core:1.5.0'
|
||||
androidTestImplementation 'junit:junit:4.12'
|
||||
}
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
package com.flycast.emulator;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.runner.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class AndroidStorageTest {
|
||||
public static final String TREE_URI = "content://com.android.externalstorage.documents/tree/primary%3AFlycast%2FROMS";
|
||||
|
||||
@Test
|
||||
public void test() {
|
||||
ActivityScenario<NativeGLActivity> scenario = ActivityScenario.launch(NativeGLActivity.class);
|
||||
scenario.onActivity(activity -> {
|
||||
try {
|
||||
// Configure storage
|
||||
AndroidStorage storage = activity.getStorage();
|
||||
String rootUri = TREE_URI;
|
||||
storage.setStorageDirectories(Arrays.asList(rootUri));
|
||||
|
||||
// Start test
|
||||
// exists (root)
|
||||
assertTrue(storage.exists(rootUri));
|
||||
// listContent (root)
|
||||
FileInfo[] kids = storage.listContent(rootUri);
|
||||
assertTrue(kids.length > 0);
|
||||
// getFileInfo (root)
|
||||
FileInfo info = storage.getFileInfo(rootUri);
|
||||
assertEquals(info.getPath(), rootUri);
|
||||
assertTrue(info.isDirectory());
|
||||
assertNotEquals(0, info.getUpdateTime());
|
||||
// getParentUri (root)
|
||||
// fails on lollipop_mr1, could be because parent folder (/Flycast) is also allowed
|
||||
assertEquals("", storage.getParentUri(rootUri));
|
||||
|
||||
boolean directoryDone = false;
|
||||
boolean fileDone = false;
|
||||
for (FileInfo file : kids) {
|
||||
if (file.isDirectory() && !directoryDone) {
|
||||
// getParentUri
|
||||
String parentUri = storage.getParentUri(file.getPath());
|
||||
// FIXME fails because getParentUri returns a docId, not a treeId
|
||||
//assertEquals(rootUri, parentUri);
|
||||
|
||||
// getSubPath (from root)
|
||||
String kidUri = storage.getSubPath(rootUri, file.getName());
|
||||
assertEquals(file.getPath(), kidUri);
|
||||
|
||||
// exists (folder)
|
||||
assertTrue(storage.exists(file.getPath()));
|
||||
|
||||
// getFileInfo (folder)
|
||||
info = storage.getFileInfo(file.getPath());
|
||||
assertEquals(file.getPath(), info.getPath());
|
||||
assertEquals(file.getName(), info.getName());
|
||||
assertTrue(info.isDirectory());
|
||||
assertNotEquals(0, info.getUpdateTime());
|
||||
assertTrue(info.isDirectory());
|
||||
|
||||
// listContent (from folder)
|
||||
FileInfo[] gdkids = storage.listContent(file.getPath());
|
||||
assertTrue(gdkids.length > 0);
|
||||
for (FileInfo sfile : gdkids) {
|
||||
if (!sfile.isDirectory()) {
|
||||
// openFile
|
||||
int fd = storage.openFile(sfile.getPath(), "r");
|
||||
assertNotEquals(-1, fd);
|
||||
// getSubPath (from folder)
|
||||
String uri = storage.getSubPath(file.getPath(), sfile.getName());
|
||||
assertEquals(sfile.getPath(), uri);
|
||||
// getParentUri (from file)
|
||||
uri = storage.getParentUri(sfile.getPath());
|
||||
assertEquals(file.getPath(), uri);
|
||||
// exists (doc)
|
||||
assertTrue(storage.exists(sfile.getPath()));
|
||||
// getFileInfo (doc)
|
||||
info = storage.getFileInfo(sfile.getPath());
|
||||
assertEquals(info.getPath(), sfile.getPath());
|
||||
assertEquals(info.getName(), sfile.getName());
|
||||
assertEquals(info.isDirectory(), sfile.isDirectory());
|
||||
assertNotEquals(0, info.getUpdateTime());
|
||||
assertFalse(info.isDirectory());
|
||||
} else {
|
||||
// getParentUri (from subfolder)
|
||||
String uri = storage.getParentUri(sfile.getPath());
|
||||
assertEquals(file.getPath(), uri);
|
||||
// exists (subfolder)
|
||||
assertTrue(storage.exists(sfile.getPath()));
|
||||
}
|
||||
}
|
||||
directoryDone = true;
|
||||
}
|
||||
if (!file.isDirectory() && !fileDone) {
|
||||
// getParentUri
|
||||
String parentUri = storage.getParentUri(file.getPath());
|
||||
// FIXME fails because getParentUri returns a docId, not a treeId
|
||||
//assertEquals(rootUri, parentUri);
|
||||
// getSubPath (from root)
|
||||
String kidUri = storage.getSubPath(rootUri, file.getName());
|
||||
assertEquals(file.getPath(), kidUri);
|
||||
// exists (file)
|
||||
assertTrue(storage.exists(file.getPath()));
|
||||
fileDone = true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//@Test
|
||||
public void testLargeFolder() {
|
||||
ActivityScenario<NativeGLActivity> scenario = ActivityScenario.launch(NativeGLActivity.class);
|
||||
scenario.onActivity(activity -> {
|
||||
try {
|
||||
// Configure storage
|
||||
AndroidStorage storage = activity.getStorage();
|
||||
String rootUri = TREE_URI;
|
||||
storage.setStorageDirectories(Arrays.asList(rootUri));
|
||||
|
||||
// list content
|
||||
String uri = storage.getSubPath(rootUri, "textures");
|
||||
uri = storage.getSubPath(uri, "T1401N");
|
||||
long t0 = System.currentTimeMillis();
|
||||
FileInfo[] kids = storage.listContent(uri);
|
||||
Log.d("testLargeFolder", "Got " + kids.length + " in " + (System.currentTimeMillis() - t0) + " ms");
|
||||
// Got 2307 in 119910 ms !!!
|
||||
// retrieving only uri in listContent: Got 2307 in 9007 ms
|
||||
// retrieving uri+isDir: Got 2307 in 62281 ms
|
||||
// manual listing: Got 2307 in 10212 ms
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -18,10 +18,12 @@
|
|||
*/
|
||||
package com.flycast.emulator;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentUris;
|
||||
import android.content.CursorLoader;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.media.MediaScannerConnection;
|
||||
|
@ -39,11 +41,15 @@ import java.io.File;
|
|||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class AndroidStorage {
|
||||
public static final int ADD_STORAGE_ACTIVITY_REQUEST = 15012010;
|
||||
public static final int EXPORT_HOME_ACTIVITY_REQUEST = 15012011;
|
||||
public static final int IMPORT_HOME_ACTIVITY_REQUEST = 15012012;
|
||||
|
||||
private Activity activity;
|
||||
|
||||
|
@ -61,8 +67,10 @@ public class AndroidStorage {
|
|||
|
||||
public native void init();
|
||||
public native void addStorageCallback(String path);
|
||||
public native void reloadConfig();
|
||||
|
||||
public void onAddStorageResult(Intent data) {
|
||||
public void onAddStorageResult(Intent data)
|
||||
{
|
||||
Uri uri = data == null ? null : data.getData();
|
||||
if (uri == null) {
|
||||
// Cancelled
|
||||
|
@ -70,15 +78,34 @@ public class AndroidStorage {
|
|||
}
|
||||
else {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
|
||||
activity.getContentResolver().takePersistableUriPermission(uri, storageIntentPerms);
|
||||
/* Use the uri path now to avoid issues when targeting sdk 30+ in the future
|
||||
String realPath = getRealPath(uri);
|
||||
// when targeting sdk 30+ (android 11+) using the real path doesn't work (empty content) -> *must* use the uri
|
||||
int targetSdkVersion = activity.getApplication().getApplicationInfo().targetSdkVersion;
|
||||
if (realPath != null && targetSdkVersion <= Build.VERSION_CODES.Q)
|
||||
addStorageCallback(realPath);
|
||||
else
|
||||
*/
|
||||
{
|
||||
try {
|
||||
activity.getContentResolver().takePersistableUriPermission(uri, storageIntentPerms);
|
||||
} catch (SecurityException e) {
|
||||
Log.w("Flycast", "takePersistableUriPermission failed", e);
|
||||
AlertDialog.Builder dlgAlert = new AlertDialog.Builder(activity);
|
||||
dlgAlert.setMessage("Can't get permissions to access this folder.\nPlease select a different one.");
|
||||
dlgAlert.setTitle("Storage Error");
|
||||
dlgAlert.setPositiveButton("Ok",
|
||||
new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog,int id) {
|
||||
addStorageCallback(null);
|
||||
}
|
||||
});
|
||||
dlgAlert.setIcon(android.R.drawable.ic_dialog_alert);
|
||||
dlgAlert.setCancelable(false);
|
||||
dlgAlert.create().show();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
String realPath = getRealPath(uri);
|
||||
if (realPath != null) {
|
||||
addStorageCallback(realPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
addStorageCallback(uri.toString());
|
||||
}
|
||||
}
|
||||
|
@ -88,18 +115,77 @@ public class AndroidStorage {
|
|||
return pfd.detachFd();
|
||||
}
|
||||
|
||||
public FileInfo[] listContent(String uri) {
|
||||
DocumentFile docFile = DocumentFile.fromTreeUri(activity, Uri.parse(uri));
|
||||
DocumentFile kids[] = docFile.listFiles();
|
||||
FileInfo ret[] = new FileInfo[kids.length];
|
||||
for (int i = 0; i < kids.length; i++) {
|
||||
ret[i] = new FileInfo();
|
||||
ret[i].setName(kids[i].getName());
|
||||
ret[i].setPath(kids[i].getUri().toString());
|
||||
ret[i].setDirectory(kids[i].isDirectory());
|
||||
}
|
||||
return ret;
|
||||
public InputStream openInputStream(String uri) throws FileNotFoundException {
|
||||
return activity.getContentResolver().openInputStream(Uri.parse(uri));
|
||||
}
|
||||
public OutputStream openOutputStream(String parent, String name) throws FileNotFoundException {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
|
||||
throw new UnsupportedOperationException("not supported");
|
||||
Uri uri = Uri.parse(parent);
|
||||
String subpath = getSubPath(parent, name);
|
||||
if (!exists(subpath)) {
|
||||
String documentId;
|
||||
if (DocumentsContract.isDocumentUri(activity, uri))
|
||||
documentId = DocumentsContract.getDocumentId(uri);
|
||||
else
|
||||
documentId = DocumentsContract.getTreeDocumentId(uri);
|
||||
uri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId);
|
||||
uri = DocumentsContract.createDocument(activity.getContentResolver(), uri,
|
||||
"application/octet-stream", name);
|
||||
}
|
||||
else {
|
||||
uri = Uri.parse(subpath);
|
||||
}
|
||||
return activity.getContentResolver().openOutputStream(uri);
|
||||
}
|
||||
|
||||
public FileInfo[] listContent(String uri)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
|
||||
throw new UnsupportedOperationException("listContent unsupported");
|
||||
Uri treeUri = Uri.parse(uri);
|
||||
String documentId;
|
||||
if (DocumentsContract.isDocumentUri(activity, treeUri))
|
||||
documentId = DocumentsContract.getDocumentId(treeUri);
|
||||
else
|
||||
documentId = DocumentsContract.getTreeDocumentId(treeUri);
|
||||
Uri docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId);
|
||||
final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(docUri,
|
||||
DocumentsContract.getDocumentId(docUri));
|
||||
final ArrayList<FileInfo> results = new ArrayList<>();
|
||||
|
||||
Cursor c = null;
|
||||
try {
|
||||
final ContentResolver resolver = activity.getContentResolver();
|
||||
c = resolver.query(childrenUri, new String[] {
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE }, null, null, null);
|
||||
while (c.moveToNext())
|
||||
{
|
||||
final String childId = c.getString(0);
|
||||
final Uri childUri = DocumentsContract.buildDocumentUriUsingTree(docUri, childId);
|
||||
FileInfo info = new FileInfo();
|
||||
info.setPath(childUri.toString());
|
||||
info.setName(c.getString(1));
|
||||
info.setDirectory(DocumentsContract.Document.MIME_TYPE_DIR.equals(c.getString(2)));
|
||||
results.add(info);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w("Flycast", "Failed query: " + e);
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (c != null) {
|
||||
try {
|
||||
c.close();
|
||||
} catch (RuntimeException rethrown) {
|
||||
throw rethrown;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.toArray(new FileInfo[results.size()]);
|
||||
}
|
||||
|
||||
public String getParentUri(String uriString) throws FileNotFoundException {
|
||||
if (uriString.isEmpty())
|
||||
|
@ -125,24 +211,27 @@ public class AndroidStorage {
|
|||
return uriString.substring(0, i);
|
||||
}
|
||||
|
||||
public String getSubPath(String reference, String relative) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
Uri refUri = Uri.parse(reference);
|
||||
String docId = DocumentsContract.getDocumentId(refUri);
|
||||
String ret;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
|
||||
ret = DocumentsContract.buildDocumentUriUsingTree(refUri, docId + "/" + relative).toString();
|
||||
else
|
||||
ret = DocumentsContract.buildDocumentUri(refUri.getAuthority(), docId + "/" + relative).toString();
|
||||
return ret;
|
||||
}
|
||||
else {
|
||||
public String getSubPath(String reference, String relative)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT)
|
||||
throw new UnsupportedOperationException("getSubPath unsupported");
|
||||
Uri refUri = Uri.parse(reference);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
String docId;
|
||||
if (DocumentsContract.isDocumentUri(activity, refUri))
|
||||
docId = DocumentsContract.getDocumentId(refUri);
|
||||
else
|
||||
docId = DocumentsContract.getTreeDocumentId(refUri);
|
||||
return DocumentsContract.buildDocumentUriUsingTree(refUri, docId + "/" + relative).toString();
|
||||
}
|
||||
String docId = DocumentsContract.getDocumentId(refUri);
|
||||
return DocumentsContract.buildDocumentUri(refUri.getAuthority(), docId + "/" + relative).toString();
|
||||
}
|
||||
|
||||
public FileInfo getFileInfo(String uriString) throws FileNotFoundException {
|
||||
public FileInfo getFileInfo(String uriString) throws FileNotFoundException
|
||||
{
|
||||
Uri uri = Uri.parse(uriString);
|
||||
// FIXME < Build.VERSION_CODES.LOLLIPOP
|
||||
DocumentFile docFile = DocumentFile.fromTreeUri(activity, uri);
|
||||
if (!docFile.exists())
|
||||
throw new FileNotFoundException(uriString);
|
||||
|
@ -152,11 +241,55 @@ public class AndroidStorage {
|
|||
info.setDirectory(docFile.isDirectory());
|
||||
info.setSize(docFile.length());
|
||||
info.setWritable(docFile.canWrite());
|
||||
info.setUpdateTime(docFile.lastModified() / 1000);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
public void addStorage(boolean isDirectory, boolean writeAccess) {
|
||||
public boolean exists(String uriString)
|
||||
{
|
||||
Uri uri = Uri.parse(uriString);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
if (!DocumentsContract.isDocumentUri(activity, uri))
|
||||
{
|
||||
String documentId = DocumentsContract.getTreeDocumentId(uri);
|
||||
uri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId);
|
||||
}
|
||||
}
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = activity.getContentResolver().query(uri, new String[]{ DocumentsContract.Document.COLUMN_DISPLAY_NAME },
|
||||
null, null, null);
|
||||
boolean ret = cursor != null && cursor.moveToNext();
|
||||
return ret;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
public String mkdir(String parent, String name) throws FileNotFoundException
|
||||
{
|
||||
Uri parentUri = Uri.parse(parent);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
if (!DocumentsContract.isDocumentUri(activity, parentUri)) {
|
||||
String documentId = DocumentsContract.getTreeDocumentId(parentUri);
|
||||
parentUri = DocumentsContract.buildDocumentUriUsingTree(parentUri, documentId);
|
||||
}
|
||||
Uri newDirUri = DocumentsContract.createDocument(activity.getContentResolver(), parentUri, DocumentsContract.Document.MIME_TYPE_DIR, name);
|
||||
return newDirUri.toString();
|
||||
}
|
||||
File dir = new File(parent, name);
|
||||
dir.mkdir();
|
||||
return dir.getAbsolutePath();
|
||||
}
|
||||
|
||||
public boolean addStorage(boolean isDirectory, boolean writeAccess)
|
||||
{
|
||||
if (isDirectory && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
|
||||
return false;
|
||||
Intent intent = new Intent(isDirectory ? Intent.ACTION_OPEN_DOCUMENT_TREE : Intent.ACTION_OPEN_DOCUMENT);
|
||||
if (!isDirectory) {
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
|
@ -164,11 +297,13 @@ public class AndroidStorage {
|
|||
intent = Intent.createChooser(intent, "Select a cheat file");
|
||||
}
|
||||
else {
|
||||
intent = Intent.createChooser(intent, "Select a content directory");
|
||||
intent = Intent.createChooser(intent, "Select a content folder");
|
||||
}
|
||||
storageIntentPerms = Intent.FLAG_GRANT_READ_URI_PERMISSION | (writeAccess ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : 0);
|
||||
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | storageIntentPerms);
|
||||
activity.startActivityForResult(intent, ADD_STORAGE_ACTIVITY_REQUEST);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private String getRealPath(final Uri uri) {
|
||||
|
@ -180,14 +315,15 @@ public class AndroidStorage {
|
|||
|
||||
// From https://github.com/HBiSoft/PickiT
|
||||
// Copyright (c) [2020] [HBiSoft]
|
||||
@SuppressLint("NewApi")
|
||||
String getRealPathFromURI_API19(final Uri uri)
|
||||
{
|
||||
final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
|
||||
|
||||
if (isKitKat && (DocumentsContract.isDocumentUri(activity, uri) || DocumentsContract.isTreeUri(uri))) {
|
||||
final boolean isTree = DocumentsContract.isTreeUri(uri);
|
||||
if (isExternalStorageDocument(uri)) {
|
||||
final boolean isTree = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && DocumentsContract.isTreeUri(uri);
|
||||
if (isKitKat && (DocumentsContract.isDocumentUri(activity, uri) || isTree))
|
||||
{
|
||||
if (isExternalStorageDocument(uri))
|
||||
{
|
||||
final String docId = isTree ? DocumentsContract.getTreeDocumentId(uri) : DocumentsContract.getDocumentId(uri);
|
||||
final String[] split = docId.split(":");
|
||||
final String type = split[0];
|
||||
|
@ -426,4 +562,41 @@ public class AndroidStorage {
|
|||
throw new RuntimeException(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void exportHomeDirectory()
|
||||
{
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent = Intent.createChooser(intent, "Select an export folder");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||
activity.startActivityForResult(intent, EXPORT_HOME_ACTIVITY_REQUEST);
|
||||
}
|
||||
|
||||
public void onExportHomeResult(Intent data)
|
||||
{
|
||||
Uri uri = data == null ? null : data.getData();
|
||||
if (uri == null)
|
||||
// Cancelled
|
||||
return;
|
||||
HomeMover mover = new HomeMover(activity, this);
|
||||
mover.copyHome(activity.getExternalFilesDir(null).toURI().toString(), uri.toString(), "Exporting home folder");
|
||||
}
|
||||
|
||||
public void importHomeDirectory()
|
||||
{
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent = Intent.createChooser(intent, "Select an import folder");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
activity.startActivityForResult(intent, IMPORT_HOME_ACTIVITY_REQUEST);
|
||||
}
|
||||
|
||||
public void onImportHomeResult(Intent data)
|
||||
{
|
||||
Uri uri = data == null ? null : data.getData();
|
||||
if (uri == null)
|
||||
// Cancelled
|
||||
return;
|
||||
HomeMover mover = new HomeMover(activity, this);
|
||||
mover.setReloadConfigOnCompletion(true);
|
||||
mover.copyHome(uri.toString(), activity.getExternalFilesDir(null).toURI().toString(), "Importing home folder");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -138,10 +138,11 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat.
|
|||
//Log.i("flycast", "External storage legacy: " + (externalStorageLegacy ? "preserved" : "lost"));
|
||||
}
|
||||
if (!storagePermissionGranted) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || !externalStorageLegacy)
|
||||
// No permission needed before Android 6
|
||||
// Permissions only needed in legacy external storage mode
|
||||
storagePermissionGranted = true;
|
||||
else if (externalStorageLegacy) {
|
||||
else {
|
||||
Log.i("flycast", "Asking for external storage permission");
|
||||
ActivityCompat.requestPermissions(this,
|
||||
new String[]{
|
||||
|
@ -193,6 +194,11 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat.
|
|||
JNIdc.setExternalStorageDirectories(pathList.toArray());
|
||||
}
|
||||
|
||||
// Testing
|
||||
public AndroidStorage getStorage() {
|
||||
return storage;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
@ -265,7 +271,10 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat.
|
|||
}
|
||||
@Override
|
||||
public boolean onGenericMotionEvent(MotionEvent event) {
|
||||
if ((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == InputDevice.SOURCE_CLASS_JOYSTICK && event.getAction() == MotionEvent.ACTION_MOVE) {
|
||||
if ((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == InputDevice.SOURCE_CLASS_JOYSTICK
|
||||
&& event.getAction() == MotionEvent.ACTION_MOVE
|
||||
&& event.getDevice() != null)
|
||||
{
|
||||
List<InputDevice.MotionRange> axes = event.getDevice().getMotionRanges();
|
||||
boolean rc = false;
|
||||
for (InputDevice.MotionRange range : axes)
|
||||
|
@ -411,9 +420,11 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat.
|
|||
|
||||
}
|
||||
|
||||
private String getDefaultHomeDir()
|
||||
{
|
||||
return getExternalFilesDir(null).getAbsolutePath();
|
||||
private String getDefaultHomeDir() {
|
||||
File dir = getExternalFilesDir(null);
|
||||
if (dir == null)
|
||||
dir = getFilesDir();
|
||||
return dir.getAbsolutePath();
|
||||
}
|
||||
|
||||
private String checkHomeDirectory(String homeDir)
|
||||
|
@ -421,7 +432,8 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat.
|
|||
if (homeDir.isEmpty())
|
||||
// home dir not set: use default
|
||||
return getDefaultHomeDir();
|
||||
if (homeDir.startsWith(getDefaultHomeDir()))
|
||||
// must account for the fact that homeDir may be on internal storage but external storage is now available
|
||||
if (homeDir.startsWith(getDefaultHomeDir()) || homeDir.startsWith(getFilesDir().getAbsolutePath()))
|
||||
// home dir is ok
|
||||
return homeDir;
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P)
|
||||
|
@ -556,8 +568,18 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat.
|
|||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == AndroidStorage.ADD_STORAGE_ACTIVITY_REQUEST)
|
||||
storage.onAddStorageResult(data);
|
||||
switch (requestCode)
|
||||
{
|
||||
case AndroidStorage.ADD_STORAGE_ACTIVITY_REQUEST:
|
||||
storage.onAddStorageResult(data);
|
||||
break;
|
||||
case AndroidStorage.IMPORT_HOME_ACTIVITY_REQUEST:
|
||||
storage.onImportHomeResult(data);
|
||||
break;
|
||||
case AndroidStorage.EXPORT_HOME_ACTIVITY_REQUEST:
|
||||
storage.onExportHomeResult(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static native void register(BaseGLActivity activity);
|
||||
|
|
|
@ -22,7 +22,7 @@ public class Emulator extends Application {
|
|||
public static final int MDT_Microphone = 2;
|
||||
public static final int MDT_None = 8;
|
||||
|
||||
public static int vibrationDuration = 20;
|
||||
public static int vibrationPower = 80;
|
||||
|
||||
public static int[] maple_devices = {
|
||||
MDT_None,
|
||||
|
@ -42,7 +42,7 @@ public class Emulator extends Application {
|
|||
*
|
||||
*/
|
||||
public void getConfigurationPrefs() {
|
||||
Emulator.vibrationDuration = JNIdc.getVirtualGamepadVibration();
|
||||
Emulator.vibrationPower = JNIdc.getVirtualGamepadVibration();
|
||||
JNIdc.getControllers(maple_devices, maple_expansion_devices);
|
||||
}
|
||||
|
||||
|
@ -54,7 +54,7 @@ public class Emulator extends Application {
|
|||
{
|
||||
Log.i("flycast", "SaveAndroidSettings: saving preferences");
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
Emulator.vibrationDuration = JNIdc.getVirtualGamepadVibration();
|
||||
Emulator.vibrationPower = JNIdc.getVirtualGamepadVibration();
|
||||
JNIdc.getControllers(maple_devices, maple_expansion_devices);
|
||||
|
||||
prefs.edit()
|
||||
|
|
|
@ -59,9 +59,18 @@ public class FileInfo {
|
|||
this.size = size;
|
||||
}
|
||||
|
||||
public long getUpdateTime() {
|
||||
return updateTime;
|
||||
}
|
||||
|
||||
public void setUpdateTime(long updateTime) {
|
||||
this.updateTime = updateTime;
|
||||
}
|
||||
|
||||
String name;
|
||||
String path;
|
||||
boolean isDirectory;
|
||||
boolean isWritable;
|
||||
long size;
|
||||
long updateTime;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,244 @@
|
|||
/*
|
||||
Copyright 2024 flyinghead
|
||||
|
||||
This file is part of Flycast.
|
||||
|
||||
Flycast is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Flycast is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Flycast. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.flycast.emulator;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.ProgressDialog;
|
||||
import android.content.DialogInterface;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class HomeMover {
|
||||
private Activity activity;
|
||||
private AndroidStorage storage;
|
||||
private StorageWrapper wrapper;
|
||||
private boolean migrationThreadCancelled = false;
|
||||
|
||||
private boolean reloadConfigOnCompletion = false;
|
||||
|
||||
private class StorageWrapper
|
||||
{
|
||||
private File getFile(String path) {
|
||||
Uri uri = Uri.parse(path);
|
||||
if (uri.getScheme().equals("file"))
|
||||
return new File(uri.getPath());
|
||||
else
|
||||
return null;
|
||||
}
|
||||
public String getSubPath(String parent, String kid)
|
||||
{
|
||||
File f = getFile(parent);
|
||||
if (f != null)
|
||||
return new File(f, kid).toURI().toString();
|
||||
else {
|
||||
try {
|
||||
return storage.getSubPath(parent, kid);
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public FileInfo[] listContent(String folder)
|
||||
{
|
||||
File dir = getFile(folder);
|
||||
if (dir != null)
|
||||
{
|
||||
File[] files = dir.listFiles();
|
||||
List<FileInfo> ret = new ArrayList<>(files.length);
|
||||
for (File f : files) {
|
||||
FileInfo info = new FileInfo();
|
||||
info.setName(f.getName());
|
||||
info.setDirectory(f.isDirectory());
|
||||
info.setPath(f.toURI().toString());
|
||||
ret.add(info);
|
||||
}
|
||||
return ret.toArray(new FileInfo[ret.size()]);
|
||||
}
|
||||
else {
|
||||
return storage.listContent(folder);
|
||||
}
|
||||
}
|
||||
|
||||
public InputStream openInputStream(String path) throws FileNotFoundException {
|
||||
File file = getFile(path);
|
||||
if (file != null)
|
||||
return new FileInputStream(file);
|
||||
else
|
||||
return storage.openInputStream(path);
|
||||
}
|
||||
|
||||
public OutputStream openOutputStream(String parent, String name) throws FileNotFoundException {
|
||||
File file = getFile(parent);
|
||||
if (file != null)
|
||||
return new FileOutputStream(new File(file, name));
|
||||
else
|
||||
return storage.openOutputStream(parent, name);
|
||||
}
|
||||
|
||||
public boolean exists(String path) {
|
||||
if (path == null)
|
||||
return false;
|
||||
File file = getFile(path);
|
||||
if (file != null)
|
||||
return file.exists();
|
||||
else
|
||||
return storage.exists(path);
|
||||
}
|
||||
|
||||
public String mkdir(String parent, String name) throws FileNotFoundException
|
||||
{
|
||||
File dir = getFile(parent);
|
||||
if (dir != null)
|
||||
{
|
||||
File subfolder = new File(dir, name);
|
||||
subfolder.mkdir();
|
||||
return subfolder.toURI().toString();
|
||||
}
|
||||
else {
|
||||
return storage.mkdir(parent, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public HomeMover(Activity activity, AndroidStorage storage) {
|
||||
this.activity = activity;
|
||||
this.storage = storage;
|
||||
this.wrapper = new StorageWrapper();
|
||||
}
|
||||
|
||||
public void copyHome(String source, String dest, String message)
|
||||
{
|
||||
migrationThreadCancelled = false;
|
||||
ProgressDialog progress = new ProgressDialog(activity);
|
||||
progress.setTitle("Copying");
|
||||
progress.setMessage(message);
|
||||
progress.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
|
||||
progress.setMax(1);
|
||||
progress.setOnCancelListener(dialogInterface -> migrationThreadCancelled = true);
|
||||
progress.show();
|
||||
|
||||
Thread thread = new Thread(new Runnable() {
|
||||
private void copyFile(String path, String name, String toDir)
|
||||
{
|
||||
if (path == null)
|
||||
return;
|
||||
//Log.d("flycast", "Copying " + path + " to " + toDir);
|
||||
try {
|
||||
InputStream in = wrapper.openInputStream(path);
|
||||
OutputStream out = wrapper.openOutputStream(toDir, name);
|
||||
byte[] buf = new byte[8192];
|
||||
while (true) {
|
||||
int len = in.read(buf);
|
||||
if (len == -1)
|
||||
break;
|
||||
out.write(buf, 0, len);
|
||||
}
|
||||
out.close();
|
||||
in.close();
|
||||
} catch (Exception e) {
|
||||
Log.e("flycast", "Error copying " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyDir(String from, String toParent, String name)
|
||||
{
|
||||
//Log.d("flycast", "Copying folder " + from + " to " + toParent + " / " + name);
|
||||
if (!wrapper.exists(from))
|
||||
return;
|
||||
try {
|
||||
String to = wrapper.getSubPath(toParent, name);
|
||||
if (!wrapper.exists(to))
|
||||
to = wrapper.mkdir(toParent, name);
|
||||
|
||||
FileInfo[] files = wrapper.listContent(from);
|
||||
incrementMaxProgress(files.length);
|
||||
for (FileInfo file : files)
|
||||
{
|
||||
if (migrationThreadCancelled)
|
||||
break;
|
||||
if (!file.isDirectory())
|
||||
copyFile(file.path, file.name, to);
|
||||
else
|
||||
copyDir(file.path, to, file.getName());
|
||||
incrementProgress(1);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e("flycast", "Error copying folder " + from, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void migrate()
|
||||
{
|
||||
incrementMaxProgress(3);
|
||||
String path = wrapper.getSubPath(source, "emu.cfg");
|
||||
copyFile(path, "emu.cfg", dest);
|
||||
if (migrationThreadCancelled)
|
||||
return;
|
||||
incrementProgress(1);
|
||||
|
||||
String srcMappings = wrapper.getSubPath(source, "mappings");
|
||||
copyDir(srcMappings, dest, "mappings");
|
||||
if (migrationThreadCancelled)
|
||||
return;
|
||||
incrementProgress(1);
|
||||
|
||||
String srcData = wrapper.getSubPath(source, "data");
|
||||
copyDir(srcData, dest, "data");
|
||||
incrementProgress(1);
|
||||
}
|
||||
|
||||
private void incrementMaxProgress(int max) {
|
||||
activity.runOnUiThread(() -> {
|
||||
progress.setMax(progress.getMax() + max);
|
||||
});
|
||||
}
|
||||
private void incrementProgress(int i) {
|
||||
activity.runOnUiThread(() -> {
|
||||
progress.incrementProgressBy(i);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
migrate();
|
||||
activity.runOnUiThread(() -> {
|
||||
progress.dismiss();
|
||||
if (reloadConfigOnCompletion)
|
||||
storage.reloadConfig();
|
||||
});
|
||||
}
|
||||
});
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public void setReloadConfigOnCompletion(boolean reloadConfigOnCompletion) {
|
||||
this.reloadConfigOnCompletion = reloadConfigOnCompletion;
|
||||
}
|
||||
}
|
|
@ -147,13 +147,11 @@ public final class NativeGLActivity extends BaseGLActivity {
|
|||
}
|
||||
}
|
||||
|
||||
// Called from native code
|
||||
public void showTextInput(int x, int y, int w, int h) {
|
||||
private void showTextInput(int x, int y, int w, int h) {
|
||||
// Transfer the task to the main thread as a Runnable
|
||||
handler.post(new ShowTextInputTask(x, y, w, h));
|
||||
}
|
||||
|
||||
// Called from native code
|
||||
public void hideTextInput() {
|
||||
Log.d("flycast", "hideTextInput " + (mTextEdit != null ? "mTextEdit != null" : ""));
|
||||
if (mTextEdit != null) {
|
||||
|
@ -177,15 +175,22 @@ public final class NativeGLActivity extends BaseGLActivity {
|
|||
}
|
||||
|
||||
// Called from native code
|
||||
public boolean isScreenKeyboardShown() {
|
||||
if (mTextEdit == null)
|
||||
return false;
|
||||
public void showScreenKeyboard(boolean show) {
|
||||
handler.post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if (!show && (mTextEdit == null || !mScreenKeyboardShown))
|
||||
return;
|
||||
|
||||
if (!mScreenKeyboardShown)
|
||||
return false;
|
||||
|
||||
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
return imm.isAcceptingText();
|
||||
InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
if (show != imm.isAcceptingText()) {
|
||||
if (show)
|
||||
showTextInput(0, 0, 16, 100);
|
||||
else
|
||||
hideTextInput();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import android.view.View;
|
|||
import android.view.WindowInsets;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import com.flycast.emulator.BaseGLActivity;
|
||||
import com.flycast.emulator.Emulator;
|
||||
import com.flycast.emulator.periph.InputDeviceManager;
|
||||
|
||||
|
@ -126,7 +127,9 @@ public class NativeGLView extends SurfaceView implements SurfaceHolder.Callback
|
|||
Log.i("flycast", "NativeGLView.surfaceChanged: " + w + "x" + h);
|
||||
surfaceReady = true;
|
||||
JNIdc.rendinitNative(surfaceHolder.getSurface(), w, h);
|
||||
Emulator.getCurrentActivity().handleStateChange(false);
|
||||
BaseGLActivity activity = Emulator.getCurrentActivity();
|
||||
if (activity != null)
|
||||
activity.handleStateChange(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -134,7 +137,9 @@ public class NativeGLView extends SurfaceView implements SurfaceHolder.Callback
|
|||
Log.i("flycast", "NativeGLView.surfaceDestroyed");
|
||||
surfaceReady = false;
|
||||
JNIdc.rendinitNative(null, 0, 0);
|
||||
Emulator.getCurrentActivity().handleStateChange(true);
|
||||
BaseGLActivity activity = Emulator.getCurrentActivity();
|
||||
if (activity != null)
|
||||
activity.handleStateChange(true);
|
||||
}
|
||||
|
||||
public boolean isSurfaceReady() {
|
||||
|
|
|
@ -2,18 +2,15 @@ package com.flycast.emulator.emu;
|
|||
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.VibrationEffect;
|
||||
import android.os.Vibrator;
|
||||
import android.view.InputDevice;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.ScaleGestureDetector;
|
||||
import android.view.View;
|
||||
|
||||
import com.flycast.emulator.Emulator;
|
||||
import com.flycast.emulator.periph.InputDeviceManager;
|
||||
import com.flycast.emulator.periph.VJoy;
|
||||
import com.flycast.emulator.periph.VibratorThread;
|
||||
|
||||
public class VirtualJoystickDelegate {
|
||||
private VibratorThread vibratorThread;
|
||||
|
@ -41,19 +38,15 @@ public class VirtualJoystickDelegate {
|
|||
this.view = view;
|
||||
this.context = view.getContext();
|
||||
|
||||
vibratorThread = new VibratorThread(context);
|
||||
vibratorThread.start();
|
||||
vibratorThread = VibratorThread.getInstance();
|
||||
|
||||
readCustomVjoyValues();
|
||||
scaleGestureDetector = new ScaleGestureDetector(context, new OscOnScaleGestureListener());
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
vibratorThread.stopVibrator();
|
||||
try {
|
||||
vibratorThread.join();
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
vibratorThread.stopThread();
|
||||
vibratorThread = null;
|
||||
}
|
||||
|
||||
public void readCustomVjoyValues() {
|
||||
|
@ -230,7 +223,7 @@ public class VirtualJoystickDelegate {
|
|||
// Not for analog
|
||||
if (vjoy[j][5] == 0)
|
||||
if (!editVjoyMode) {
|
||||
vibratorThread.vibrate();
|
||||
vibratorThread.click();
|
||||
}
|
||||
vjoy[j][5] = 2;
|
||||
}
|
||||
|
@ -349,10 +342,8 @@ public class VirtualJoystickDelegate {
|
|||
}
|
||||
else
|
||||
{
|
||||
MotionEvent.PointerCoords pointerCoords = new MotionEvent.PointerCoords();
|
||||
event.getPointerCoords(0, pointerCoords);
|
||||
mouse_pos[0] = Math.round(pointerCoords.x);
|
||||
mouse_pos[1] = Math.round(pointerCoords.y);
|
||||
mouse_pos[0] = Math.round(event.getX());
|
||||
mouse_pos[1] = Math.round(event.getY());
|
||||
mouse_btns = MotionEvent.BUTTON_PRIMARY; // Mouse left button down
|
||||
}
|
||||
break;
|
||||
|
@ -360,10 +351,8 @@ public class VirtualJoystickDelegate {
|
|||
case MotionEvent.ACTION_MOVE:
|
||||
if (event.getPointerCount() == 1)
|
||||
{
|
||||
MotionEvent.PointerCoords pointerCoords = new MotionEvent.PointerCoords();
|
||||
event.getPointerCoords(0, pointerCoords);
|
||||
mouse_pos[0] = Math.round(pointerCoords.x);
|
||||
mouse_pos[1] = Math.round(pointerCoords.y);
|
||||
mouse_pos[0] = Math.round(event.getX());
|
||||
mouse_pos[1] = Math.round(event.getY());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -372,7 +361,7 @@ public class VirtualJoystickDelegate {
|
|||
InputDeviceManager.getInstance().virtualGamepadEvent(rv, joyx, joyy, left_trigger, right_trigger, fastForward);
|
||||
// Only register the mouse event if no virtual gamepad button is down
|
||||
if ((!editVjoyMode && rv == 0xFFFFFFFF && left_trigger == 0 && right_trigger == 0 && joyx == 0 && joyy == 0 && !fastForward)
|
||||
|| JNIdc.guiIsOpen())
|
||||
|| JNIdc.guiIsOpen())
|
||||
InputDeviceManager.getInstance().mouseEvent(mouse_pos[0], mouse_pos[1], mouse_btns);
|
||||
return(true);
|
||||
}
|
||||
|
@ -405,56 +394,4 @@ public class VirtualJoystickDelegate {
|
|||
selectedVjoyElement = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private class VibratorThread extends Thread
|
||||
{
|
||||
private Vibrator vibrator;
|
||||
private boolean vibrate = false;
|
||||
private boolean stopping = false;
|
||||
|
||||
VibratorThread(Context context) {
|
||||
vibrator = (Vibrator)context.getSystemService(Context.VIBRATOR_SERVICE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
while (!stopping) {
|
||||
boolean doVibrate;
|
||||
synchronized (this) {
|
||||
doVibrate = false;
|
||||
try {
|
||||
this.wait();
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
if (vibrate) {
|
||||
doVibrate = true;
|
||||
vibrate = false;
|
||||
}
|
||||
}
|
||||
if (doVibrate) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(Emulator.vibrationDuration, VibrationEffect.DEFAULT_AMPLITUDE));
|
||||
} else {
|
||||
vibrator.vibrate(Emulator.vibrationDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void stopVibrator() {
|
||||
synchronized (this) {
|
||||
stopping = true;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
public void vibrate() {
|
||||
if (Emulator.vibrationDuration > 0) {
|
||||
synchronized (this) {
|
||||
vibrate = true;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,10 @@ import android.view.InputDevice;
|
|||
import com.flycast.emulator.Emulator;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
|
||||
public final class InputDeviceManager implements InputManager.InputDeviceListener {
|
||||
|
@ -21,6 +24,13 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
|
|||
private InputManager inputManager;
|
||||
private int maple_port = 0;
|
||||
|
||||
private static class VibrationParams {
|
||||
float power;
|
||||
float inclination;
|
||||
long stopTime;
|
||||
}
|
||||
private Map<Integer, VibrationParams> vibParams = new HashMap<>();
|
||||
|
||||
public InputDeviceManager()
|
||||
{
|
||||
init();
|
||||
|
@ -30,7 +40,8 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
|
|||
{
|
||||
maple_port = 0;
|
||||
if (applicationContext.getPackageManager().hasSystemFeature("android.hardware.touchscreen"))
|
||||
joystickAdded(VIRTUAL_GAMEPAD_ID, "Virtual Gamepad", 0, "virtual_gamepad_uid", new int[0], new int[0]);
|
||||
joystickAdded(VIRTUAL_GAMEPAD_ID, "Virtual Gamepad", 0, "virtual_gamepad_uid",
|
||||
new int[0], new int[0], getVibrator(VIRTUAL_GAMEPAD_ID) != null);
|
||||
int[] ids = InputDevice.getDeviceIds();
|
||||
for (int id : ids)
|
||||
onInputDeviceAdded(id);
|
||||
|
@ -50,7 +61,7 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
|
|||
@Override
|
||||
public void onInputDeviceAdded(int i) {
|
||||
InputDevice device = InputDevice.getDevice(i);
|
||||
if ((device.getSources() & InputDevice.SOURCE_CLASS_BUTTON) == InputDevice.SOURCE_CLASS_BUTTON) {
|
||||
if (device != null && (device.getSources() & InputDevice.SOURCE_CLASS_BUTTON) == InputDevice.SOURCE_CLASS_BUTTON) {
|
||||
int port = 0;
|
||||
if ((device.getSources() & InputDevice.SOURCE_CLASS_JOYSTICK) == InputDevice.SOURCE_CLASS_JOYSTICK) {
|
||||
port = this.maple_port == 3 ? 3 : this.maple_port++;
|
||||
|
@ -65,7 +76,8 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
|
|||
fullAxes.add(range.getAxis());
|
||||
}
|
||||
joystickAdded(i, device.getName(), port, device.getDescriptor(),
|
||||
ArrayUtils.toPrimitive(fullAxes.toArray(new Integer[0])), ArrayUtils.toPrimitive(halfAxes.toArray(new Integer[0])));
|
||||
ArrayUtils.toPrimitive(fullAxes.toArray(new Integer[0])), ArrayUtils.toPrimitive(halfAxes.toArray(new Integer[0])),
|
||||
getVibrator(i) != null);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -80,34 +92,116 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
|
|||
public void onInputDeviceChanged(int i) {
|
||||
}
|
||||
|
||||
// Called from native code
|
||||
private boolean rumble(int i, float power, float inclination, int duration_ms) {
|
||||
Vibrator vibrator;
|
||||
private Vibrator getVibrator(int i) {
|
||||
if (i == VIRTUAL_GAMEPAD_ID) {
|
||||
vibrator = (Vibrator)Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE);
|
||||
return (Vibrator)Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE);
|
||||
}
|
||||
else {
|
||||
InputDevice device = InputDevice.getDevice(i);
|
||||
if (device == null)
|
||||
return false;
|
||||
vibrator = device.getVibrator();
|
||||
if (!vibrator.hasVibrator())
|
||||
return false;
|
||||
return null;
|
||||
Vibrator vibrator = device.getVibrator();
|
||||
return vibrator.hasVibrator() ? vibrator : null;
|
||||
}
|
||||
}
|
||||
|
||||
private void vibrate(Vibrator vibrator, long duration_ms, float power)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
int ipow = Math.min((int)(power * 255), 255);
|
||||
if (ipow >= 1)
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(duration_ms, ipow));
|
||||
else
|
||||
vibrator.cancel();
|
||||
}
|
||||
else
|
||||
vibrator.vibrate(duration_ms);
|
||||
}
|
||||
|
||||
// Called from native code
|
||||
// returns false if the device has no vibrator
|
||||
private boolean rumble(int i, float power, float inclination, int duration_ms)
|
||||
{
|
||||
Vibrator vibrator = getVibrator(i);
|
||||
if (vibrator == null)
|
||||
return false;
|
||||
if (i == VIRTUAL_GAMEPAD_ID) {
|
||||
if (Emulator.vibrationPower == 0)
|
||||
return true;
|
||||
power *= Emulator.vibrationPower / 100.f;
|
||||
}
|
||||
|
||||
if (power == 0) {
|
||||
vibrator.cancel();
|
||||
} else {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(duration_ms, VibrationEffect.DEFAULT_AMPLITUDE));
|
||||
} else {
|
||||
vibrator.vibrate(duration_ms);
|
||||
VibrationParams params;
|
||||
synchronized (this) {
|
||||
params = vibParams.get(i);
|
||||
if (params == null) {
|
||||
params = new VibrationParams();
|
||||
vibParams.put(i, params);
|
||||
}
|
||||
}
|
||||
if (power != 0) {
|
||||
params.stopTime = System.currentTimeMillis() + duration_ms;
|
||||
if (inclination > 0)
|
||||
params.inclination = inclination * power;
|
||||
else
|
||||
params.inclination = 0;
|
||||
}
|
||||
params.power = power;
|
||||
VibratorThread.getInstance().setVibrating();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean updateRumble()
|
||||
{
|
||||
List<Integer> ids;
|
||||
synchronized (this) {
|
||||
ids = new ArrayList<Integer>(vibParams.keySet());
|
||||
}
|
||||
boolean active = false;
|
||||
for (int id : ids) {
|
||||
if (updateRumble(id))
|
||||
active = true;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
private boolean updateRumble(int i)
|
||||
{
|
||||
Vibrator vibrator = getVibrator(i);
|
||||
VibrationParams params;
|
||||
synchronized (this) {
|
||||
params = vibParams.get(i);
|
||||
}
|
||||
if (vibrator == null || params == null)
|
||||
return false;
|
||||
long remTime = params.stopTime - System.currentTimeMillis();
|
||||
if (remTime <= 0 || params.power == 0) {
|
||||
params.power = 0;
|
||||
params.inclination = 0;
|
||||
vibrator.cancel();
|
||||
return false;
|
||||
}
|
||||
if (params.inclination > 0)
|
||||
vibrate(vibrator, remTime, params.inclination * remTime);
|
||||
else
|
||||
vibrate(vibrator, remTime, params.power);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void stopRumble()
|
||||
{
|
||||
List<Integer> ids;
|
||||
synchronized (this) {
|
||||
ids = new ArrayList<Integer>(vibParams.keySet());
|
||||
}
|
||||
for (int id : ids) {
|
||||
Vibrator vibrator = getVibrator(id);
|
||||
if (vibrator != null)
|
||||
vibrator.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public static InputDeviceManager getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
@ -118,7 +212,7 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene
|
|||
public native boolean joystickAxisEvent(int id, int button, int value);
|
||||
public native void mouseEvent(int xpos, int ypos, int buttons);
|
||||
public native void mouseScrollEvent(int scrollValue);
|
||||
private native void joystickAdded(int id, String name, int maple_port, String uniqueId, int[] fullAxes, int[] halfAxes);
|
||||
private native void joystickAdded(int id, String name, int maple_port, String uniqueId, int[] fullAxes, int[] halfAxes, boolean rumbleEnabled);
|
||||
private native void joystickRemoved(int id);
|
||||
public native boolean keyboardEvent(int key, boolean pressed);
|
||||
public native void keyboardText(int c);
|
||||
|
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
Copyright 2024 flyinghead
|
||||
|
||||
This file is part of Flycast.
|
||||
|
||||
Flycast is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Flycast is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with Flycast. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.flycast.emulator.periph;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.VibrationEffect;
|
||||
import android.os.Vibrator;
|
||||
import android.view.InputDevice;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.flycast.emulator.Emulator;
|
||||
|
||||
public class VibratorThread extends Thread {
|
||||
private boolean stopping = false;
|
||||
private boolean click = false;
|
||||
private long nextRumbleUpdate = 0;
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private VibrationEffect clickEffect = null;
|
||||
int clickDuration = 0;
|
||||
private static VibratorThread INSTANCE = null;
|
||||
private static final int LEGACY_VIBRATION_DURATION = 20; // ms
|
||||
|
||||
public static VibratorThread getInstance() {
|
||||
synchronized (VibratorThread.class) {
|
||||
if (INSTANCE == null)
|
||||
INSTANCE = new VibratorThread();
|
||||
}
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private VibratorThread() {
|
||||
start();
|
||||
}
|
||||
|
||||
private Vibrator getVibrator(int i)
|
||||
{
|
||||
if (i == InputDeviceManager.VIRTUAL_GAMEPAD_ID) {
|
||||
if (Emulator.vibrationPower > 0)
|
||||
return (Vibrator) Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE);
|
||||
else
|
||||
// vibration disabled
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
InputDevice device = InputDevice.getDevice(i);
|
||||
if (device == null)
|
||||
return null;
|
||||
Vibrator vibrator = device.getVibrator();
|
||||
return vibrator.hasVibrator() ? vibrator : null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
while (!stopping)
|
||||
{
|
||||
boolean doClick = false;
|
||||
synchronized (this) {
|
||||
try {
|
||||
if (nextRumbleUpdate != 0) {
|
||||
long waitTime = nextRumbleUpdate - System.currentTimeMillis();
|
||||
if (waitTime > 0)
|
||||
this.wait(waitTime);
|
||||
}
|
||||
else {
|
||||
this.wait();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
if (click) {
|
||||
doClick = true;
|
||||
click = false;
|
||||
}
|
||||
}
|
||||
if (doClick)
|
||||
doClick();
|
||||
if (nextRumbleUpdate != 0 && nextRumbleUpdate - System.currentTimeMillis() < 5) {
|
||||
if (!InputDeviceManager.getInstance().updateRumble())
|
||||
nextRumbleUpdate = 0;
|
||||
else
|
||||
nextRumbleUpdate = System.currentTimeMillis() + 16667;
|
||||
}
|
||||
}
|
||||
InputDeviceManager.getInstance().stopRumble();
|
||||
}
|
||||
|
||||
public void stopThread() {
|
||||
synchronized (this) {
|
||||
stopping = true;
|
||||
notify();
|
||||
}
|
||||
try {
|
||||
join();
|
||||
} catch (InterruptedException e) {
|
||||
}
|
||||
INSTANCE = null;
|
||||
}
|
||||
|
||||
public void click() {
|
||||
if (Emulator.vibrationPower > 0) {
|
||||
synchronized (this) {
|
||||
click = true;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void doClick()
|
||||
{
|
||||
Vibrator vibrator = getVibrator(InputDeviceManager.VIRTUAL_GAMEPAD_ID);
|
||||
if (vibrator == null)
|
||||
return;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
if (clickEffect == null)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
clickEffect = VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK);
|
||||
else
|
||||
clickEffect = VibrationEffect.createOneShot(LEGACY_VIBRATION_DURATION, VibrationEffect.DEFAULT_AMPLITUDE);
|
||||
}
|
||||
vibrator.vibrate(clickEffect);
|
||||
} else {
|
||||
vibrator.vibrate(LEGACY_VIBRATION_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
public void setVibrating()
|
||||
{
|
||||
// FIXME possible race condition
|
||||
synchronized (this) {
|
||||
nextRumbleUpdate = 1;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -71,9 +71,7 @@ static jobject g_activity;
|
|||
static jmethodID VJoyStartEditingMID;
|
||||
static jmethodID VJoyStopEditingMID;
|
||||
static jmethodID VJoyResetEditingMID;
|
||||
static jmethodID showTextInputMid;
|
||||
static jmethodID hideTextInputMid;
|
||||
static jmethodID isScreenKeyboardShownMid;
|
||||
static jmethodID showScreenKeyboardMid;
|
||||
static jmethodID onGameStateChangeMid;
|
||||
|
||||
static void emuEventCallback(Event event, void *)
|
||||
|
@ -290,7 +288,6 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_emu_JNIdc_pause(JNIE
|
|||
if (config::AutoSaveState)
|
||||
dc_savestate(config::SavestateSlot);
|
||||
}
|
||||
gui_save();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_emu_JNIdc_resume(JNIEnv *env,jobject obj)
|
||||
|
@ -496,22 +493,13 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceMa
|
|||
keyboard = std::make_shared<AndroidKeyboard>();
|
||||
GamepadDevice::Register(keyboard);
|
||||
gui_setOnScreenKeyboardCallback([](bool show) {
|
||||
if (g_activity == nullptr)
|
||||
return;
|
||||
JNIEnv *env = jni::env();
|
||||
if (show != env->CallBooleanMethod(g_activity, isScreenKeyboardShownMid))
|
||||
{
|
||||
INFO_LOG(INPUT, "show/hide keyboard %d", show);
|
||||
if (show)
|
||||
env->CallVoidMethod(g_activity, showTextInputMid, 0, 0, 16, 100);
|
||||
else
|
||||
env->CallVoidMethod(g_activity, hideTextInputMid);
|
||||
}
|
||||
if (g_activity != nullptr)
|
||||
jni::env()->CallVoidMethod(g_activity, showScreenKeyboardMid, show);
|
||||
});
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceManager_joystickAdded(JNIEnv *env, jobject obj, jint id, jstring name,
|
||||
jint maple_port, jstring junique_id, jintArray fullAxes, jintArray halfAxes)
|
||||
jint maple_port, jstring junique_id, jintArray fullAxes, jintArray halfAxes, jboolean hasRumble)
|
||||
{
|
||||
std::string joyname = jni::String(name, false);
|
||||
std::string unique_id = jni::String(junique_id, false);
|
||||
|
@ -520,6 +508,7 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceMa
|
|||
|
||||
std::shared_ptr<AndroidGamepadDevice> gamepad = std::make_shared<AndroidGamepadDevice>(maple_port, id, joyname.c_str(), unique_id.c_str(), full, half);
|
||||
AndroidGamepadDevice::AddAndroidGamepad(gamepad);
|
||||
gamepad->setRumbleEnabled(hasRumble);
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceManager_joystickRemoved(JNIEnv *env, jobject obj, jint id)
|
||||
{
|
||||
|
@ -582,20 +571,19 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceMa
|
|||
|
||||
extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_BaseGLActivity_register(JNIEnv *env, jobject obj, jobject activity)
|
||||
{
|
||||
if (g_activity != NULL)
|
||||
{
|
||||
if (g_activity != nullptr) {
|
||||
env->DeleteGlobalRef(g_activity);
|
||||
g_activity = NULL;
|
||||
g_activity = nullptr;
|
||||
}
|
||||
if (activity != NULL) {
|
||||
if (activity != nullptr)
|
||||
{
|
||||
g_activity = env->NewGlobalRef(activity);
|
||||
VJoyStartEditingMID = env->GetMethodID(env->GetObjectClass(activity), "VJoyStartEditing", "()V");
|
||||
VJoyStopEditingMID = env->GetMethodID(env->GetObjectClass(activity), "VJoyStopEditing", "(Z)V");
|
||||
VJoyResetEditingMID = env->GetMethodID(env->GetObjectClass(activity), "VJoyResetEditing", "()V");
|
||||
showTextInputMid = env->GetMethodID(env->GetObjectClass(activity), "showTextInput", "(IIII)V");
|
||||
hideTextInputMid = env->GetMethodID(env->GetObjectClass(activity), "hideTextInput", "()V");
|
||||
isScreenKeyboardShownMid = env->GetMethodID(env->GetObjectClass(activity), "isScreenKeyboardShown", "()Z");
|
||||
onGameStateChangeMid = env->GetMethodID(env->GetObjectClass(activity), "onGameStateChange", "(Z)V");
|
||||
jclass actClass = env->GetObjectClass(activity);
|
||||
VJoyStartEditingMID = env->GetMethodID(actClass, "VJoyStartEditing", "()V");
|
||||
VJoyStopEditingMID = env->GetMethodID(actClass, "VJoyStopEditing", "(Z)V");
|
||||
VJoyResetEditingMID = env->GetMethodID(actClass, "VJoyResetEditing", "()V");
|
||||
showScreenKeyboardMid = env->GetMethodID(actClass, "showScreenKeyboard", "(Z)V");
|
||||
onGameStateChangeMid = env->GetMethodID(actClass, "onGameStateChange", "(Z)V");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -106,7 +106,6 @@ public:
|
|||
if (id == VIRTUAL_GAMEPAD_ID)
|
||||
{
|
||||
input_mapper = std::make_shared<IdentityInputMapping>();
|
||||
rumbleEnabled = true;
|
||||
// hasAnalogStick = true; // TODO has an analog stick but input mapping isn't persisted
|
||||
}
|
||||
else
|
||||
|
@ -310,9 +309,14 @@ public:
|
|||
|
||||
void rumble(float power, float inclination, u32 duration_ms) override
|
||||
{
|
||||
power *= rumblePower / 100.f;
|
||||
jboolean has_vibrator = jni::env()->CallBooleanMethod(input_device_manager, input_device_manager_rumble, android_id, power, inclination, duration_ms);
|
||||
rumbleEnabled = has_vibrator;
|
||||
}
|
||||
void setRumbleEnabled(bool rumbleEnabled) {
|
||||
this->rumbleEnabled = rumbleEnabled;
|
||||
}
|
||||
|
||||
bool is_virtual_gamepad() override { return android_id == VIRTUAL_GAMEPAD_ID; }
|
||||
|
||||
bool hasHalfAxis(int axis) const { return std::find(halfAxes.begin(), halfAxes.end(), axis) != halfAxes.end(); }
|
||||
|
|
|
@ -37,8 +37,11 @@ public:
|
|||
jgetParentUri = env->GetMethodID(clazz, "getParentUri", "(Ljava/lang/String;)Ljava/lang/String;");
|
||||
jgetSubPath = env->GetMethodID(clazz, "getSubPath", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;");
|
||||
jgetFileInfo = env->GetMethodID(clazz, "getFileInfo", "(Ljava/lang/String;)Lcom/flycast/emulator/FileInfo;");
|
||||
jaddStorage = env->GetMethodID(clazz, "addStorage", "(ZZ)V");
|
||||
jexists = env->GetMethodID(clazz, "exists", "(Ljava/lang/String;)Z");
|
||||
jaddStorage = env->GetMethodID(clazz, "addStorage", "(ZZ)Z");
|
||||
jsaveScreenshot = env->GetMethodID(clazz, "saveScreenshot", "(Ljava/lang/String;[B)V");
|
||||
jimportHomeDirectory = env->GetMethodID(clazz, "importHomeDirectory", "()V");
|
||||
jexportHomeDirectory = env->GetMethodID(clazz, "exportHomeDirectory", "()V");
|
||||
}
|
||||
|
||||
bool isKnownPath(const std::string& path) override {
|
||||
|
@ -119,11 +122,25 @@ public:
|
|||
return fromJavaFileInfo(jinfo);
|
||||
}
|
||||
|
||||
void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) override
|
||||
bool exists(const std::string& uri) override
|
||||
{
|
||||
jni::env()->CallVoidMethod(jstorage, jaddStorage, isDirectory, writeAccess);
|
||||
jni::String juri(uri);
|
||||
bool ret = jni::env()->CallBooleanMethod(jstorage, jexists, (jstring)juri);
|
||||
try {
|
||||
checkException();
|
||||
return ret;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) override
|
||||
{
|
||||
bool ret = jni::env()->CallBooleanMethod(jstorage, jaddStorage, isDirectory, writeAccess);
|
||||
checkException();
|
||||
addStorageCallback = callback;
|
||||
if (ret)
|
||||
addStorageCallback = callback;
|
||||
return ret;
|
||||
}
|
||||
|
||||
void doStorageCallback(jstring path)
|
||||
|
@ -147,6 +164,16 @@ public:
|
|||
checkException();
|
||||
}
|
||||
|
||||
void importHomeDirectory() {
|
||||
jni::env()->CallVoidMethod(jstorage, jimportHomeDirectory);
|
||||
checkException();
|
||||
}
|
||||
|
||||
void exportHomeDirectory() {
|
||||
jni::env()->CallVoidMethod(jstorage, jexportHomeDirectory);
|
||||
checkException();
|
||||
}
|
||||
|
||||
private:
|
||||
void checkException()
|
||||
{
|
||||
|
@ -169,6 +196,7 @@ private:
|
|||
info.isDirectory = env->CallBooleanMethod(jinfo, jisDirectory);
|
||||
info.isWritable = env->CallBooleanMethod(jinfo, jisWritable);
|
||||
info.size = env->CallLongMethod(jinfo, jgetSize);
|
||||
info.updateTime = env->CallLongMethod(jinfo, jgetUpdateTime);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
@ -185,6 +213,7 @@ private:
|
|||
jisDirectory = env->GetMethodID(infoClass, "isDirectory", "()Z");
|
||||
jisWritable = env->GetMethodID(infoClass, "isWritable", "()Z");
|
||||
jgetSize = env->GetMethodID(infoClass, "getSize", "()J");
|
||||
jgetUpdateTime = env->GetMethodID(infoClass, "getUpdateTime", "()J");
|
||||
}
|
||||
|
||||
jobject jstorage;
|
||||
|
@ -194,13 +223,17 @@ private:
|
|||
jmethodID jaddStorage;
|
||||
jmethodID jgetSubPath;
|
||||
jmethodID jgetFileInfo;
|
||||
jmethodID jexists;
|
||||
jmethodID jsaveScreenshot;
|
||||
jmethodID jexportHomeDirectory;
|
||||
jmethodID jimportHomeDirectory;
|
||||
// FileInfo accessors lazily initialized to avoid having to load the class
|
||||
jmethodID jgetName = nullptr;
|
||||
jmethodID jgetPath = nullptr;
|
||||
jmethodID jisDirectory = nullptr;
|
||||
jmethodID jisWritable = nullptr;
|
||||
jmethodID jgetSize = nullptr;
|
||||
jmethodID jgetUpdateTime = nullptr;
|
||||
void (*addStorageCallback)(bool cancelled, std::string selectedPath);
|
||||
};
|
||||
|
||||
|
@ -217,6 +250,14 @@ void saveScreenshot(const std::string& name, const std::vector<u8>& data)
|
|||
return static_cast<AndroidStorage&>(customStorage()).saveScreenshot(name, data);
|
||||
}
|
||||
|
||||
void importHomeDirectory() {
|
||||
static_cast<AndroidStorage&>(customStorage()).importHomeDirectory();
|
||||
}
|
||||
|
||||
void exportHomeDirectory() {
|
||||
static_cast<AndroidStorage&>(customStorage()).exportHomeDirectory();
|
||||
}
|
||||
|
||||
} // namespace hostfs
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_AndroidStorage_addStorageCallback(JNIEnv *env, jobject obj, jstring path)
|
||||
|
@ -228,3 +269,14 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_AndroidStorage_init(
|
|||
{
|
||||
static_cast<hostfs::AndroidStorage&>(hostfs::customStorage()).init(env, jstorage);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_AndroidStorage_reloadConfig(JNIEnv *env)
|
||||
{
|
||||
if (cfgOpen())
|
||||
{
|
||||
const RenderType render = config::RendererType;
|
||||
config::Settings::instance().load(false);
|
||||
// Make sure the renderer type doesn't change mid-flight
|
||||
config::RendererType = render;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,3 +19,5 @@ android.useAndroidX=true
|
|||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
# Don't uninstall the apk after running tests
|
||||
android.injected.androidTest.leaveApksInstalledAfterRun=true
|
||||
|
|
Binary file not shown.
|
@ -80,7 +80,6 @@ static bool emulatorRunning;
|
|||
{
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
gui_save();
|
||||
if (config::AutoSaveState && !settings.content.path.empty())
|
||||
dc_savestate(config::SavestateSlot);
|
||||
}
|
||||
|
|
|
@ -1100,8 +1100,8 @@ struct retro_core_option_v2_definition option_defs_us[] = {
|
|||
CORE_OPTION_NAME "_per_content_vmus",
|
||||
"Per-Game Visual Memory Units/Systems (VMU)",
|
||||
"Per-Game VMUs",
|
||||
"When disabled, all games share up to 8 VMU save files (A1/A2/B1/B2/C1/C2/D1/D2) located in RetroArch's system directory.\n"
|
||||
"The 'VMU A1' setting creates a unique VMU 'A1' file in RetroArch's save directory for each game that is launched.\n"
|
||||
"When disabled, all games share up to 8 VMU save files (A1/A2/B1/B2/C1/C2/D1/D2) located in RetroArch's system folder.\n"
|
||||
"The 'VMU A1' setting creates a unique VMU 'A1' file in RetroArch's save folder for each game that is launched.\n"
|
||||
"The 'All VMUs' setting creates up to 8 unique VMU files (A1/A2/B1/B2/C1/C2/D1/D2) for each game that is launched.",
|
||||
NULL,
|
||||
"vmu",
|
||||
|
|
Loading…
Reference in New Issue