diff --git a/lib/release/src/auto_update.h b/lib/release/src/auto_update.h new file mode 100644 index 0000000..1f44d84 --- /dev/null +++ b/lib/release/src/auto_update.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include + +namespace roro::release { + +// Installing a release without being asked to (issue #52): once a newer one is known, wait for a +// quiet moment (nothing recording, nothing written, no key for a while), then say so on the +// screen for 30 seconds. Unless someone answers no, it is installed. Host-tested; the caller says +// what "quiet" is and does the installing. +class AutoUpdate { + public: + static constexpr uint32_t kIdleMs = 120000; // no key for this long: nobody is using the device + static constexpr uint32_t kAskMs = 30000; // how long the question stays before it answers itself + + enum class Action : uint8_t { + None, + Ask, // the question goes up + Dismiss, // the question comes down, nothing is installed + Install, // the question comes down, install tag() + }; + + // A newer release is known. A second offer while one is waiting or asked replaces its tag. + void offer(const std::string& tag) { + tag_ = tag; + if (state_ == State::Idle) state_ = State::Waiting; + } + // The setting went off, or the release is no longer wanted. + void withdraw() { withdrawn_ = true; } + // The user's answer to the question. + void answer(bool install) { + if (state_ != State::Asking) return; + answered_ = true; + accepted_ = install; + } + + // Call often. `busy`: something is recording or being written, and a restart would cut it. + // `idleMs`: how long since the last key. + Action step(uint32_t nowMs, bool busy, uint32_t idleMs) { + bool withdrawn = withdrawn_; + withdrawn_ = false; + switch (state_) { + case State::Idle: return Action::None; + case State::Waiting: + if (withdrawn) return state_ = State::Idle, Action::None; + if (busy || idleMs < kIdleMs) return Action::None; + state_ = State::Asking; + askedMs_ = nowMs; + answered_ = false; + return Action::Ask; + case State::Asking: + if (withdrawn) return state_ = State::Idle, Action::Dismiss; + if (answered_) return state_ = State::Idle, accepted_ ? Action::Install : Action::Dismiss; + // Something started meanwhile (a Track from a console): the question goes, and comes back later. + if (busy) return state_ = State::Waiting, Action::Dismiss; + if (nowMs - askedMs_ >= kAskMs) return state_ = State::Idle, Action::Install; + return Action::None; + } + return Action::None; + } + + bool waiting() const { return state_ == State::Waiting; } + bool asking() const { return state_ == State::Asking; } + const std::string& tag() const { return tag_; } + // Whole seconds until the question answers itself, rounded up. + int secondsLeft(uint32_t nowMs) const { + uint32_t gone = nowMs - askedMs_; + return !asking() || gone >= kAskMs ? 0 : static_cast((kAskMs - gone + 999) / 1000); + } + + private: + enum class State : uint8_t { Idle, Waiting, Asking }; + State state_ = State::Idle; + std::string tag_; + uint32_t askedMs_ = 0; + bool answered_ = false, accepted_ = false, withdrawn_ = false; +}; + +} // namespace roro::release diff --git a/lib/release/src/update_check.h b/lib/release/src/update_check.h index bd2d998..f12c02e 100644 --- a/lib/release/src/update_check.h +++ b/lib/release/src/update_check.h @@ -24,6 +24,12 @@ inline bool shouldAnnounce(const Release& latest, const std::string& running, co return isNewer(latest, running) && latest.tag != failed && latest.tag != announced; } +// Should the background check offer to install it by itself (issue #52)? Any newer release, each +// day it is still the newest; never a version that rolled back on this device. +inline bool shouldInstall(const Release& latest, const std::string& running, const std::string& failed) { + return isNewer(latest, running) && latest.tag != failed; +} + // Is `url` a download this device takes from the project's server, for this repository? Whatever // the API says, the device only fetches from where it asked (a redirected or rewritten answer // can't send it elsewhere). diff --git a/test/test_auto_update/test_auto_update.cpp b/test/test_auto_update/test_auto_update.cpp new file mode 100644 index 0000000..98d022f --- /dev/null +++ b/test/test_auto_update/test_auto_update.cpp @@ -0,0 +1,137 @@ +#include + +#include "auto_update.h" +#include "update_check.h" + +using namespace roro::release; +using Action = AutoUpdate::Action; + +void setUp() {} +void tearDown() {} + +namespace { +constexpr uint32_t kIdle = AutoUpdate::kIdleMs, kAsk = AutoUpdate::kAskMs; +Release release(const char* tag) { + Release r; + r.tag = tag; + r.otaUrl = "https://git.twis.la/twisla/roro9stack/releases/download/x/x.ota"; + r.otaSize = 1; + return r; +} +} // namespace + +void test_nothing_happens_without_an_offer() { + AutoUpdate a; + TEST_ASSERT_TRUE(a.step(1000, false, kIdle) == Action::None); + TEST_ASSERT_FALSE(a.waiting()); + TEST_ASSERT_FALSE(a.asking()); +} + +void test_it_waits_for_a_quiet_moment() { + AutoUpdate a; + a.offer("v1.2.0"); + TEST_ASSERT_TRUE(a.waiting()); + TEST_ASSERT_TRUE(a.step(1000, false, kIdle - 1) == Action::None); // a key not long ago + TEST_ASSERT_TRUE(a.step(2000, true, kIdle * 10) == Action::None); // a Track is recording + TEST_ASSERT_TRUE(a.step(3000, false, kIdle) == Action::Ask); + TEST_ASSERT_TRUE(a.asking()); + TEST_ASSERT_EQUAL_STRING("v1.2.0", a.tag().c_str()); +} + +void test_unanswered_it_installs_after_30_seconds() { + AutoUpdate a; + a.offer("v1.2.0"); + a.step(5000, false, kIdle); + TEST_ASSERT_EQUAL(30, a.secondsLeft(5000)); + TEST_ASSERT_EQUAL(30, a.secondsLeft(5001)); + TEST_ASSERT_EQUAL(1, a.secondsLeft(5000 + kAsk - 1)); + TEST_ASSERT_TRUE(a.step(5000 + kAsk - 1, false, kIdle) == Action::None); + TEST_ASSERT_TRUE(a.step(5000 + kAsk, false, kIdle) == Action::Install); + TEST_ASSERT_FALSE(a.asking()); + TEST_ASSERT_TRUE(a.step(5000 + kAsk + 1, false, kIdle) == Action::None); // once +} + +void test_accept_installs_at_once() { + AutoUpdate a; + a.offer("v1.2.0"); + a.step(0, false, kIdle); + a.answer(true); + TEST_ASSERT_TRUE(a.step(100, false, 0) == Action::Install); +} + +void test_cancel_drops_it_until_the_next_offer() { + AutoUpdate a; + a.offer("v1.2.0"); + a.step(0, false, kIdle); + a.answer(false); + TEST_ASSERT_TRUE(a.step(100, false, 0) == Action::Dismiss); + TEST_ASSERT_TRUE(a.step(kAsk * 2, false, kIdle * 2) == Action::None); + a.offer("v1.2.0"); // the next day's check + TEST_ASSERT_TRUE(a.step(kAsk * 3, false, kIdle) == Action::Ask); +} + +void test_something_starting_takes_the_question_down() { + AutoUpdate a; + a.offer("v1.2.0"); + a.step(0, false, kIdle); + TEST_ASSERT_TRUE(a.step(1000, true, kIdle) == Action::Dismiss); + TEST_ASSERT_TRUE(a.waiting()); + TEST_ASSERT_TRUE(a.step(2000, true, kIdle) == Action::None); + TEST_ASSERT_TRUE(a.step(90000, false, kIdle) == Action::Ask); // and it comes back, with a full count + TEST_ASSERT_EQUAL(30, a.secondsLeft(90000)); +} + +void test_the_setting_going_off_withdraws_it() { + AutoUpdate a; + a.offer("v1.2.0"); + a.withdraw(); + TEST_ASSERT_TRUE(a.step(0, false, kIdle) == Action::None); + TEST_ASSERT_FALSE(a.waiting()); + a.offer("v1.2.0"); + a.step(0, false, kIdle); + a.withdraw(); + TEST_ASSERT_TRUE(a.step(10, false, kIdle) == Action::Dismiss); + TEST_ASSERT_FALSE(a.asking()); +} + +void test_an_answer_is_only_for_a_question() { + AutoUpdate a; + a.offer("v1.2.0"); + a.answer(true); // nothing was asked yet + TEST_ASSERT_TRUE(a.step(0, false, kIdle) == Action::Ask); + TEST_ASSERT_TRUE(a.step(1, false, kIdle) == Action::None); +} + +void test_a_newer_offer_replaces_the_tag() { + AutoUpdate a; + a.offer("v1.2.0"); + a.offer("v1.3.0"); + a.step(0, false, kIdle); + TEST_ASSERT_EQUAL_STRING("v1.3.0", a.tag().c_str()); +} + +void test_what_is_offered() { + // Newer than what runs, and not a version that rolled back here. + TEST_ASSERT_TRUE(shouldInstall(release("v1.2.0"), "v1.1.0", "")); + TEST_ASSERT_FALSE(shouldInstall(release("v1.1.0"), "v1.1.0", "")); + TEST_ASSERT_FALSE(shouldInstall(release("v1.0.0"), "v1.1.0", "")); + TEST_ASSERT_FALSE(shouldInstall(release("v1.2.0"), "v1.1.0", "v1.2.0")); + Release noFile = release("v1.2.0"); + noFile.otaUrl.clear(); + TEST_ASSERT_FALSE(shouldInstall(noFile, "v1.1.0", "")); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_nothing_happens_without_an_offer); + RUN_TEST(test_it_waits_for_a_quiet_moment); + RUN_TEST(test_unanswered_it_installs_after_30_seconds); + RUN_TEST(test_accept_installs_at_once); + RUN_TEST(test_cancel_drops_it_until_the_next_offer); + RUN_TEST(test_something_starting_takes_the_question_down); + RUN_TEST(test_the_setting_going_off_withdraws_it); + RUN_TEST(test_an_answer_is_only_for_a_question); + RUN_TEST(test_a_newer_offer_replaces_the_tag); + RUN_TEST(test_what_is_offered); + return UNITY_END(); +}