Wear Recovery UI: Adjust for round screens
Change-Id: Ice4b8d4fcdc37ee1ca44ef195cb870b141862f08
This commit is contained in:
parent
1551e6a0a4
commit
ebc02271ec
6 changed files with 228 additions and 28 deletions
|
@ -36,10 +36,8 @@ static const std::vector<std::pair<std::string, Device::BuiltinAction>> kFastboo
|
||||||
{ "Power off", Device::SHUTDOWN_FROM_FASTBOOT },
|
{ "Power off", Device::SHUTDOWN_FROM_FASTBOOT },
|
||||||
};
|
};
|
||||||
|
|
||||||
Device::BuiltinAction StartFastboot(Device* device, const std::vector<std::string>& /* args */) {
|
void FillDefaultFastbootLines(std::vector<std::string>& title_lines) {
|
||||||
RecoveryUI* ui = device->GetUI();
|
title_lines.push_back("Android Fastboot");
|
||||||
|
|
||||||
std::vector<std::string> title_lines = { "Android Fastboot" };
|
|
||||||
title_lines.push_back("Product name - " + android::base::GetProperty("ro.product.device", ""));
|
title_lines.push_back("Product name - " + android::base::GetProperty("ro.product.device", ""));
|
||||||
title_lines.push_back("Bootloader version - " + android::base::GetProperty("ro.bootloader", ""));
|
title_lines.push_back("Bootloader version - " + android::base::GetProperty("ro.bootloader", ""));
|
||||||
title_lines.push_back("Baseband version - " +
|
title_lines.push_back("Baseband version - " +
|
||||||
|
@ -48,6 +46,32 @@ Device::BuiltinAction StartFastboot(Device* device, const std::vector<std::strin
|
||||||
title_lines.push_back(std::string("Secure boot - ") +
|
title_lines.push_back(std::string("Secure boot - ") +
|
||||||
((android::base::GetProperty("ro.secure", "") == "1") ? "yes" : "no"));
|
((android::base::GetProperty("ro.secure", "") == "1") ? "yes" : "no"));
|
||||||
title_lines.push_back("HW version - " + android::base::GetProperty("ro.revision", ""));
|
title_lines.push_back("HW version - " + android::base::GetProperty("ro.revision", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
void FillWearableFastbootLines(std::vector<std::string>& title_lines) {
|
||||||
|
title_lines.push_back("Android Fastboot");
|
||||||
|
title_lines.push_back(android::base::GetProperty("ro.product.device", "") + " - " +
|
||||||
|
android::base::GetProperty("ro.revision", ""));
|
||||||
|
title_lines.push_back(android::base::GetProperty("ro.bootloader", ""));
|
||||||
|
|
||||||
|
const size_t max_baseband_len = 24;
|
||||||
|
const std::string& baseband = android::base::GetProperty("ro.build.expect.baseband", "");
|
||||||
|
title_lines.push_back(baseband.length() > max_baseband_len
|
||||||
|
? baseband.substr(0, max_baseband_len - 3) + "..."
|
||||||
|
: baseband);
|
||||||
|
|
||||||
|
title_lines.push_back("Serial #: " + android::base::GetProperty("ro.serialno", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
Device::BuiltinAction StartFastboot(Device* device, const std::vector<std::string>& /* args */) {
|
||||||
|
RecoveryUI* ui = device->GetUI();
|
||||||
|
std::vector<std::string> title_lines;
|
||||||
|
|
||||||
|
if (ui->IsWearable()) {
|
||||||
|
FillWearableFastbootLines(title_lines);
|
||||||
|
} else {
|
||||||
|
FillDefaultFastbootLines(title_lines);
|
||||||
|
}
|
||||||
|
|
||||||
ui->ResetKeyInterruptStatus();
|
ui->ResetKeyInterruptStatus();
|
||||||
ui->SetTitle(title_lines);
|
ui->SetTitle(title_lines);
|
||||||
|
|
|
@ -309,7 +309,7 @@ class ScreenRecoveryUI : public RecoveryUI, public DrawInterface {
|
||||||
void PutChar(char);
|
void PutChar(char);
|
||||||
void ClearText();
|
void ClearText();
|
||||||
|
|
||||||
void LoadAnimation();
|
virtual void LoadAnimation();
|
||||||
std::unique_ptr<GRSurface> LoadBitmap(const std::string& filename);
|
std::unique_ptr<GRSurface> LoadBitmap(const std::string& filename);
|
||||||
std::unique_ptr<GRSurface> LoadLocalizedBitmap(const std::string& filename);
|
std::unique_ptr<GRSurface> LoadLocalizedBitmap(const std::string& filename);
|
||||||
|
|
||||||
|
@ -416,6 +416,7 @@ class ScreenRecoveryUI : public RecoveryUI, public DrawInterface {
|
||||||
// Display the background texts for "erasing", "error", "no_command" and "installing" for the
|
// Display the background texts for "erasing", "error", "no_command" and "installing" for the
|
||||||
// selected locale.
|
// selected locale.
|
||||||
void SelectAndShowBackgroundText(const std::vector<std::string>& locales_entries, size_t sel);
|
void SelectAndShowBackgroundText(const std::vector<std::string>& locales_entries, size_t sel);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // RECOVERY_UI_H
|
#endif // RECOVERY_UI_H
|
||||||
|
|
|
@ -177,6 +177,10 @@ class RecoveryUI {
|
||||||
const std::vector<std::string>& backup_headers, const std::vector<std::string>& backup_items,
|
const std::vector<std::string>& backup_headers, const std::vector<std::string>& backup_items,
|
||||||
const std::function<int(int, bool)>& key_handler) = 0;
|
const std::function<int(int, bool)>& key_handler) = 0;
|
||||||
|
|
||||||
|
virtual bool IsWearable() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Set whether or not the fastbootd logo is displayed.
|
// Set whether or not the fastbootd logo is displayed.
|
||||||
void SetEnableFastbootdLogo(bool enable) {
|
void SetEnableFastbootdLogo(bool enable) {
|
||||||
fastbootd_logo_enabled_ = enable;
|
fastbootd_logo_enabled_ = enable;
|
||||||
|
|
|
@ -29,6 +29,10 @@ class WearRecoveryUI : public ScreenRecoveryUI {
|
||||||
void SetStage(int current, int max) override;
|
void SetStage(int current, int max) override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
// curved progress bar frames for round screens
|
||||||
|
std::vector<std::unique_ptr<GRSurface>> progress_frames_;
|
||||||
|
std::vector<std::unique_ptr<GRSurface>> rtl_progress_frames_;
|
||||||
|
|
||||||
// progress bar vertical position, it's centered horizontally
|
// progress bar vertical position, it's centered horizontally
|
||||||
const int progress_bar_baseline_;
|
const int progress_bar_baseline_;
|
||||||
|
|
||||||
|
@ -36,17 +40,30 @@ class WearRecoveryUI : public ScreenRecoveryUI {
|
||||||
// Recovery, build id and etc) and the bottom lines that may otherwise go out of the screen.
|
// Recovery, build id and etc) and the bottom lines that may otherwise go out of the screen.
|
||||||
const int menu_unusable_rows_;
|
const int menu_unusable_rows_;
|
||||||
|
|
||||||
|
const bool is_screen_circle_;
|
||||||
|
|
||||||
std::unique_ptr<Menu> CreateMenu(const std::vector<std::string>& text_headers,
|
std::unique_ptr<Menu> CreateMenu(const std::vector<std::string>& text_headers,
|
||||||
const std::vector<std::string>& text_items,
|
const std::vector<std::string>& text_items,
|
||||||
size_t initial_selection) const override;
|
size_t initial_selection) const override;
|
||||||
|
|
||||||
int GetProgressBaseline() const override;
|
int GetProgressBaseline() const override;
|
||||||
|
|
||||||
|
int GetTextBaseline() const override;
|
||||||
|
|
||||||
void update_progress_locked() override;
|
void update_progress_locked() override;
|
||||||
|
|
||||||
|
void LoadAnimation() override;
|
||||||
|
|
||||||
|
bool IsWearable() override;
|
||||||
|
|
||||||
|
void SetProgress(float fraction) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void draw_background_locked() override;
|
void draw_background_locked() override;
|
||||||
void draw_screen_locked() override;
|
void draw_screen_locked() override;
|
||||||
|
void draw_circle_foreground_locked();
|
||||||
|
size_t GetProgressFrameIndex(float fraction) const;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // RECOVERY_WEAR_UI_H
|
#endif // RECOVERY_WEAR_UI_H
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
#include "otautil/paths.h"
|
||||||
#include "recovery_ui/wear_ui.h"
|
#include "recovery_ui/wear_ui.h"
|
||||||
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
@ -23,24 +24,25 @@
|
||||||
|
|
||||||
#include <android-base/properties.h>
|
#include <android-base/properties.h>
|
||||||
#include <android-base/strings.h>
|
#include <android-base/strings.h>
|
||||||
|
|
||||||
#include <minui/minui.h>
|
#include <minui/minui.h>
|
||||||
|
|
||||||
constexpr int kDefaultProgressBarBaseline = 259;
|
constexpr int kDefaultProgressBarBaseline = 259;
|
||||||
constexpr int kDefaultMenuUnusableRows = 9;
|
constexpr int kDefaultMenuUnusableRows = 9;
|
||||||
|
constexpr int kProgressBarVerticalOffsetDp = 72;
|
||||||
|
constexpr bool kDefaultIsScreenCircle = true;
|
||||||
|
|
||||||
WearRecoveryUI::WearRecoveryUI()
|
WearRecoveryUI::WearRecoveryUI()
|
||||||
: ScreenRecoveryUI(true),
|
: ScreenRecoveryUI(true),
|
||||||
progress_bar_baseline_(android::base::GetIntProperty("ro.recovery.ui.progress_bar_baseline",
|
progress_bar_baseline_(android::base::GetIntProperty("ro.recovery.ui.progress_bar_baseline",
|
||||||
kDefaultProgressBarBaseline)),
|
kDefaultProgressBarBaseline)),
|
||||||
menu_unusable_rows_(android::base::GetIntProperty("ro.recovery.ui.menu_unusable_rows",
|
menu_unusable_rows_(android::base::GetIntProperty("ro.recovery.ui.menu_unusable_rows",
|
||||||
kDefaultMenuUnusableRows)) {
|
kDefaultMenuUnusableRows)),
|
||||||
|
is_screen_circle_(android::base::GetBoolProperty("ro.recovery.ui.is_screen_circle",
|
||||||
|
kDefaultIsScreenCircle)) {
|
||||||
// TODO: menu_unusable_rows_ should be computed based on the lines in draw_screen_locked().
|
// TODO: menu_unusable_rows_ should be computed based on the lines in draw_screen_locked().
|
||||||
|
|
||||||
touch_screen_allowed_ = true;
|
touch_screen_allowed_ = true;
|
||||||
}
|
SetEnableFastbootdLogo(false); // logo not required on Wear
|
||||||
|
|
||||||
int WearRecoveryUI::GetProgressBaseline() const {
|
|
||||||
return progress_bar_baseline_;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw background frame on the screen. Does not flip pages.
|
// Draw background frame on the screen. Does not flip pages.
|
||||||
|
@ -51,40 +53,152 @@ void WearRecoveryUI::draw_background_locked() {
|
||||||
gr_color(0, 0, 0, 255);
|
gr_color(0, 0, 0, 255);
|
||||||
gr_fill(0, 0, gr_fb_width(), gr_fb_height());
|
gr_fill(0, 0, gr_fb_width(), gr_fb_height());
|
||||||
|
|
||||||
if (current_icon_ != NONE) {
|
if (current_icon_ == ERROR) {
|
||||||
const auto& frame = GetCurrentFrame();
|
const auto& frame = GetCurrentFrame();
|
||||||
int frame_width = gr_get_width(frame);
|
int frame_width = gr_get_width(frame);
|
||||||
int frame_height = gr_get_height(frame);
|
int frame_height = gr_get_height(frame);
|
||||||
int frame_x = (gr_fb_width() - frame_width) / 2;
|
int frame_x = (gr_fb_width() - frame_width) / 2;
|
||||||
int frame_y = (gr_fb_height() - frame_height) / 2;
|
int frame_y = (gr_fb_height() - frame_height) / 2;
|
||||||
gr_blit(frame, 0, 0, frame_width, frame_height, frame_x, frame_y);
|
gr_blit(frame, 0, 0, frame_width, frame_height, frame_x, frame_y);
|
||||||
|
}
|
||||||
|
|
||||||
// Draw recovery text on screen above progress bar.
|
if (current_icon_ != NONE) {
|
||||||
|
// Draw recovery text on screen centered
|
||||||
const auto& text = GetCurrentText();
|
const auto& text = GetCurrentText();
|
||||||
int text_x = (ScreenWidth() - gr_get_width(text)) / 2;
|
int text_x = (ScreenWidth() - gr_get_width(text)) / 2;
|
||||||
int text_y = GetProgressBaseline() - gr_get_height(text) - 10;
|
int text_y = (ScreenHeight() - gr_get_height(text)) / 2;
|
||||||
gr_color(255, 255, 255, 255);
|
gr_color(255, 255, 255, 255);
|
||||||
gr_texticon(text_x, text_y, text);
|
gr_texticon(text_x, text_y, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void WearRecoveryUI::draw_screen_locked() {
|
void WearRecoveryUI::draw_screen_locked() {
|
||||||
draw_background_locked();
|
|
||||||
if (!show_text) {
|
if (!show_text) {
|
||||||
draw_foreground_locked();
|
draw_background_locked();
|
||||||
} else {
|
if (is_screen_circle_) {
|
||||||
SetColor(UIElement::TEXT_FILL);
|
draw_circle_foreground_locked();
|
||||||
gr_fill(0, 0, gr_fb_width(), gr_fb_height());
|
} else {
|
||||||
|
draw_foreground_locked();
|
||||||
// clang-format off
|
}
|
||||||
static std::vector<std::string> SWIPE_HELP = {
|
return;
|
||||||
"Swipe up/down to move.",
|
|
||||||
"Swipe left/right to select.",
|
|
||||||
"",
|
|
||||||
};
|
|
||||||
// clang-format on
|
|
||||||
draw_menu_and_text_buffer_locked(SWIPE_HELP);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetColor(UIElement::TEXT_FILL);
|
||||||
|
gr_clear();
|
||||||
|
|
||||||
|
// clang-format off
|
||||||
|
static std::vector<std::string> SWIPE_HELP = {
|
||||||
|
"Swipe up/down to move.",
|
||||||
|
"Swipe left/right to select.",
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
// clang-format on
|
||||||
|
draw_menu_and_text_buffer_locked(SWIPE_HELP);
|
||||||
|
}
|
||||||
|
|
||||||
|
void WearRecoveryUI::draw_circle_foreground_locked() {
|
||||||
|
if (current_icon_ != NONE) {
|
||||||
|
const auto& frame = GetCurrentFrame();
|
||||||
|
int frame_width = gr_get_width(frame);
|
||||||
|
int frame_height = gr_get_height(frame);
|
||||||
|
int frame_x = (ScreenWidth() - frame_width) / 2;
|
||||||
|
int frame_y = GetAnimationBaseline();
|
||||||
|
DrawSurface(frame, 0, 0, frame_width, frame_height, frame_x, frame_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressBarType == DETERMINATE) {
|
||||||
|
const auto& first_progress_frame = rtl_locale_ ? rtl_progress_frames_[0].get()
|
||||||
|
:progress_frames_[0].get();
|
||||||
|
int width = gr_get_width(first_progress_frame);
|
||||||
|
int height = gr_get_height(first_progress_frame);
|
||||||
|
|
||||||
|
int progress_x = (ScreenWidth() - width) / 2;
|
||||||
|
int progress_y = GetProgressBaseline();
|
||||||
|
|
||||||
|
const auto index = GetProgressFrameIndex(progress);
|
||||||
|
const auto& frame = rtl_locale_ ? rtl_progress_frames_[index].get()
|
||||||
|
: progress_frames_[index].get();
|
||||||
|
|
||||||
|
DrawSurface(frame, 0, 0, width, height, progress_x, progress_y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WearRecoveryUI::LoadAnimation() {
|
||||||
|
ScreenRecoveryUI::LoadAnimation();
|
||||||
|
std::unique_ptr<DIR, decltype(&closedir)> dir(opendir(Paths::Get().resource_dir().c_str()),
|
||||||
|
closedir);
|
||||||
|
dirent* de;
|
||||||
|
std::vector<std::string> progress_frame_names;
|
||||||
|
std::vector<std::string> rtl_progress_frame_names;
|
||||||
|
|
||||||
|
if(dir.get() == nullptr) abort();
|
||||||
|
|
||||||
|
while ((de = readdir(dir.get())) != nullptr) {
|
||||||
|
int value, num_chars;
|
||||||
|
if (sscanf(de->d_name, "progress%d%n.png", &value, &num_chars) == 1) {
|
||||||
|
progress_frame_names.emplace_back(de->d_name, num_chars);
|
||||||
|
} else if (sscanf(de->d_name, "rtl_progress%d%n.png", &value, &num_chars) == 1) {
|
||||||
|
rtl_progress_frame_names.emplace_back(de->d_name, num_chars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t progress_frames = progress_frame_names.size();
|
||||||
|
size_t rtl_progress_frames = rtl_progress_frame_names.size();
|
||||||
|
|
||||||
|
// You must have an animation.
|
||||||
|
if (progress_frames == 0 || rtl_progress_frames == 0) abort();
|
||||||
|
|
||||||
|
std::sort(progress_frame_names.begin(), progress_frame_names.end());
|
||||||
|
std::sort(rtl_progress_frame_names.begin(), rtl_progress_frame_names.end());
|
||||||
|
|
||||||
|
progress_frames_.clear();
|
||||||
|
progress_frames_.reserve(progress_frames);
|
||||||
|
for (const auto& frame_name : progress_frame_names) {
|
||||||
|
progress_frames_.emplace_back(LoadBitmap(frame_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
rtl_progress_frames_.clear();
|
||||||
|
rtl_progress_frames_.reserve(rtl_progress_frames);
|
||||||
|
for (const auto& frame_name : rtl_progress_frame_names) {
|
||||||
|
rtl_progress_frames_.emplace_back(LoadBitmap(frame_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WearRecoveryUI::SetProgress(float fraction) {
|
||||||
|
if (is_screen_circle_) {
|
||||||
|
std::lock_guard<std::mutex> lg(updateMutex);
|
||||||
|
if (fraction < 0.0) fraction = 0.0;
|
||||||
|
if (fraction > 1.0) fraction = 1.0;
|
||||||
|
if (progressBarType == DETERMINATE && fraction > progress) {
|
||||||
|
// Skip updates that aren't visibly different.
|
||||||
|
if (GetProgressFrameIndex(fraction) != GetProgressFrameIndex(progress)) {
|
||||||
|
// circular display
|
||||||
|
progress = fraction;
|
||||||
|
update_progress_locked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// rectangular display
|
||||||
|
ScreenRecoveryUI::SetProgress(fraction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int WearRecoveryUI::GetProgressBaseline() const {
|
||||||
|
int progress_height = gr_get_height(progress_frames_[0].get());
|
||||||
|
return (ScreenHeight() - progress_height) / 2 + PixelsFromDp(kProgressBarVerticalOffsetDp);
|
||||||
|
}
|
||||||
|
|
||||||
|
int WearRecoveryUI::GetTextBaseline() const {
|
||||||
|
if (is_screen_circle_) {
|
||||||
|
return GetProgressBaseline() - PixelsFromDp(kProgressBarVerticalOffsetDp) -
|
||||||
|
gr_get_height(installing_text_.get());
|
||||||
|
} else {
|
||||||
|
return ScreenRecoveryUI::GetTextBaseline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t WearRecoveryUI::GetProgressFrameIndex(float fraction) const {
|
||||||
|
return static_cast<size_t>(fraction * (progress_frames_.size() - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO merge drawing routines with screen_ui
|
// TODO merge drawing routines with screen_ui
|
||||||
|
@ -93,6 +207,10 @@ void WearRecoveryUI::update_progress_locked() {
|
||||||
gr_flip();
|
gr_flip();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool WearRecoveryUI::IsWearable() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void WearRecoveryUI::SetStage(int /* current */, int /* max */) {}
|
void WearRecoveryUI::SetStage(int /* current */, int /* max */) {}
|
||||||
|
|
||||||
std::unique_ptr<Menu> WearRecoveryUI::CreateMenu(const std::vector<std::string>& text_headers,
|
std::unique_ptr<Menu> WearRecoveryUI::CreateMenu(const std::vector<std::string>& text_headers,
|
||||||
|
|
36
tools/image_generator/draw-progress.sh
Normal file
36
tools/image_generator/draw-progress.sh
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# arc central angle in degrees
|
||||||
|
arc_size="64.5"
|
||||||
|
|
||||||
|
arc_start=$(bc -l <<< "90 - $arc_size / 2")
|
||||||
|
arc_end=$(bc -l <<< "90 + $arc_size / 2")
|
||||||
|
|
||||||
|
N=100
|
||||||
|
for ((i=0; i < $N; i++)); do
|
||||||
|
progress=$(bc -l <<< "$i / ($N - 1)")
|
||||||
|
fg_arc_start=$(bc -l <<< "$arc_end - $progress * $arc_size")
|
||||||
|
|
||||||
|
filename="progress$(printf "%02d" $i).png"
|
||||||
|
echo "-- Writing file: $filename"
|
||||||
|
|
||||||
|
convert -size 400x400 xc:black \
|
||||||
|
-draw "stroke-linecap round stroke-width 8 \
|
||||||
|
stroke gray ellipse 200,200 100,100 $arc_start,$arc_end \
|
||||||
|
stroke white ellipse 200,200 100,100 $fg_arc_start,$arc_end" "$filename"
|
||||||
|
|
||||||
|
echo "-- Writing file: rtl_$filename"
|
||||||
|
convert -size 400x400 xc:black \
|
||||||
|
-draw "stroke-linecap round stroke-width 8 \
|
||||||
|
stroke gray ellipse 200,200 100,100 $arc_start,$arc_end \
|
||||||
|
stroke white ellipse 200,200 100,100 $fg_arc_start,$arc_end" "rtl_$filename"
|
||||||
|
|
||||||
|
mogrify -crop 120x30+140+280 "$filename"
|
||||||
|
mogrify -crop 120x30+140+280 "rtl_$filename"
|
||||||
|
|
||||||
|
# Use color format recovery can use
|
||||||
|
mogrify -define png:format=png24 -type TrueColor "$filename"
|
||||||
|
mogrify -define png:format=png24 -type TrueColor "rtl_$filename"
|
||||||
|
|
||||||
|
mogrify -flop "rtl_$filename"
|
||||||
|
done
|
Loading…
Reference in a new issue