Page MenuHomePhorge

D309.1781323073.diff
No OneTemporary

Size
23 KB
Referenced Files
None
Subscribers
None

D309.1781323073.diff

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -35,7 +35,7 @@
- mkdir -pv "$TMPDIR"
- chown -R podman:podman "$TMPDIR"
- sudo -u podman TMPDIR="$TMPDIR" podman login -u "$REGISTRY_USER" -p "$REGISTRY_PASSWORD" "$REGISTRY"
- - sudo -u podman TMPDIR="$TMPDIR" podman build -f "$CI_PROJECT_DIR"/Containerfile --build-arg JOBS=3 --build-arg BASE_IMG_TAG=$BASE_IMG_TAG --build-arg BUILD_TYPE=$BUILD_TYPE $BUILD_ARGS -t "$CANONICAL_TAG" "$CI_PROJECT_DIR"
+ - sudo -u podman TMPDIR="$TMPDIR" podman build -f "$CI_PROJECT_DIR"/Containerfile --build-arg JOBS=3 --build-arg BASE_IMG_TAG=$BASE_IMG_TAG --build-arg BUILD_TYPE=$BUILD_TYPE --build-arg LIBKAZV_ERROR_ON_WARNING=OFF $BUILD_ARGS -t "$CANONICAL_TAG" "$CI_PROJECT_DIR"
# Only branch and tag commits should trigger pushing
- |
if [ -z "$CI_COMMIT_BRANCH" -a -z "$CI_COMMIT_TAG" ]; then
@@ -98,7 +98,7 @@
ccache --show-stats || true
mkdir build && cd build && \
cmake .. -DCMAKE_INSTALL_PREFIX="$LIBKAZV_INSTALL_DIR" -DCMAKE_PREFIX_PATH="$DEPS_INSTALL_DIR" -DCMAKE_BUILD_TYPE=$BUILD_TYPE -Dlibkazv_BUILD_TESTS=ON \
- -Dlibkazv_BUILD_EXAMPLES=ON -Dlibkazv_BUILD_KAZVJOB=ON -Dlibkazv_ENABLE_COVERAGE=ON -DCMAKE_CXX_FLAGS=-fsanitize=address && \
+ -Dlibkazv_BUILD_EXAMPLES=ON -Dlibkazv_BUILD_KAZVJOB=ON -Dlibkazv_ENABLE_COVERAGE=ON -DCMAKE_CXX_FLAGS=-fsanitize=address -Dlibkazv_ERROR_ON_WARNING=OFF && \
make -j$JOBS && \
make CTEST_OUTPUT_ON_FAILURE=1 test && \
gcovr --xml-pretty --exclude-unreachable-branches --print-summary -o coverage.xml -r "${CI_PROJECT_DIR}" --object-directory src -e '.*/api/.*' -e '.*/tests/.*' -e '.*/testfixtures/.*' -e '.*/examples/.*'
diff --git a/CMakeLists.txt b/CMakeLists.txt
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -26,11 +26,17 @@
option(libkazv_BUILD_KAZVJOB "Build libkazvjob the async and networking library" ON)
option(libkazv_OUTPUT_LEVEL "Output level: Debug>=90, Info>=70, Quiet>=20, no output=1" 0)
option(libkazv_ENABLE_COVERAGE "Enable code coverage information" OFF)
+option(libkazv_ERROR_ON_WARNING "Turn compiler warnings to errors. Only use for development purposes." OFF)
if(libkazv_ENABLE_COVERAGE)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fprofile-arcs -ftest-coverage -fPIC -O0")
endif()
+if(libkazv_ERROR_ON_WARNING)
+ message(STATUS "Turning compiler warnings to errors. This should only be used for development purposes.")
+ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Werror")
+endif()
+
if((libkazv_BUILD_TESTS OR libkazv_BUILD_EXAMPLES) AND NOT libkazv_BUILD_KAZVJOB)
message(FATAL_ERROR
"You asked kazvjob not to be built, but asked to build tests or examples. Tests and examples both depend on kazvjob. This is not possible.")
diff --git a/Containerfile b/Containerfile
--- a/Containerfile
+++ b/Containerfile
@@ -11,6 +11,7 @@
ARG DEPS_INSTALL_DIR=/opt/libkazv-deps
ARG LIBKAZV_INSTALL_DIR=/opt/libkazv
+ARG LIBKAZV_ERROR_ON_WARNING=OFF
RUN --mount=type=cache,id=ccache,target=/ccache \
export CCACHE_COMPILERCHECK=content \
@@ -20,7 +21,7 @@
CC=/usr/lib/ccache/gcc CXX=/usr/lib/ccache/g++ && \
ccache --zero-stats; ccache --show-stats; \
mkdir build && cd build && \
- cmake .. -DCMAKE_INSTALL_PREFIX="$LIBKAZV_INSTALL_DIR" -DCMAKE_PREFIX_PATH="$DEPS_INSTALL_DIR" -DCMAKE_BUILD_TYPE=$BUILD_TYPE -Dlibkazv_BUILD_TESTS=ON -Dlibkazv_BUILD_EXAMPLES=ON -Dlibkazv_BUILD_KAZVJOB=ON && \
+ cmake .. -DCMAKE_INSTALL_PREFIX="$LIBKAZV_INSTALL_DIR" -DCMAKE_PREFIX_PATH="$DEPS_INSTALL_DIR" -DCMAKE_BUILD_TYPE=$BUILD_TYPE -Dlibkazv_BUILD_TESTS=ON -Dlibkazv_BUILD_EXAMPLES=ON -Dlibkazv_BUILD_KAZVJOB=ON -Dlibkazv_ERROR_ON_WARNING="$LIBKAZV_ERROR_ON_WARNING" && \
make -j$JOBS && \
make test && \
make -j$JOBS install && \
diff --git a/src/client/actions/auth.cpp b/src/client/actions/auth.cpp
--- a/src/client/actions/auth.cpp
+++ b/src/client/actions/auth.cpp
@@ -182,7 +182,7 @@
m.versions = r.versions();
return {
std::move(m),
- [r](auto &&ctx) {
+ [r]([[maybe_unused]] auto &&ctx) {
return EffectStatus(r.success(), json{
{"versions", r.versions()},
});
diff --git a/src/client/client-model.cpp b/src/client/client-model.cpp
--- a/src/client/client-model.cpp
+++ b/src/client/client-model.cpp
@@ -382,7 +382,7 @@
if (! changedUsers.empty()) {
for (auto roomId : roomIds) {
auto it = std::find_if(changedUsers.begin(), changedUsers.end(),
- [=](auto userId) { return roomList.rooms[roomId].hasUser(userId); });
+ [=, this](auto userId) { return roomList.rooms[roomId].hasUser(userId); });
if (it != changedUsers.end()) {
kzo.client.dbg() << "rotate keys for room " << roomId << std::endl;
markRotate(roomId);
diff --git a/src/client/client.cpp b/src/client/client.cpp
--- a/src/client/client.cpp
+++ b/src/client/client.cpp
@@ -193,7 +193,7 @@
auto Client::logout() const
-> PromiseT
{
- return stopSyncing().then([ctx=m_ctx] (auto stat) {
+ return stopSyncing().then([ctx=m_ctx] ([[maybe_unused]] auto stat) {
return ctx.dispatch(HardLogoutAction{});
});
}
diff --git a/src/client/push-rules-desc.cpp b/src/client/push-rules-desc.cpp
--- a/src/client/push-rules-desc.cpp
+++ b/src/client/push-rules-desc.cpp
@@ -69,7 +69,7 @@
{
boost::smatch m;
auto isStr = is.template get<std::string>();
- auto res = boost::regex_match(isStr, m, memberCountIsRegex);
+ boost::regex_match(isStr, m, memberCountIsRegex);
auto targetStr = m[2].str();
int target;
std::from_chars(targetStr.data(), targetStr.data() + targetStr.size(), target);
@@ -276,7 +276,7 @@
return false;
}
- PushAction PushRulesDescPrivate::handleRule(std::string ruleSetName, const json &rule, const Event &e, const RoomModel &room) const
+ PushAction PushRulesDescPrivate::handleRule([[maybe_unused]] std::string ruleSetName, const json &rule, [[maybe_unused]] const Event &e, [[maybe_unused]] const RoomModel &room) const
{
auto actions = rule.at("actions");
auto res = PushAction{
@@ -332,6 +332,6 @@
auto [ruleSetName, rule] = ruleOpt.value();
return m_d->handleRule(ruleSetName, rule, e, room);
}
- return {false};
+ return {false, std::nullopt, false};
}
}
diff --git a/src/client/room/local-echo.hpp b/src/client/room/local-echo.hpp
--- a/src/client/room/local-echo.hpp
+++ b/src/client/room/local-echo.hpp
@@ -34,7 +34,7 @@
namespace boost::serialization
{
template<class Archive>
- void save(Archive &ar, const Kazv::LocalEchoDesc &d, std::uint32_t const version)
+ void save(Archive &ar, const Kazv::LocalEchoDesc &d, [[maybe_unused]] std::uint32_t const version)
{
using namespace Kazv;
LocalEchoDesc::Status dummyStatus{LocalEchoDesc::Failed};
@@ -46,7 +46,7 @@
}
template<class Archive>
- void load(Archive &ar, Kazv::LocalEchoDesc &d, std::uint32_t const version)
+ void load(Archive &ar, Kazv::LocalEchoDesc &d, [[maybe_unused]] std::uint32_t const version)
{
using namespace Kazv;
LocalEchoDesc::Status dummyStatus{LocalEchoDesc::Failed};
diff --git a/src/crypto/crypto.cpp b/src/crypto/crypto.cpp
--- a/src/crypto/crypto.cpp
+++ b/src/crypto/crypto.cpp
@@ -305,12 +305,12 @@
return m_d->account.value()->max_number_of_one_time_keys();
}
- std::size_t Crypto::genOneTimeKeysRandomSize(int num)
+ std::size_t Crypto::genOneTimeKeysRandomSize([[maybe_unused]] int num)
{
return 0;
}
- void Crypto::genOneTimeKeysWithRandom(RandomData random, int num)
+ void Crypto::genOneTimeKeysWithRandom([[maybe_unused]] RandomData random, int num)
{
assert(random.size() >= genOneTimeKeysRandomSize(num));
@@ -595,12 +595,12 @@
for (auto [userId, devices] : keyMap) {
auto unknownDevices =
intoImmer(immer::flex_vector<std::string>{},
- zug::filter([=](auto kv) {
+ zug::filter([this](auto kv) {
auto [deviceId, theirCurve25519IdentityKey] = kv;
return m_d->knownSessions.find(theirCurve25519IdentityKey)
== m_d->knownSessions.end();
})
- | zug::map([=](auto kv) {
+ | zug::map([](auto kv) {
auto [deviceId, key] = kv;
return deviceId;
}),
diff --git a/src/crypto/session.cpp b/src/crypto/session.cpp
--- a/src/crypto/session.cpp
+++ b/src/crypto/session.cpp
@@ -20,11 +20,11 @@
}
SessionPrivate::SessionPrivate(OutboundSessionTag,
- RandomTag,
- RandomData random,
- CryptoPrivate &cryptoD,
- std::string theirIdentityKey,
- std::string theirOneTimeKey)
+ RandomTag,
+ [[maybe_unused]] RandomData random,
+ CryptoPrivate &cryptoD,
+ std::string theirIdentityKey,
+ std::string theirOneTimeKey)
: SessionPrivate()
{
assert(random.size() >= Session::constructOutboundRandomSize());
@@ -216,7 +216,7 @@
return 0;
}
- std::pair<int, std::string> Session::encryptWithRandom(RandomData random, std::string plainText)
+ std::pair<int, std::string> Session::encryptWithRandom([[maybe_unused]] RandomData random, std::string plainText)
{
assert(random.size() >= encryptRandomSize());
diff --git a/src/eventemitter/lagerstoreeventemitter.hpp b/src/eventemitter/lagerstoreeventemitter.hpp
--- a/src/eventemitter/lagerstoreeventemitter.hpp
+++ b/src/eventemitter/lagerstoreeventemitter.hpp
@@ -66,11 +66,11 @@
}
if (needsCleanup) {
- std::remove_if(m_listeners.begin(),
+ m_listeners.erase(std::remove_if(m_listeners.begin(),
m_listeners.end(),
[](auto ptr) {
return ptr.expired();
- });
+ }), m_listeners.end());
}
}
@@ -152,7 +152,7 @@
private:
void addListener(ListenerSP listener) {
- m_postingFunc([=]() {
+ m_postingFunc([=, this]() {
m_holder.m_listeners.push_back(listener);
});
}
diff --git a/src/job/cprjobhandler.cpp b/src/job/cprjobhandler.cpp
--- a/src/job/cprjobhandler.cpp
+++ b/src/job/cprjobhandler.cpp
@@ -129,7 +129,7 @@
void addToQueue(BaseJob job, Callback callback) {
boost::asio::post(
executor,
- [=] {
+ [=, this] {
// precondition: job has a queueId
auto queueId = job.queueId().value();
@@ -140,7 +140,7 @@
void clearQueue(std::string queueId) {
boost::asio::post(
executor,
- [=] { clearQueueImpl(queueId); });
+ [=, this] { clearQueueImpl(queueId); });
}
void clearQueueImpl(std::string queueId) {
@@ -156,7 +156,7 @@
kzo.job.dbg() << "this job is " << (status == Waiting ? "Waiting" : "Running") << std::endl;
if (status == Waiting) {
// Run callback in a different thread, just as in submitImpl().
- q->async([=] { callback(job.genResponse(fakeResponse)); } );
+ q->async([=, this] { callback(job.genResponse(fakeResponse)); } );
}
// if status is Running, the callback is already called
}
@@ -166,7 +166,7 @@
void popJob(std::string queueId) {
boost::asio::post(
executor,
- [=] { popJobImpl(queueId); });
+ [=, this] { popJobImpl(queueId); });
}
void popJobImpl(std::string queueId) {
@@ -178,7 +178,7 @@
void monitorQueues() {
boost::asio::post(
executor,
- [=] {
+ [=, this] {
// precondition: job has a queueId
for (auto &[queueId, queue] : jobQueues) { // need to change queue
if (! queue.empty()) {
@@ -187,7 +187,7 @@
queue.front().status = Running;
submitImpl(
job,
- [=](Response r) { // in new thread
+ [=, this](Response r) { // in new thread
callback(r);
if (! r.success() // should be enough for now
@@ -206,7 +206,7 @@
void addTimerToMap(TimerSP timer, std::optional<std::string> timerId) {
boost::asio::post(
executor,
- [=] {
+ [=, this] {
timerMap[timerId].push_back(timer);
});
}
@@ -214,15 +214,16 @@
void clearTimer(TimerSP timer, std::optional<std::string> timerId) {
boost::asio::post(
executor,
- [=] {
- std::remove(timerMap[timerId].begin(), timerMap[timerId].end(), timer);
+ [=, this] {
+ auto &timers = timerMap[timerId];
+ timers.erase(std::remove(timers.begin(), timers.end(), timer), timers.end());
});
}
void cancelAllTimers(std::optional<std::string> timerId) {
boost::asio::post(
executor,
- [=] {
+ [=, this] {
cancelAllTimersImpl(timerId);
});
}
@@ -244,7 +245,7 @@
auto dur = boost::asio::chrono::milliseconds(ms);
timer->expires_at(timer->expiry() + dur);
timer->async_wait(
- [=](const boost::system::error_code &error) {
+ [=, this](const boost::system::error_code &error) {
intervalTimerCallback(timer, func, ms, error);
});
}
@@ -287,7 +288,7 @@
: m_d(new Private{this, std::move(executor), Private::TimerMap{}, Private::JobMap{}})
{
setInterval(
- [=] {
+ [=, this] {
m_d->monitorQueues();
},
50, // ms
@@ -311,7 +312,7 @@
m_d->addTimerToMap(timer, timerId);
timer->async_wait(
- [=, timer=timer](const boost::system::error_code &error){
+ [=, this, timer=timer](const boost::system::error_code &error){
if (! error) {
func();
this->m_d->clearTimer(timer, timerId);
@@ -327,7 +328,7 @@
m_d->addTimerToMap(timer, timerId);
timer->async_wait(
- [=](const boost::system::error_code &error) {
+ [=, this](const boost::system::error_code &error) {
m_d->intervalTimerCallback(timer, func, ms, error);
});
}
@@ -459,7 +460,7 @@
std::shared_future<Response> res = std::visit(lager::visitor{
- [=](BaseJob::Get) {
+ [=, this](BaseJob::Get) {
if (readCallback) {
return cpr::GetCallback(callback, url, cpr::ReadCallback(readCallback), header, params);
} else if (writeCallback) {
@@ -468,7 +469,7 @@
return cpr::GetCallback(callback, url, header, body, params);
}
},
- [=](BaseJob::Post) {
+ [=, this](BaseJob::Post) {
if (readCallback) {
return cpr::PostCallback(callback, url, cpr::ReadCallback(readCallback), header, params);
} else if (writeCallback) {
@@ -477,7 +478,7 @@
return cpr::PostCallback(callback, url, header, body, params);
}
},
- [=](BaseJob::Put) {
+ [=, this](BaseJob::Put) {
if (readCallback) {
return cpr::PutCallback(callback, url, cpr::ReadCallback(readCallback), header, params);
} else if (writeCallback) {
@@ -486,7 +487,7 @@
return cpr::PutCallback(callback, url, header, body, params);
}
},
- [=](BaseJob::Delete) {
+ [=, this](BaseJob::Delete) {
if (readCallback) {
return cpr::DeleteCallback(callback, url, cpr::ReadCallback(readCallback), header, params);
} else if (writeCallback) {
@@ -497,7 +498,7 @@
}
}, method).share();
- q->async([=]() {
+ q->async([=, this]() {
userCallback(job.genResponse(res.get()));
});
}
@@ -506,7 +507,7 @@
{
boost::asio::post(
m_d->executor,
- [=] {
+ [=, this] {
auto ids = zug::into_vector(
zug::map([](auto i) { return i.first; }),
m_d->timerMap);
diff --git a/src/tests/crypto-test.cpp b/src/tests/crypto-test.cpp
--- a/src/tests/crypto-test.cpp
+++ b/src/tests/crypto-test.cpp
@@ -415,7 +415,10 @@
std::string encrypted;
// Can be moved from itself
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wself-move"
desc2 = std::move(desc2);
+#pragma GCC diagnostic pop
REQUIRE(desc2.valid());
std::tie(desc2, encrypted) = std::move(desc2).process(original);
diff --git a/src/tests/event-emitter-test.cpp b/src/tests/event-emitter-test.cpp
--- a/src/tests/event-emitter-test.cpp
+++ b/src/tests/event-emitter-test.cpp
@@ -80,4 +80,90 @@
REQUIRE(counter == 1);
REQUIRE(counter2 == 1);
}
+
+ SECTION("Expired listeners should be removed from internal list") {
+ auto guard = boost::asio::make_work_guard(ioContext.get_executor());
+ auto thread = std::thread([&] { ioContext.run(); });
+
+ std::vector<int> results;
+
+ {
+ auto w = ee.watchable();
+ w.after<ReceivingPresenceEvent>(
+ [&](auto) {
+ results.push_back(1);
+ });
+ }
+ // w is destroyed, listener expired
+
+ // Emit several events to trigger cleanup of expired listener
+ // via sendToListeners -> erase-remove
+ for (int i = 0; i < 5; ++i) {
+ ee.emit(ReceivingPresenceEvent{});
+ }
+
+ std::this_thread::sleep_for(std::chrono::milliseconds{100});
+
+ // Now register a new listener - if erase-remove works, the old
+ // expired listener was removed and no stale references remain
+ int newCounter = 0;
+ {
+ auto w2 = ee.watchable();
+ w2.after<ReceivingPresenceEvent>(
+ [&](auto) {
+ ++newCounter;
+ });
+
+ ee.emit(ReceivingPresenceEvent{});
+ ee.emit(ReceivingPresenceEvent{});
+ std::this_thread::sleep_for(std::chrono::milliseconds{100});
+ }
+
+ guard.reset();
+ thread.join();
+
+ REQUIRE(results.empty());
+ REQUIRE(newCounter == 2);
+ }
+
+ SECTION("Many expired listeners should be cleaned without issue") {
+ auto guard = boost::asio::make_work_guard(ioContext.get_executor());
+ auto thread = std::thread([&] { ioContext.run(); });
+
+ // Create and destroy many watchables to build up expired listeners
+ for (int i = 0; i < 50; ++i) {
+ auto w = ee.watchable();
+ std::atomic<int> unused{0};
+ w.after<ReceivingPresenceEvent>(
+ [&unused](auto) {
+ ++unused;
+ });
+ }
+ // All watchables destroyed, all listeners expired
+
+ // Emit events to trigger cleanup via erase-remove
+ for (int i = 0; i < 10; ++i) {
+ ee.emit(ReceivingPresenceEvent{});
+ }
+
+ std::this_thread::sleep_for(std::chrono::milliseconds{100});
+
+ // Verify emitter still works after cleanup
+ int aliveCounter = 0;
+ {
+ auto w = ee.watchable();
+ w.after<ReceivingPresenceEvent>(
+ [&](auto) {
+ ++aliveCounter;
+ });
+
+ ee.emit(ReceivingPresenceEvent{});
+ std::this_thread::sleep_for(std::chrono::milliseconds{100});
+ }
+
+ guard.reset();
+ thread.join();
+
+ REQUIRE(aliveCounter == 1);
+ }
}
diff --git a/src/tests/kazvjobtest.cpp b/src/tests/kazvjobtest.cpp
--- a/src/tests/kazvjobtest.cpp
+++ b/src/tests/kazvjobtest.cpp
@@ -77,7 +77,7 @@
std::string id{"testTimerId"};
h.setInterval(
- [&v, &h, id] {
+ [&v, &h, id]() {
v.push_back(50);
std::cout << "timer executed" << std::endl;
if (v.size() >= 2) {
@@ -86,7 +86,7 @@
}, 100, id);
h.setTimeout(
- [&h] {
+ [&h]() {
h.stop();
}, 300);
@@ -97,6 +97,67 @@
REQUIRE( v[1] == 50 );
}
+TEST_CASE("clearTimer should erase timer from map when timer fires", "[kazvjob]")
+{
+ boost::asio::io_context ioContext;
+ CprJobHandler h(ioContext.get_executor());
+
+ std::vector<int> firedWithIds;
+
+ // Set two timers with the same timerId - each fires once and
+ // is cleared via clearTimer -> erase-remove, then a new timer
+ // with the same ID is set
+ h.setTimeout(
+ [&firedWithIds, &h]() {
+ firedWithIds.push_back(1);
+ // Set a new timer with the same ID after this one fires
+ h.setTimeout(
+ [&firedWithIds, &h]() {
+ firedWithIds.push_back(2);
+ h.stop();
+ }, 50, "shared-id");
+ }, 50, "shared-id");
+
+ ioContext.run();
+
+ REQUIRE(firedWithIds.size() == 2);
+ REQUIRE(firedWithIds[0] == 1);
+ REQUIRE(firedWithIds[1] == 2);
+}
+
+TEST_CASE("Multiple timers with same id should be independently clearable", "[kazvjob]")
+{
+ boost::asio::io_context ioContext;
+ CprJobHandler h(ioContext.get_executor());
+
+ std::vector<int> fired;
+
+ // After each timer fires, clearTimer via erase-remove should
+ // remove only that specific timer, not affect others
+ h.setTimeout(
+ [&fired]() {
+ fired.push_back(1);
+ }, 50, "multi-id");
+
+ h.setTimeout(
+ [&fired]() {
+ fired.push_back(2);
+ }, 100, "multi-id");
+
+ h.setTimeout(
+ [&fired, &h]() {
+ fired.push_back(3);
+ h.stop();
+ }, 150, "multi-id");
+
+ ioContext.run();
+
+ REQUIRE(fired.size() == 3);
+ REQUIRE(fired[0] == 1);
+ REQUIRE(fired[1] == 2);
+ REQUIRE(fired[2] == 3);
+}
+
static BaseJob succJob =
BaseJob(TEST_SERVER_URL, "/.well-known/matrix/client", BaseJob::Get{}, "TestJob")
.withQueue("testjob");

File Metadata

Mime Type
text/plain
Expires
Fri, Jun 12, 8:57 PM (22 h, 47 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1581848
Default Alt Text
D309.1781323073.diff (23 KB)

Event Timeline