Merge remote-tracking branch 'origin/playstore' into dev

This commit is contained in:
Flyinghead 2024-08-24 18:08:59 +02:00
commit a93bd9e64e
43 changed files with 1349 additions and 340 deletions

View File

@ -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

View File

@ -176,7 +176,7 @@ public:
}
recordStream->requestStart();
NOTICE_LOG(AUDIO, "Oboe recorder started. stream capacity: %d frames",
stream->getBufferCapacityInFrames());
recordStream->getBufferCapacityInFrames());
return true;
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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());

View File

@ -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

View File

@ -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);

View File

@ -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;

View File

@ -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) {
}
}

View File

@ -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)

View File

@ -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);
}
}

View File

@ -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

View File

@ -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;

View File

@ -766,7 +766,6 @@ static int suspendEventFilter(void *userdata, SDL_Event *event)
{
if (event->type == SDL_APP_WILLENTERBACKGROUND)
{
gui_save();
if (gameRunning)
{
try {

View File

@ -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

View File

@ -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();
}

View File

@ -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/");
}

View File

@ -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)

View File

@ -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(), ':', '-');

View File

@ -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,

View File

@ -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()

View File

@ -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;
}
}

View File

@ -310,3 +310,5 @@ private:
u64 endTime = 0;
std::mutex mutex;
};
std::string middleEllipsis(const std::string& s, float width);

View File

@ -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'
}

View File

@ -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);
}
});
}
}

View File

@ -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");
}
}

View File

@ -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);

View File

@ -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()

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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();
}
}
});
}
}

View File

@ -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() {

View File

@ -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();
}
}
}
}
}

View File

@ -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);

View File

@ -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();
}
}
}

View File

@ -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");
}
}

View File

@ -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(); }

View File

@ -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;
}
}

View File

@ -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.

View File

@ -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);
}

View File

@ -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",