ppsspp/UI/Store.cpp
2020-05-17 14:44:11 +02:00

565 lines
16 KiB
C++

// Copyright (c) 2014- PPSSPP Project.
// This program 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, version 2.0 or later versions.
// This program 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 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#include <functional>
#include "base/basictypes.h"
#include "json/json_reader.h"
#include "i18n/i18n.h"
#include "ui/screen.h"
#include "ui/ui_context.h"
#include "ui/viewgroup.h"
#include "gfx_es2/draw_buffer.h"
#include "Common/Log.h"
#include "Common/StringUtils.h"
#include "Core/Config.h"
#include "Core/System.h"
#include "Core/Util/GameManager.h"
#include "UI/EmuScreen.h"
#include "UI/Store.h"
#include "UI/TextureUtil.h"
const std::string storeBaseUrl = "http://store.ppsspp.org/";
// baseUrl is assumed to have a trailing slash, and not contain any subdirectories.
std::string ResolveUrl(std::string baseUrl, std::string url) {
if (url.empty()) {
return baseUrl;
} else if (url[0] == '/') {
return baseUrl + url.substr(1);
} else if (startsWith(url, "http://") || startsWith(url, "https://")) {
return url;
} else {
// Huh.
return baseUrl + url;
}
}
class HttpImageFileView : public UI::View {
public:
HttpImageFileView(http::Downloader *downloader, const std::string &path, UI::ImageSizeMode sizeMode = UI::IS_DEFAULT, UI::LayoutParams *layoutParams = 0)
: UI::View(layoutParams), path_(path), sizeMode_(sizeMode), downloader_(downloader) {}
~HttpImageFileView() {
if (download_)
download_->Cancel();
}
void GetContentDimensions(const UIContext &dc, float &w, float &h) const override;
void Draw(UIContext &dc) override;
void SetFilename(std::string filename);
void SetColor(uint32_t color) { color_ = color; }
void SetFixedSize(float fixW, float fixH) { fixedSizeW_ = fixW; fixedSizeH_ = fixH; }
void SetCanBeFocused(bool can) { canFocus_ = can; }
bool CanBeFocused() const override { return false; }
const std::string &GetFilename() const { return path_; }
private:
void DownloadCompletedCallback(http::Download &download);
bool canFocus_ = false;
std::string path_;
uint32_t color_ = 0xFFFFFFFF;
UI::ImageSizeMode sizeMode_;
http::Downloader *downloader_;
std::shared_ptr<http::Download> download_;
std::string textureData_;
std::unique_ptr<ManagedTexture> texture_;
bool textureFailed_ = false;
float fixedSizeW_ = 0.0f;
float fixedSizeH_ = 0.0f;
};
void HttpImageFileView::GetContentDimensions(const UIContext &dc, float &w, float &h) const {
switch (sizeMode_) {
case UI::IS_FIXED:
w = fixedSizeW_;
h = fixedSizeH_;
break;
case UI::IS_DEFAULT:
default:
if (texture_) {
float texw = (float)texture_->Width();
float texh = (float)texture_->Height();
w = texw;
h = texh;
} else {
w = 16;
h = 16;
}
break;
}
}
void HttpImageFileView::SetFilename(std::string filename) {
if (path_ != filename) {
textureFailed_ = false;
path_ = filename;
texture_.reset(nullptr);
}
}
void HttpImageFileView::DownloadCompletedCallback(http::Download &download) {
if (download.IsCancelled()) {
// We were probably destroyed. Can't touch "this" (heh).
return;
}
if (download.ResultCode() == 200) {
download.buffer().TakeAll(&textureData_);
} else {
textureFailed_ = true;
}
}
void HttpImageFileView::Draw(UIContext &dc) {
using namespace Draw;
if (!texture_ && !textureFailed_ && !path_.empty() && !download_) {
download_ = downloader_->StartDownloadWithCallback(path_, "", std::bind(&HttpImageFileView::DownloadCompletedCallback, this, std::placeholders::_1));
download_->SetHidden(true);
}
if (!textureData_.empty()) {
texture_ = CreateTextureFromFileData(dc.GetDrawContext(), (const uint8_t *)(textureData_.data()), (int)textureData_.size(), DETECT);
if (!texture_)
textureFailed_ = true;
textureData_.clear();
download_.reset();
}
if (HasFocus()) {
dc.FillRect(dc.theme->itemFocusedStyle.background, bounds_.Expand(3));
}
// TODO: involve sizemode
if (texture_) {
float tw = texture_->Width();
float th = texture_->Height();
float x = bounds_.x;
float y = bounds_.y;
float w = bounds_.w;
float h = bounds_.h;
if (tw / th < w / h) {
float nw = h * tw / th;
x += (w - nw) / 2.0f;
w = nw;
} else {
float nh = w * th / tw;
y += (h - nh) / 2.0f;
h = nh;
}
dc.Flush();
dc.GetDrawContext()->BindTexture(0, texture_->GetTexture());
dc.Draw()->Rect(x, y, w, h, color_);
dc.Flush();
dc.RebindTexture();
} else {
// draw a black rectangle to represent the missing image.
dc.FillRect(UI::Drawable(0x7F000000), GetBounds());
}
}
// This is the entry in a list. Does not have install buttons and so on.
class ProductItemView : public UI::StickyChoice {
public:
ProductItemView(const StoreEntry &entry, UI::LayoutParams *layoutParams = 0)
: UI::StickyChoice(entry.name, "", layoutParams), entry_(entry) {}
void GetContentDimensions(const UIContext &dc, float &w, float &h) const override {
w = 300;
h = 164;
}
StoreEntry GetEntry() const { return entry_; }
private:
const StoreEntry &entry_;
};
// This is a "details" view of a game. Lets you install it.
class ProductView : public UI::LinearLayout {
public:
ProductView(const StoreEntry &entry)
: LinearLayout(UI::ORIENT_VERTICAL), entry_(entry) {
CreateViews();
}
void Update() override;
UI::Event OnClickLaunch;
private:
void CreateViews();
UI::EventReturn OnInstall(UI::EventParams &e);
UI::EventReturn OnCancel(UI::EventParams &e);
UI::EventReturn OnUninstall(UI::EventParams &e);
UI::EventReturn OnLaunchClick(UI::EventParams &e);
bool IsGameInstalled() {
return g_GameManager.IsGameInstalled(entry_.file);
}
StoreEntry entry_;
UI::Button *installButton_ = nullptr;
UI::Button *launchButton_ = nullptr;
UI::Button *cancelButton_ = nullptr;
bool wasInstalled_ = false;
};
void ProductView::CreateViews() {
using namespace UI;
Clear();
if (!entry_.iconURL.empty()) {
Add(new HttpImageFileView(&g_DownloadManager, ResolveUrl(storeBaseUrl, entry_.iconURL), IS_FIXED))->SetFixedSize(144, 88);
}
Add(new TextView(entry_.name));
Add(new TextView(entry_.author));
auto st = GetI18NCategory("Store");
auto di = GetI18NCategory("Dialog");
wasInstalled_ = IsGameInstalled();
if (!wasInstalled_) {
launchButton_ = nullptr;
installButton_ = Add(new Button(st->T("Install")));
installButton_->OnClick.Handle(this, &ProductView::OnInstall);
} else {
installButton_ = nullptr;
Add(new TextView(st->T("Already Installed")));
Add(new Button(st->T("Uninstall")))->OnClick.Handle(this, &ProductView::OnUninstall);
launchButton_ = new Button(st->T("Launch Game"));
launchButton_->OnClick.Handle(this, &ProductView::OnLaunchClick);
Add(launchButton_);
}
cancelButton_ = Add(new Button(di->T("Cancel")));
cancelButton_->OnClick.Handle(this, &ProductView::OnCancel);
cancelButton_->SetVisibility(V_GONE);
// Add star rating, comments etc?
Add(new TextView(entry_.description, ALIGN_LEFT | FLAG_WRAP_TEXT, false));
float size = entry_.size / (1024.f * 1024.f);
char temp[256];
sprintf(temp, "%s: %.2f %s", st->T("Size"), size, st->T("MB"));
Add(new TextView(temp));
}
void ProductView::Update() {
if (wasInstalled_ != IsGameInstalled()) {
CreateViews();
}
if (installButton_) {
installButton_->SetEnabled(g_GameManager.GetState() == GameManagerState::IDLE);
}
if (cancelButton_ && g_GameManager.GetState() != GameManagerState::DOWNLOADING)
cancelButton_->SetVisibility(UI::V_GONE);
if (launchButton_)
launchButton_->SetEnabled(g_GameManager.GetState() == GameManagerState::IDLE);
View::Update();
}
UI::EventReturn ProductView::OnInstall(UI::EventParams &e) {
std::string fileUrl;
if (entry_.downloadURL.empty()) {
// Construct the URL, easy to predict from our server
std::string shortName = entry_.file;
if (shortName.find('.') == std::string::npos)
shortName += ".zip";
fileUrl = storeBaseUrl + "files/" + shortName;
} else {
// Use the provided URL, for external hosting.
fileUrl = entry_.downloadURL;
}
if (installButton_) {
installButton_->SetEnabled(false);
}
if (cancelButton_) {
cancelButton_->SetVisibility(UI::V_VISIBLE);
}
INFO_LOG(SYSTEM, "Triggering install of '%s'", fileUrl.c_str());
g_GameManager.DownloadAndInstall(fileUrl);
return UI::EVENT_DONE;
}
UI::EventReturn ProductView::OnCancel(UI::EventParams &e) {
g_GameManager.CancelDownload();
return UI::EVENT_DONE;
}
UI::EventReturn ProductView::OnUninstall(UI::EventParams &e) {
g_GameManager.Uninstall(entry_.file);
CreateViews();
return UI::EVENT_DONE;
}
UI::EventReturn ProductView::OnLaunchClick(UI::EventParams &e) {
if (g_GameManager.GetState() != GameManagerState::IDLE) {
// Button should have been disabled. Just a safety check.
return UI::EVENT_DONE;
}
std::string pspGame = GetSysDirectory(DIRECTORY_GAME);
std::string path = pspGame + entry_.file;
#ifdef _WIN32
path = ReplaceAll(path, "\\", "/");
#endif
UI::EventParams e2{};
e2.v = e.v;
e2.s = path;
// Insta-update - here we know we are already on the right thread.
OnClickLaunch.Trigger(e2);
return UI::EVENT_DONE;
}
StoreScreen::StoreScreen() {
StoreFilter noFilter;
SetFilter(noFilter);
lang_ = g_Config.sLanguageIni;
loading_ = true;
std::string indexPath = storeBaseUrl + "index.json";
listing_ = g_DownloadManager.StartDownload(indexPath, "");
}
StoreScreen::~StoreScreen() {
g_DownloadManager.CancelAll();
}
// Handle async download tasks
void StoreScreen::update() {
UIDialogScreenWithBackground::update();
g_DownloadManager.Update();
if (listing_.get() != 0 && listing_->Done()) {
if (listing_->ResultCode() == 200) {
std::string listingJson;
listing_->buffer().TakeAll(&listingJson);
// printf("%s\n", listingJson.c_str());
loading_ = false;
connectionError_ = false;
ParseListing(listingJson);
RecreateViews();
} else {
// Failed to contact store. Don't do anything.
ELOG("Download failed : error code %d", listing_->ResultCode());
connectionError_ = true;
loading_ = false;
RecreateViews();
}
// Forget the listing.
listing_.reset();
}
const char *storeName = "PPSSPP Homebrew Store";
switch (g_GameManager.GetState()) {
case GameManagerState::DOWNLOADING:
titleText_->SetText(std::string(storeName) + " - downloading");
break;
case GameManagerState::INSTALLING:
titleText_->SetText(std::string(storeName) + " - installing");
break;
default:
titleText_->SetText(storeName);
break;
}
}
void StoreScreen::ParseListing(std::string json) {
using namespace json;
JsonReader reader(json.c_str(), json.size());
if (!reader.ok() || !reader.root()) {
ELOG("Error parsing JSON from store");
connectionError_ = true;
RecreateViews();
return;
}
const JsonGet root = reader.root();
const JsonNode *entries = root.getArray("entries");
if (entries) {
entries_.clear();
for (const JsonNode *pgame : entries->value) {
JsonGet game = pgame->value;
StoreEntry e;
e.type = ENTRY_PBPZIP;
e.name = GetTranslatedString(game, "name");
e.description = GetTranslatedString(game, "description", "");
e.author = game.getString("author", "?");
e.size = game.getInt("size");
e.downloadURL = game.getString("download-url", "");
e.iconURL = game.getString("icon-url", "");
e.hidden = game.getBool("hidden", false);
const char *file = game.getString("file", nullptr);
if (!file)
continue;
e.file = file;
entries_.push_back(e);
}
}
}
void StoreScreen::CreateViews() {
using namespace UI;
root_ = new LinearLayout(ORIENT_VERTICAL);
auto di = GetI18NCategory("Dialog");
auto st = GetI18NCategory("Store");
// Top bar
LinearLayout *topBar = root_->Add(new LinearLayout(ORIENT_HORIZONTAL));
topBar->Add(new Button(di->T("Back")))->OnClick.Handle<UIScreen>(this, &UIScreen::OnBack);
titleText_ = new TextView("PPSSPP Homebrew Store");
topBar->Add(titleText_);
UI::Drawable solid(0xFFbd9939);
topBar->SetBG(solid);
LinearLayout *content;
if (connectionError_ || loading_) {
content = new LinearLayout(ORIENT_VERTICAL, new LinearLayoutParams(FILL_PARENT, FILL_PARENT, 1.0f));
content->Add(new TextView(loading_ ? st->T("Loading...") : st->T("Connection Error")));
content->Add(new Button(di->T("Retry")))->OnClick.Handle(this, &StoreScreen::OnRetry);
content->Add(new Button(di->T("Back")))->OnClick.Handle<UIScreen>(this, &UIScreen::OnBack);
scrollItemView_ = nullptr;
productPanel_ = nullptr;
} else {
content = new LinearLayout(ORIENT_HORIZONTAL, new LinearLayoutParams(FILL_PARENT, FILL_PARENT, 1.0f));
ScrollView *leftScroll = new ScrollView(ORIENT_VERTICAL, new LinearLayoutParams(WRAP_CONTENT, FILL_PARENT, 0.4f));
leftScroll->SetTag("StoreMainList");
content->Add(leftScroll);
scrollItemView_ = new LinearLayout(ORIENT_VERTICAL, new LayoutParams(FILL_PARENT, WRAP_CONTENT));
leftScroll->Add(scrollItemView_);
std::vector<StoreEntry> entries = FilterEntries();
for (size_t i = 0; i < entries.size(); i++) {
scrollItemView_->Add(new ProductItemView(entries_[i]))->OnClick.Handle(this, &StoreScreen::OnGameSelected);
}
// TODO: Similar apps, etc etc
productPanel_ = new ScrollView(ORIENT_VERTICAL, new LinearLayoutParams(0.5f));
leftScroll->SetTag("StoreMainProduct");
content->Add(productPanel_);
ProductItemView *selectedItem = GetSelectedItem();
if (selectedItem) {
ProductView *productView = new ProductView(selectedItem->GetEntry());
productView->OnClickLaunch.Handle(this, &StoreScreen::OnGameLaunch);
productPanel_->Add(productView);
selectedItem->Press();
} else {
lastSelectedName_ = "";
}
}
root_->Add(content);
}
std::vector<StoreEntry> StoreScreen::FilterEntries() {
std::vector<StoreEntry> filtered;
for (size_t i = 0; i < entries_.size(); i++) {
// TODO: Actually filter by category etc.
if (!entries_[i].hidden)
filtered.push_back(entries_[i]);
}
return filtered;
}
ProductItemView *StoreScreen::GetSelectedItem() {
for (int i = 0; i < scrollItemView_->GetNumSubviews(); ++i) {
ProductItemView *item = static_cast<ProductItemView *>(scrollItemView_->GetViewByIndex(i));
if (item->GetEntry().name == lastSelectedName_)
return item;
}
return nullptr;
}
UI::EventReturn StoreScreen::OnGameSelected(UI::EventParams &e) {
ProductItemView *item = static_cast<ProductItemView *>(e.v);
if (!item)
return UI::EVENT_DONE;
productPanel_->Clear();
ProductView *productView = new ProductView(item->GetEntry());
productView->OnClickLaunch.Handle(this, &StoreScreen::OnGameLaunch);
productPanel_->Add(productView);
ProductItemView *previousItem = GetSelectedItem();
if (previousItem && previousItem != item)
previousItem->Release();
lastSelectedName_ = item->GetEntry().name;
return UI::EVENT_DONE;
}
UI::EventReturn StoreScreen::OnGameLaunch(UI::EventParams &e) {
std::string path = e.s;
screenManager()->switchScreen(new EmuScreen(path));
return UI::EVENT_DONE;
}
void StoreScreen::SetFilter(const StoreFilter &filter) {
filter_ = filter;
RecreateViews();
}
UI::EventReturn StoreScreen::OnRetry(UI::EventParams &e) {
SetFilter(filter_);
return UI::EVENT_DONE;
}
std::string StoreScreen::GetStoreJsonURL(std::string storePath) const {
std::string path = storeBaseUrl + storePath;
if (*path.rbegin() != '/')
path += '/';
path += "index.json";
return path;
}
std::string StoreScreen::GetTranslatedString(const json::JsonGet json, std::string key, const char *fallback) const {
json::JsonGet dict = json.getDict("en_US");
if (dict && json.hasChild(lang_.c_str(), JSON_OBJECT)) {
if (json.getDict(lang_.c_str()).hasChild(key.c_str(), JSON_STRING)) {
dict = json.getDict(lang_.c_str());
}
}
const char *str = nullptr;
if (dict) {
str = dict.getString(key.c_str(), nullptr);
}
if (str) {
return std::string(str);
} else {
return fallback ? fallback : "(error)";
}
}