diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 09391757a..6ca0c175f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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 diff --git a/core/audio/audiobackend_oboe.cpp b/core/audio/audiobackend_oboe.cpp index 2843b7468..9bf7609c5 100644 --- a/core/audio/audiobackend_oboe.cpp +++ b/core/audio/audiobackend_oboe.cpp @@ -176,7 +176,7 @@ public: } recordStream->requestStart(); NOTICE_LOG(AUDIO, "Oboe recorder started. stream capacity: %d frames", - stream->getBufferCapacityInFrames()); + recordStream->getBufferCapacityInFrames()); return true; } diff --git a/core/emulator.cpp b/core/emulator.cpp index da5aacb45..c1ccb9f37 100644 --- a/core/emulator.cpp +++ b/core/emulator.cpp @@ -944,15 +944,20 @@ void Emulator::start() bool Emulator::checkStatus(bool wait) { try { - const std::lock_guard lock(mutex); + std::unique_lock 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; diff --git a/core/emulator.h b/core/emulator.h index 6802b00e8..eb2a698be 100644 --- a/core/emulator.h +++ b/core/emulator.h @@ -179,7 +179,7 @@ private: Terminated, }; State state = Uninitialized; - std::future threadResult; + std::shared_future threadResult; bool resetRequested = false; bool singleStep = false; u64 startTime = 0; diff --git a/core/hw/flashrom/flashrom.cpp b/core/hw/flashrom/flashrom.cpp index f848af3de..3c501b44e 100644 --- a/core/hw/flashrom/flashrom.cpp +++ b/core/hw/flashrom/flashrom.cpp @@ -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; diff --git a/core/hw/naomi/gdcartridge.cpp b/core/hw/naomi/gdcartridge.cpp index cdd00354f..357a9c965 100644 --- a/core/hw/naomi/gdcartridge.cpp +++ b/core/hw/naomi/gdcartridge.cpp @@ -494,9 +494,9 @@ void GDCartridge::device_start(LoadProgress *progress, std::vector *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 gdrom; try { + gdrom_path = hostfs::storage().getSubPath(parent, gdrom_path); gdrom = std::unique_ptr(OpenDisc(gdrom_path + ".chd", digest)); } catch (const FlycastException& e) @@ -504,8 +504,8 @@ void GDCartridge::device_start(LoadProgress *progress, std::vector *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(OpenDisc(gdrom_parent_path + ".chd", digest)); } catch (const FlycastException& e) { WARN_LOG(NAOMI, "Opening parent chd failed: %s", e.what()); diff --git a/core/hw/naomi/naomi_cart.cpp b/core/hw/naomi/naomi_cart.cpp index ae0757297..45c7e37bb 100644 --- a/core/hw/naomi/naomi_cart.cpp +++ b/core/hw/naomi/naomi_cart.cpp @@ -181,9 +181,12 @@ void naomi_cart_LoadBios(const char *filename) std::unique_ptr 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 diff --git a/core/hw/naomi/systemsp.cpp b/core/hw/naomi/systemsp.cpp index 9cc51b366..02935dd10 100644 --- a/core/hw/naomi/systemsp.cpp +++ b/core/hw/naomi/systemsp.cpp @@ -2088,12 +2088,18 @@ void SystemSpCart::Init(LoadProgress *progress, std::vector *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); diff --git a/core/nullDC.cpp b/core/nullDC.cpp index 5129cd830..d18543a0c 100644 --- a/core/nullDC.cpp +++ b/core/nullDC.cpp @@ -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 +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& 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; diff --git a/core/oslib/oslib.cpp b/core/oslib/oslib.cpp index fd61df6f8..05d33657d 100644 --- a/core/oslib/oslib.cpp +++ b/core/oslib/oslib.cpp @@ -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) { } } diff --git a/core/oslib/oslib.h b/core/oslib/oslib.h index 985f6cb77..916b33e98 100644 --- a/core/oslib/oslib.h +++ b/core/oslib/oslib.h @@ -62,6 +62,11 @@ namespace hostfs std::string getShaderCachePath(const std::string& filename); void saveScreenshot(const std::string& name, const std::vector& data); + +#ifdef __ANDROID__ + void importHomeDirectory(); + void exportHomeDirectory(); +#endif } static inline void *allocAligned(size_t alignment, size_t size) diff --git a/core/oslib/storage.cpp b/core/oslib/storage.cpp index 6f497b8ba..70a0c580c 100644 --- a/core/oslib/storage.cpp +++ b/core/oslib/storage.cpp @@ -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 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); } } diff --git a/core/oslib/storage.h b/core/oslib/storage.h index e1d1b9811..416832d90 100644 --- a/core/oslib/storage.h +++ b/core/oslib/storage.h @@ -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 diff --git a/core/rend/CustomTexture.cpp b/core/rend/CustomTexture.cpp index fd082cdd0..93078201f 100644 --- a/core/rend/CustomTexture.cpp +++ b/core/rend/CustomTexture.cpp @@ -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; diff --git a/core/sdl/sdl.cpp b/core/sdl/sdl.cpp index 215843642..9027dfa25 100644 --- a/core/sdl/sdl.cpp +++ b/core/sdl/sdl.cpp @@ -766,7 +766,6 @@ static int suspendEventFilter(void *userdata, SDL_Event *event) { if (event->type == SDL_APP_WILLENTERBACKGROUND) { - gui_save(); if (gameRunning) { try { diff --git a/core/stdclass.cpp b/core/stdclass.cpp index f8c97ab5e..ba783f835 100644 --- a/core/stdclass.cpp +++ b/core/stdclass.cpp @@ -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 diff --git a/core/ui/boxart/boxart.cpp b/core/ui/boxart/boxart.cpp index dc3f42c32..72c4e4a2d 100644 --- a/core/ui/boxart/boxart.cpp +++ b/core/ui/boxart/boxart.cpp @@ -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(); +} diff --git a/core/ui/boxart/boxart.h b/core/ui/boxart/boxart.h index 2f91dc424..24bcc03b2 100644 --- a/core/ui/boxart/boxart.h +++ b/core/ui/boxart/boxart.h @@ -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/"); } diff --git a/core/ui/game_scanner.cpp b/core/ui/game_scanner.cpp index 49666726e..0dba94738 100644 --- a/core/ui/game_scanner.cpp +++ b/core/ui/game_scanner.cpp @@ -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) diff --git a/core/ui/gui.cpp b/core/ui/gui.cpp index 390484d48..58291ad8c 100644 --- a/core/ui/gui.cpp +++ b/core/ui/gui.cpp @@ -1441,7 +1441,7 @@ static void gamepadSettingsPopup(const std::shared_ptr& 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 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(), ':', '-'); diff --git a/core/ui/gui.h b/core/ui/gui.h index 9c23c38b0..80be4f5e6 100644 --- a/core/ui/gui.h +++ b/core/ui/gui.h @@ -20,6 +20,7 @@ #include "types.h" #include +#include 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 function); enum class GuiState { Closed, diff --git a/core/ui/gui_cheats.cpp b/core/ui/gui_cheats.cpp index b10faa6f0..c04a91bd5 100644 --- a/core/ui/gui_cheats.cpp +++ b/core/ui/gui_cheats.cpp @@ -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() diff --git a/core/ui/gui_util.cpp b/core/ui/gui_util.cpp index 62ee49c74..83562dd96 100644 --- a/core/ui/gui_util.cpp +++ b/core/ui/gui_util.cpp @@ -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::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; + } +} diff --git a/core/ui/gui_util.h b/core/ui/gui_util.h index 8bb28f6ed..7fec8ebae 100644 --- a/core/ui/gui_util.h +++ b/core/ui/gui_util.h @@ -310,3 +310,5 @@ private: u64 endTime = 0; std::mutex mutex; }; + +std::string middleEllipsis(const std::string& s, float width); diff --git a/shell/android-studio/flycast/build.gradle b/shell/android-studio/flycast/build.gradle index dce29d0b0..545c5731d 100644 --- a/shell/android-studio/flycast/build.gradle +++ b/shell/android-studio/flycast/build.gradle @@ -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' } diff --git a/shell/android-studio/flycast/src/androidTest/java/com/flycast/emulator/AndroidStorageTest.java b/shell/android-studio/flycast/src/androidTest/java/com/flycast/emulator/AndroidStorageTest.java new file mode 100644 index 000000000..9a9519c67 --- /dev/null +++ b/shell/android-studio/flycast/src/androidTest/java/com/flycast/emulator/AndroidStorageTest.java @@ -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 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 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); + } + }); + } +} \ No newline at end of file diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java index df1fc3899..20f092925 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java @@ -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 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"); + } } diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java index a8184a7df..3c0555933 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java @@ -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 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); diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/Emulator.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/Emulator.java index 1bddf2a31..28d8ba14e 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/Emulator.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/Emulator.java @@ -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() diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/FileInfo.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/FileInfo.java index 5b036a33d..ba007f8aa 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/FileInfo.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/FileInfo.java @@ -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; } diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java new file mode 100644 index 000000000..0256f6d6f --- /dev/null +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java @@ -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 . +*/ +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 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; + } +} diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/NativeGLActivity.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/NativeGLActivity.java index 86408aceb..7041eb70d 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/NativeGLActivity.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/NativeGLActivity.java @@ -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(); + } + } + }); } } diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/NativeGLView.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/NativeGLView.java index 2a04e13c8..0a928ac7c 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/NativeGLView.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/NativeGLView.java @@ -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() { diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java index 06fe81045..1b23cf0f0 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java @@ -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(); - } - } - } - } } diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java index 305597141..a8078f063 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java @@ -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 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 ids; + synchronized (this) { + ids = new ArrayList(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 ids; + synchronized (this) { + ids = new ArrayList(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); diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java new file mode 100644 index 000000000..9b7068c7d --- /dev/null +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java @@ -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 . +*/ +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(); + } + } +} diff --git a/shell/android-studio/flycast/src/main/jni/src/Android.cpp b/shell/android-studio/flycast/src/main/jni/src/Android.cpp index b2fce4df1..09146fde7 100644 --- a/shell/android-studio/flycast/src/main/jni/src/Android.cpp +++ b/shell/android-studio/flycast/src/main/jni/src/Android.cpp @@ -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(); 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 gamepad = std::make_shared(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"); } } diff --git a/shell/android-studio/flycast/src/main/jni/src/android_gamepad.h b/shell/android-studio/flycast/src/main/jni/src/android_gamepad.h index c95b62057..c637e3c44 100644 --- a/shell/android-studio/flycast/src/main/jni/src/android_gamepad.h +++ b/shell/android-studio/flycast/src/main/jni/src/android_gamepad.h @@ -106,7 +106,6 @@ public: if (id == VIRTUAL_GAMEPAD_ID) { input_mapper = std::make_shared(); - 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(); } diff --git a/shell/android-studio/flycast/src/main/jni/src/android_storage.h b/shell/android-studio/flycast/src/main/jni/src/android_storage.h index af7ee59b8..d21e35ea0 100644 --- a/shell/android-studio/flycast/src/main/jni/src/android_storage.h +++ b/shell/android-studio/flycast/src/main/jni/src/android_storage.h @@ -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& data) return static_cast(customStorage()).saveScreenshot(name, data); } +void importHomeDirectory() { + static_cast(customStorage()).importHomeDirectory(); +} + +void exportHomeDirectory() { + static_cast(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::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; + } +} diff --git a/shell/android-studio/gradle.properties b/shell/android-studio/gradle.properties index 534063aa9..aaace3531 100644 --- a/shell/android-studio/gradle.properties +++ b/shell/android-studio/gradle.properties @@ -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 diff --git a/shell/android-studio/playstore.jks b/shell/android-studio/playstore.jks new file mode 100644 index 000000000..ec5e2a7fc Binary files /dev/null and b/shell/android-studio/playstore.jks differ diff --git a/shell/apple/emulator-ios/emulator/AppDelegate.mm b/shell/apple/emulator-ios/emulator/AppDelegate.mm index 592f5311e..34840d42a 100644 --- a/shell/apple/emulator-ios/emulator/AppDelegate.mm +++ b/shell/apple/emulator-ios/emulator/AppDelegate.mm @@ -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); } diff --git a/shell/libretro/libretro_core_options.h b/shell/libretro/libretro_core_options.h index 4427f4603..6e6d19d67 100644 --- a/shell/libretro/libretro_core_options.h +++ b/shell/libretro/libretro_core_options.h @@ -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",