diff --git a/Common/File/AndroidContentURI.h b/Common/File/AndroidContentURI.h index 7015e0a20a..bc6bc65fe8 100644 --- a/Common/File/AndroidContentURI.h +++ b/Common/File/AndroidContentURI.h @@ -67,6 +67,10 @@ public: return root.empty() ? file : root; } + const std::string &Provider() const { + return provider; + } + bool IsTreeURI() const { return !root.empty(); } diff --git a/Common/File/FileUtil.cpp b/Common/File/FileUtil.cpp index aeb7235221..c750aec1f9 100644 --- a/Common/File/FileUtil.cpp +++ b/Common/File/FileUtil.cpp @@ -1276,4 +1276,21 @@ void ChangeMTime(const Path &path, time_t mtime) { #endif } +bool IsProbablyInDownloadsFolder(const Path &filename) { + INFO_LOG(Log::Common, "IsProbablyInDownloadsFolder: Looking at %s (%s)...", filename.c_str(), filename.ToVisualString().c_str()); + switch (filename.Type()) { + case PathType::CONTENT_URI: + { + AndroidContentURI uri(filename.ToString()); + INFO_LOG(Log::Common, "Content URI provider: %s", uri.Provider().c_str()); + if (containsNoCase(uri.Provider(), "download")) { + // like com.android.providers.downloads.documents + return true; + } + break; + } + } + return filename.FilePathContainsNoCase("download"); +} + } // namespace File diff --git a/Common/File/FileUtil.h b/Common/File/FileUtil.h index 9a3af34bb0..eedf4cecf1 100644 --- a/Common/File/FileUtil.h +++ b/Common/File/FileUtil.h @@ -122,6 +122,10 @@ bool CreateEmptyFile(const Path &filename); // TODO: Belongs in System or something. bool OpenFileInEditor(const Path &fileName); +// Uses some heuristics to determine if this is a folder that we would want to +// write to. +bool IsProbablyInDownloadsFolder(const Path &folder); + // TODO: Belongs in System or something. const Path &GetExeDirectory(); diff --git a/Common/File/Path.h b/Common/File/Path.h index e94caf14c3..cf48fb277d 100644 --- a/Common/File/Path.h +++ b/Common/File/Path.h @@ -50,6 +50,9 @@ public: PathType Type() const { return type_; } + bool IsLocalType() const { + return type_ == PathType::NATIVE || type_ == PathType::CONTENT_URI; + } bool Valid() const { return !path_.empty(); } bool IsRoot() const { return path_ == "/"; } // Special value - only path that can end in a slash. diff --git a/Common/StringUtils.cpp b/Common/StringUtils.cpp index 63ac6413e0..be11318d1d 100644 --- a/Common/StringUtils.cpp +++ b/Common/StringUtils.cpp @@ -93,6 +93,12 @@ long parseLong(std::string s) { return value; } +bool containsNoCase(std::string_view haystack, std::string_view needle) { + auto pred = [](char ch1, char ch2) { return std::toupper(ch1) == std::toupper(ch2); }; + auto found = std::search(haystack.begin(), haystack.end(), needle.begin(), needle.end(), pred); + return found != haystack.end(); +} + bool CharArrayFromFormatV(char* out, int outsize, const char* format, va_list args) { int writtenCount = vsnprintf(out, outsize, format, args); diff --git a/Common/StringUtils.h b/Common/StringUtils.h index 6197877cd0..aa331e0f93 100644 --- a/Common/StringUtils.h +++ b/Common/StringUtils.h @@ -69,6 +69,8 @@ inline bool equalsNoCase(std::string_view str, std::string_view key) { return strncasecmp(str.data(), key.data(), key.size()) == 0; } +bool containsNoCase(std::string_view haystack, std::string_view needle); + void DataToHexString(const uint8_t *data, size_t size, std::string *output); void DataToHexString(int indent, uint32_t startAddr, const uint8_t* data, size_t size, std::string* output); diff --git a/Core/Util/GameManager.cpp b/Core/Util/GameManager.cpp index 95597ca3ce..433e280135 100644 --- a/Core/Util/GameManager.cpp +++ b/Core/Util/GameManager.cpp @@ -389,14 +389,30 @@ void GameManager::InstallZipContents(ZipFileTask task) { // Examine the URL to guess out what we're installing. // TODO: Bad idea due to Android content api where we don't always get the filename. if (urlExtension == ".cso" || urlExtension == ".iso" || urlExtension == ".chd") { - // It's a raw ISO or CSO file. We just copy it to the destination. - std::string shortFilename = task.url.GetFilename(); - bool success = InstallRawISO(task.fileName, shortFilename, task.deleteAfter); + // It's a raw ISO or CSO file. We just copy it to the destination, which is the + // currently selected directory in the game browser. Note: This might not be a good option! + Path destPath = Path(g_Config.currentDirectory) / task.url.GetFilename(); + if (!File::Exists(destPath)) { + // Fall back to the root of the memstick. + destPath = g_Config.memStickDirectory; + } + g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 0.0f, 0.0f, 0.1f); + + // TODO: To save disk space, we should probably attempt a move first, if deleteAfter is true. + // TODO: Update the progress bar continuously. + bool success = File::Copy(task.fileName, destPath); + if (!success) { ERROR_LOG(Log::HLE, "Raw ISO install failed"); // This shouldn't normally happen at all (only when putting ISOs in a store, which is not a normal use case), so skipping the translation string SetInstallError("Failed to install raw ISO"); } + if (task.deleteAfter) { + File::Delete(task.fileName); + } + g_OSD.RemoveProgressBar("install", success, 0.5f); + installProgress_ = 1.0f; + InstallDone(); return; } @@ -404,8 +420,10 @@ void GameManager::InstallZipContents(ZipFileTask task) { struct zip *z = ZipOpenPath(task.fileName); if (!z) { - g_OSD.RemoveProgressBar("install", false, 0.5f); + g_OSD.RemoveProgressBar("install", false, 1.5f); SetInstallError(sy->T("Unable to open zip file")); + installProgress_ = 1.0f; + InstallDone(); return; } @@ -424,15 +442,17 @@ void GameManager::InstallZipContents(ZipFileTask task) { { Path pspGame = GetSysDirectory(DIRECTORY_GAME); INFO_LOG(Log::HLE, "Installing '%s' into '%s'", task.fileName.c_str(), pspGame.c_str()); - // InstallZipContents contains code to close (and delete) z. + // InstallZipContents contains code to close z. success = ExtractZipContents(z, pspGame, zipInfo, false); break; } case ZipFileContents::ISO_FILE: - INFO_LOG(Log::HLE, "Installing '%s' into its containing directory", task.fileName.c_str()); + { + INFO_LOG(Log::HLE, "Installing '%s' into '%s'", task.fileName.c_str(), task.destination.c_str()); // InstallZippedISO contains code to close z. - success = InstallZippedISO(z, zipInfo.isoFileIndex, task.fileName, task.deleteAfter); + success = InstallZippedISO(z, zipInfo.isoFileIndex, task.destination); break; + } case ZipFileContents::TEXTURE_PACK: { // InstallMemstickGame contains code to close z, and works for textures too. @@ -468,10 +488,16 @@ void GameManager::InstallZipContents(ZipFileTask task) { break; } + // Common functionality. if (task.deleteAfter && success) { File::Delete(task.fileName); } g_OSD.RemoveProgressBar("install", success, 0.5f); + installProgress_ = 1.0f; + InstallDone(); + if (success) { + ResetInstallError(); + } } bool GameManager::DetectTexturePackDest(struct zip *z, int iniIndex, Path &dest) { @@ -765,10 +791,6 @@ bool GameManager::ExtractZipContents(struct zip *z, const Path &dest, const ZipF INFO_LOG(Log::HLE, "Unzipped %d files (%d bytes / %d).", info.numFiles, (int)bytesCopied, (int)allBytes); zip_close(z); z = nullptr; - installProgress_ = 1.0f; - InstallDone(); - ResetInstallError(); - g_OSD.RemoveProgressBar("install", true, 0.5f); return true; bail: @@ -782,7 +804,6 @@ bail: File::DeleteDir(iter); } SetInstallError(sy->T("Storage full")); - g_OSD.RemoveProgressBar("install", false, 0.5f); return false; } @@ -842,7 +863,7 @@ bool GameManager::InstallMemstickZip(struct zip *z, const Path &zipfile, const P return true; } -bool GameManager::InstallZippedISO(struct zip *z, int isoFileIndex, const Path &zipfile, bool deleteAfter) { +bool GameManager::InstallZippedISO(struct zip *z, int isoFileIndex, const Path &destDir) { // Let's place the output file in the currently selected Games directory. std::string fn = zip_get_name(z, isoFileIndex, 0); size_t nameOffset = fn.rfind('/'); @@ -866,7 +887,12 @@ bool GameManager::InstallZippedISO(struct zip *z, int isoFileIndex, const Path & name = name.substr(2); } - Path outputISOFilename = Path(g_Config.currentDirectory) / name; + Path outputISOFilename = destDir; + if (outputISOFilename.empty()) { + outputISOFilename = Path(g_Config.currentDirectory); + } + outputISOFilename = outputISOFilename / name; + size_t bytesCopied = 0; bool success = false; auto di = GetI18NCategory(I18NCat::DIALOG); @@ -876,10 +902,6 @@ bool GameManager::InstallZippedISO(struct zip *z, int isoFileIndex, const Path & success = true; } zip_close(z); - if (success && deleteAfter) { - File::Delete(zipfile); - g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 0.0f, 0.0f, 0.1f); - } g_OSD.RemoveProgressBar("install", success, 0.5f); z = 0; @@ -910,25 +932,6 @@ bool GameManager::UninstallGameOnThread(const std::string &name) { return true; } -bool GameManager::InstallRawISO(const Path &file, const std::string &originalName, bool deleteAfter) { - Path destPath = Path(g_Config.currentDirectory) / originalName; - auto di = GetI18NCategory(I18NCat::DIALOG); - g_OSD.SetProgressBar("install", di->T("Installing..."), 0.0f, 0.0f, 0.0f, 0.1f); - // TODO: To save disk space, we should probably attempt a move first. - if (File::Copy(file, destPath)) { - if (deleteAfter) { - File::Delete(file); - } - g_OSD.RemoveProgressBar("install", true, 0.5f); - } else { - g_OSD.RemoveProgressBar("install", false, 0.5f); - } - installProgress_ = 1.0f; - InstallDone(); - ResetInstallError(); - return true; -} - void GameManager::ResetInstallError() { if (!InstallInProgress()) { installError_.clear(); diff --git a/Core/Util/GameManager.h b/Core/Util/GameManager.h index dffddfd4fd..27d7422d2b 100644 --- a/Core/Util/GameManager.h +++ b/Core/Util/GameManager.h @@ -64,6 +64,7 @@ struct ZipFileTask { std::optional zipFileInfo; Path url; // Same as filename if installing from disk. Probably not really useful. Path fileName; + Path destination; // If set, will override the default destination. bool deleteAfter; }; @@ -117,8 +118,7 @@ private: bool ExtractZipContents(struct zip *z, const Path &dest, const ZipFileInfo &info, bool allowRoot); bool InstallMemstickZip(struct zip *z, const Path &zipFile, const Path &dest, const ZipFileInfo &info); - bool InstallZippedISO(struct zip *z, int isoFileIndex, const Path &zipfile, bool deleteAfter); - bool InstallRawISO(const Path &zipFile, const std::string &originalName, bool deleteAfter); + bool InstallZippedISO(struct zip *z, int isoFileIndex, const Path &destDir); void UninstallGame(const std::string &name); void InstallDone(); diff --git a/UI/InstallZipScreen.cpp b/UI/InstallZipScreen.cpp index c676d168a6..2278a88002 100644 --- a/UI/InstallZipScreen.cpp +++ b/UI/InstallZipScreen.cpp @@ -20,8 +20,10 @@ #include "Common/UI/ViewGroup.h" #include "Common/StringUtils.h" +#include "Common/File/FileUtil.h" #include "Common/Data/Text/I18n.h" #include "Common/Data/Text/Parsers.h" +#include "Core/Config.h" #include "Core/System.h" #include "Core/Util/GameManager.h" #include "Core/Loaders.h" @@ -65,6 +67,10 @@ void InstallZipScreen::CreateViews() { doneView_ = nullptr; installChoice_ = nullptr; existingSaveView_ = nullptr; + destFolders_.clear(); + + std::vector destOptions; + if (z) { DetectZipFileContents(z, &zipFileInfo_); // Even if this fails, it sets zipInfo->contents. if (zipFileInfo_.contents == ZipFileContents::ISO_FILE || zipFileInfo_.contents == ZipFileContents::PSP_GAME_DIR) { @@ -78,6 +84,21 @@ void InstallZipScreen::CreateViews() { doneView_ = leftColumn->Add(new TextView("")); + if (zipFileInfo_.contents == ZipFileContents::ISO_FILE) { + const bool isInDownloads = File::IsProbablyInDownloadsFolder(zipPath_); + Path parent; + if (!isInDownloads && zipPath_.CanNavigateUp()) { + parent = zipPath_.NavigateUp(); + destFolders_.push_back(parent); + } + if (g_Config.currentDirectory.IsLocalType() && File::Exists(g_Config.currentDirectory) && g_Config.currentDirectory != parent) { + destFolders_.push_back(g_Config.currentDirectory); + } + destFolders_.push_back(g_Config.memStickDirectory); + } else { + destFolders_.push_back(GetSysDirectory(DIRECTORY_GAME)); + } + installChoice_ = rightColumnItems->Add(new Choice(iz->T("Install"))); installChoice_->OnClick.Handle(this, &InstallZipScreen::OnInstall); returnToHomebrew_ = true; @@ -102,6 +123,8 @@ void InstallZipScreen::CreateViews() { Path savedataDir = GetSysDirectory(DIRECTORY_SAVEDATA); bool overwrite = !CanExtractWithoutOverwrite(z, savedataDir, 50); + destFolders_.push_back(savedataDir); + leftColumn->Add(new NoticeView(NoticeLevel::WARN, di->T("Confirm Overwrite"), "")); int columnWidth = 300; @@ -143,6 +166,15 @@ void InstallZipScreen::CreateViews() { leftColumn->Add(new TextView(er->T("Error reading file"), ALIGN_LEFT, false, new AnchorLayoutParams(10, 10, NONE, NONE))); } + if (destFolders_.size() > 1) { + leftColumn->Add(new TextView(iz->T("Install into folder"))); + for (int i = 0; i < (int)destFolders_.size(); i++) { + leftColumn->Add(new RadioButton(&destFolderChoice_, i, destFolders_[i].ToVisualString())); + } + } else if (destFolders_.size() == 1 && zipFileInfo_.contents != ZipFileContents::SAVE_DATA) { + leftColumn->Add(new TextView(StringFromFormat("%s %s", iz->T_cstr("Install into folder:"), destFolders_[0].ToVisualString().c_str()))); + } + // OK so that EmuScreen will handle it right. backChoice_ = rightColumnItems->Add(new Choice(di->T("Back"))); backChoice_->OnClick.Handle(this, &UIScreen::OnOK); @@ -166,6 +198,9 @@ UI::EventReturn InstallZipScreen::OnInstall(UI::EventParams ¶ms) { task.fileName = zipPath_; task.deleteAfter = deleteZipFile_; task.zipFileInfo = zipFileInfo_; + if (!destFolders_.empty() && destFolderChoice_ < destFolders_.size()) { + task.destination = destFolders_[destFolderChoice_]; + } if (g_GameManager.InstallZipOnThread(task)) { installStarted_ = true; if (installChoice_) { diff --git a/UI/InstallZipScreen.h b/UI/InstallZipScreen.h index 68ad95c427..68982187ac 100644 --- a/UI/InstallZipScreen.h +++ b/UI/InstallZipScreen.h @@ -19,6 +19,8 @@ #include +#include "Common/File/Path.h" + #include "Common/UI/View.h" #include "Common/UI/UIScreen.h" @@ -46,6 +48,8 @@ private: SavedataView *existingSaveView_ = nullptr; Path savedataToOverwrite_; Path zipPath_; + std::vector destFolders_; + int destFolderChoice_ = 0; ZipFileInfo zipFileInfo_{}; bool returnToHomebrew_ = true; bool installStarted_ = false;