diff options
37 files changed, 1528 insertions, 1755 deletions
diff --git a/.appveyor.yml b/.appveyor.yml index 725adc8c..701028af 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -1,7 +1,7 @@ image: Visual Studio 2017 environment: - #DEPLOY_DIR: quotient-%APPVEYOR_BUILD_VERSION% + CMAKE_ARGS: '-G "NMake Makefiles JOM" -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=RelWithDebInfo' matrix: - QTDIR: C:\Qt\5.13\msvc2017_64 VCVARS: "C:\\Program Files (x86)\\Microsoft Visual Studio\\2017\\Community\\VC\\Auxiliary\\Build\\vcvars64.bat" @@ -19,17 +19,14 @@ init: before_build: - git submodule update --init --recursive - git clone https://gitlab.matrix.org/matrix-org/olm.git -- cd olm -- cmake -G "NMake Makefiles JOM" -H. -Bbuild -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_INSTALL_PREFIX=install -- cmake --build build -- cmake --build build --target install -- cd .. -- cmake -G "NMake Makefiles JOM" -H. -Bbuild -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_INSTALL_PREFIX="%DEPLOY_DIR%" -DOlm_DIR="olm/install/lib/cmake/Olm" +- cmake %CMAKE_ARGS% -Holm -Bbuild/olm +- cmake --build build/olm build_script: +- cmake %CMAKE_ARGS% -H. -Bbuild "-DOlm_DIR=build/olm" - cmake --build build # qmake uses olm just built by CMake - it can't build olm on its own. -- qmake "INCLUDEPATH += olm/install/include" "LIBS += -Lbuild" "LIBS += -Lolm/install/lib" && jom +- qmake "INCLUDEPATH += olm/include" "LIBS += -Lbuild" "LIBS += -Lbuild/olm" && jom #after_build: #- cmake --build build --target install diff --git a/.lgtm.yml b/.lgtm.yml new file mode 100644 index 00000000..b9952f40 --- /dev/null +++ b/.lgtm.yml @@ -0,0 +1,15 @@ +extraction: + cpp: + prepare: + packages: # Assuming package base of cosmic + - ninja-build + - qt5-default + - qtmultimedia5-dev + after_prepare: + - git clone https://gitlab.matrix.org/matrix-org/olm.git + - pushd olm + - cmake . -Bbuild -GNinja + - cmake --build build + - popd + configure: + command: "cmake . -GNinja -DOlm_DIR=olm/build" diff --git a/.travis.yml b/.travis.yml index 68118423..b1b3ef95 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,13 +4,6 @@ dist: bionic git: depth: false -before_cache: -- brew cleanup - -cache: - directories: - - $HOME/Library/Caches/Homebrew - addons: apt: packages: @@ -19,6 +12,12 @@ addons: - qtmultimedia5-dev - valgrind +env: + global: + - DESTDIR="$TRAVIS_BUILD_DIR/install" + - CMAKE_ARGS="-DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_PREFIX_PATH=$DESTDIR/usr" + - VALGRIND="valgrind --tool=memcheck --leak-check=yes --gen-suppressions=all --suppressions=tests/.valgrind.supp $VALGRIND_OPTIONS" + matrix: include: - os: linux @@ -27,51 +26,53 @@ matrix: compiler: clang - os: osx osx_image: xcode10.1 - env: [ 'PATH=/usr/local/opt/qt/bin:$PATH' ] + env: [ 'PATH=/usr/local/opt/qt/bin:$PATH', 'VALGRIND=' ] addons: homebrew: update: true packages: - qt5 + before_cache: + - brew cleanup + cache: + directories: + - $HOME/Library/Caches/Homebrew before_install: -- eval "${ENV_EVAL}" -- if [ "$TRAVIS_OS_NAME" = "linux" ]; then USE_NINJA="-GNinja"; fi -- if [ "$TRAVIS_OS_NAME" = "linux" ]; then VALGRIND="valgrind $VALGRIND_OPTIONS"; fi +- if [ -f "$(which ninja)" ]; then export CMAKE_ARGS="$CMAKE_ARGS -GNinja"; fi +# RPM spec-style: swallow a command with default parameters into an alias +# and add/override parameters further in the code if/as necessary +- shopt -s expand_aliases +- alias _cmake_config='cmake $CMAKE_ARGS . -Bbuild' +- alias _cmake_build='cmake --build build' install: - git clone https://gitlab.matrix.org/matrix-org/olm.git - pushd olm -- cmake . -Bbuild -DBUILD_SHARED_LIBS=NO -DCMAKE_INSTALL_PREFIX=install -- cmake --build build -- cmake --build build --target install +- _cmake_config +- _cmake_build # TODO: add --target install when the patch lands in olm - popd - git clone https://github.com/quotient-im/matrix-doc.git - git clone --recursive https://github.com/KitsuneRal/gtad.git - pushd gtad -- cmake $USE_NINJA -DCMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH} . +- cmake $CMAKE_ARGS . - cmake --build . - popd before_script: -- mkdir build && pushd build -- cmake $USE_NINJA -DMATRIX_DOC_PATH="matrix-doc" -DGTAD_PATH="gtad/gtad" -DCMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH} -DCMAKE_INSTALL_PREFIX=../install -DOlm_DIR=../olm/install/lib/cmake/Olm .. -- cmake --build . --target update-api -- popd +- _cmake_config -DMATRIX_DOC_PATH="matrix-doc" -DGTAD_PATH="gtad/gtad" -DOlm_DIR=olm/build +- _cmake_build --target update-api script: -- cmake --build build --target all -- cmake --build build --target install -# Build qmc-example with the installed library -- mkdir build-example && pushd build-example -- cmake -DCMAKE_PREFIX_PATH=../install -DOlm_DIR=../olm/install/lib/cmake/Olm ../examples -- cmake --build . --target all -- popd +- _cmake_build --target install +# Build quotest with the installed libQuotient +- cmake $CMAKE_ARGS tests -Bbuild-test -DOlm_DIR=olm/build +- cmake --build build-test --target all # Build with qmake -- qmake qmc-example.pro "CONFIG += debug" "CONFIG -= app_bundle" "QMAKE_CC = $CC" "QMAKE_CXX = $CXX" "INCLUDEPATH += olm/include" "LIBS += -Lbuild/lib" "LIBS += -Lolm/install/lib" +- qmake quotest.pro "CONFIG += debug" "CONFIG -= app_bundle" "QMAKE_CC = $CC" "QMAKE_CXX = $CXX" "INCLUDEPATH += olm/include" "LIBS += -Lbuild/lib" "LIBS += -Lolm/build" - make all -# Run the qmake-compiled qmc-example under valgrind -- if [ "$QMC_TEST_USER" != "" ]; then LD_LIBRARY_PATH="build/lib" $VALGRIND ./qmc-example "$QMC_TEST_USER" "$QMC_TEST_PWD" qmc-example-travis '#qmc-test:matrix.org' "Travis CI job $TRAVIS_JOB_NUMBER"; fi +# Run the qmake-compiled quotest under valgrind +- if [ "$TEST_USER" != "" ]; then LD_LIBRARY_PATH="olm/build" $VALGRIND ./quotest "$TEST_USER" "$TEST_PWD" quotest-travis '#quotest:matrix.org' "Travis CI job $TRAVIS_JOB_NUMBER"; fi notifications: webhooks: diff --git a/.valgrind.qmc-example.supp b/.valgrind.qmc-example.supp deleted file mode 100644 index 6f0bf60a..00000000 --- a/.valgrind.qmc-example.supp +++ /dev/null @@ -1,152 +0,0 @@ -{ - libc_dirty_free_on_exit - Memcheck:Free - fun:free - fun:__libc_freeres - fun:_vgnU_freeres - fun:__run_exit_handlers - fun:exit -} - -{ - sendPostedEvents1 - Memcheck:Leak - match-leak-kinds: possible - fun:_Znwm - fun:_ZN15QtSharedPointer20ExternalRefCountData9getAndRefEPK7QObject - obj:/opt/qt56/lib/libQt5Network.so.* -} - -{ - sendPostedEvents3 - Memcheck:Leak - ... - obj:/opt/qt56/lib/libQt5Network.so.* - fun:_ZN7QObject5eventEP6QEvent - fun:_ZN16QCoreApplication6notifyEP7QObjectP6QEvent - fun:_ZN16QCoreApplication15notifyInternal2EP7QObjectP6QEvent - fun:_ZN23QCoreApplicationPrivate16sendPostedEventsEP7QObjectiP11QThreadData - obj:/opt/qt56/lib/libQt5Core.so.* -} - -{ - QAuthenticator - Memcheck:Leak - match-leak-kinds: possible - ... - fun:_ZN14QAuthenticator6detachEv -} - -{ - QObject_connect - Memcheck:Leak - match-leak-kinds: possible - ... - obj:/opt/qt56/lib/libQt5Core.so.* - fun:_ZN7QObject7connectEPKS_PKcS1_S3_N2Qt14ConnectionTypeE -} - -{ - QNetworkProxy - Memcheck:Leak - match-leak-kinds: possible - fun:_Znwm - fun:_ZN13QNetworkProxyC1ENS_9ProxyTypeERK7QStringtS3_S3_ - obj:/opt/qt56/lib/libQt5Network.so.* -} - -{ - QTimer - Memcheck:Leak - match-leak-kinds: possible - fun:_Znwm - fun:_ZN7QObjectC1EPS_ - fun:_ZN6QTimerC1EP7QObject -} - -{ - QSslConfiguration - Memcheck:Leak - match-leak-kinds: possible - fun:_Znwm - ... - fun:_ZN17QSslConfigurationC1Ev -} - -{ - sendPostedEvents6 - Memcheck:Leak - match-leak-kinds: possible - fun:_Znwm - ... - obj:/opt/qt56/lib/libQt5Network.so.* - fun:_ZN7QObject5eventEP6QEvent - fun:_ZN16QCoreApplication6notifyEP7QObjectP6QEvent - fun:_ZN16QCoreApplication15notifyInternal2EP7QObjectP6QEvent - fun:_ZN23QCoreApplicationPrivate16sendPostedEventsEP7QObjectiP11QThreadData -} - -{ - QMetaObject_activate_in_QtNetwork - Memcheck:Leak - match-leak-kinds: possible - fun:_Znwm - ... - obj:/opt/qt56/lib/libQt5Network.so.* - fun:_ZN11QMetaObject8activateEP7QObjectiiPPv -} - -{ - QMapDatabase_from_QtNetwork - Memcheck:Leak - match-leak-kinds: possible - fun:_Znwm - fun:_ZN12QMapDataBase10createDataEv - obj:/opt/qt56/lib/libQt5Network.so.* -} - -{ - QThread - Memcheck:Leak - match-leak-kinds: possible - ... - fun:_ZN7QThread5startENS_8PriorityE -} - -{ - libcrypto_ASN1 - Memcheck:Leak - match-leak-kinds: definite - fun:malloc - ... - fun:ASN1_item_ex_d2i -} - -{ - QObject_from_QtNetwork - Memcheck:Leak - match-leak-kinds: possible - fun:_Znwm - fun:_ZN7QObjectC1EPS_ - obj:/opt/qt56/lib/libQt5Network.so.* -} - -{ - array_new_from_QtNetwork - Memcheck:Leak - match-leak-kinds: possible - fun:_Znam - obj:/opt/qt56/lib/libQt5Network.so.* -} - -{ - malloc_from_libcrypto - Memcheck:Leak - match-leak-kinds: possible - fun:malloc - fun:CRYPTO_malloc - ... - obj:/lib/x86_64-linux-gnu/libcrypto.so.1.0.0 - ... - obj:/opt/qt56/lib/libQt5Network.so.* -} diff --git a/CMakeLists.txt b/CMakeLists.txt index f4720f25..9a17e1f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ endif() set(API_VERSION "0.6") project(Quotient VERSION "${API_VERSION}.0" LANGUAGES CXX) -option(QUOTIENT_INSTALL_EXAMPLE "install qmc-example application" ON) +option(${PROJECT_NAME}_INSTALL_TESTS "install quotest (former qmc-example) application" ON) include(CheckCXXCompilerFlag) if (NOT WIN32) @@ -52,7 +52,7 @@ else() endforeach () endif() -find_package(Qt5 5.9 REQUIRED Network Gui Multimedia) +find_package(Qt5 5.9 REQUIRED Network Gui Multimedia Test) get_filename_component(Qt5_Prefix "${Qt5_DIR}/../../../.." ABSOLUTE) if ((NOT DEFINED USE_INTREE_LIBQOLM OR USE_INTREE_LIBQOLM) @@ -194,6 +194,7 @@ if (MATRIX_DOC_PATH AND GTAD_PATH) ${ABS_GTAD_PATH} --config ${CSAPI_DIR}/gtad.yaml --out ${CSAPI_DIR} ${FULL_CSAPI_SRC_DIR} old_sync.yaml- room_initial_sync.yaml- # deprecated + search.yaml- # current GTAD is limited in handling move-only data sync.yaml- # we have a better handcrafted implementation WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}/lib SOURCES ${FULL_CSAPI_DIR}/gtad.yaml @@ -219,7 +220,7 @@ if (MATRIX_DOC_PATH AND GTAD_PATH) endif() endif() -set(example_SRCS examples/qmc-example.cpp) +set(tests_SRCS tests/quotest.cpp) add_library(${PROJECT_NAME} ${lib_SRCS} ${api_SRCS}) set_target_properties(${PROJECT_NAME} PROPERTIES @@ -238,8 +239,10 @@ target_include_directories(${PROJECT_NAME} PUBLIC ) target_link_libraries(${PROJECT_NAME} QtOlm Qt5::Core Qt5::Network Qt5::Gui Qt5::Multimedia) -add_executable(qmc-example ${example_SRCS}) -target_link_libraries(qmc-example Qt5::Core Quotient) +set(TEST_BINARY quotest) +add_executable(${TEST_BINARY} ${tests_SRCS}) +target_link_libraries(${TEST_BINARY} Qt5::Core Qt5::Test Quotient) + configure_file(Quotient.pc.in ${CMAKE_CURRENT_BINARY_DIR}/Quotient.pc @ONLY NEWLINE_STYLE UNIX) # Installation @@ -282,9 +285,9 @@ if (WIN32) install(FILES mime/packages/freedesktop.org.xml DESTINATION mime/packages) endif (WIN32) -if (QUOTIENT_INSTALL_EXAMPLE) - install(TARGETS qmc-example RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) -endif (QUOTIENT_INSTALL_EXAMPLE) +if (QUOTIENT_INSTALL_TESTS) + install(TARGETS ${TEST_BINARY} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +endif () if (UNIX AND NOT APPLE) install(FILES ${CMAKE_CURRENT_BINARY_DIR}/Quotient.pc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba68d226..fd604621 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,120 +142,20 @@ The code should strive to be DRY (don't repeat yourself), clear, and obviously correct (i.e. buildable). Some technical debt is inevitable, just don't bankrupt us with it. Refactoring is welcome. -### Generated C++ code for CS API -The code in lib/csapi, lib/identity and lib/application-service, although -it resides in Git, is actually generated from (a soft fork of) the official -Matrix Swagger/OpenAPI definition files. Do not edit C++ files -in these directories by hand! - -Now, if you're unhappy with something in there and want to improve the code, -you have to understand the way these files are produced and setup -some additional tooling. The shortest possible procedure resembling -the below text can be found in .travis.yml (our Travis CI configuration -actually regenerates those files upon every build). As described below, -there is a handy build target for CMake; patches with a similar target -for qmake are (you guessed it) very welcome. - -#### Why generate the code at all? -Because before both original authors of libQuotient had to do monkey business of writing boilerplate code, with the same patterns, types etc., literally, for every single API endpoint, and one of the authors got fed up with it at some point in time. By then about 15 job classes were written; the entire API counts about 100 endpoints. Besides, the existing jobs had to be updated according to changes in CS API that have been, and will keep, coming. Other considerations can be found in [this talk about API description languages that briefly touches on GTAD](https://youtu.be/W5TmRozH-rg). - -#### Prerequisites for CS API code generation -1. Get the source code of GTAD and its dependencies, e.g. using the command: - `git clone --recursive https://github.com/KitsuneRal/gtad.git` -2. Build GTAD: in the source code directory, do `cmake . && cmake --build .` - (you might need to pass `-DCMAKE_PREFIX_PATH=<path to Qt>`, - similar to libQuotient itself). -3. Get the Matrix CS API definitions that are included in the matrix-doc repo: - `git clone https://github.com/quotient-im/matrix-doc.git` - (quotient-im/matrix-doc is a fork that's known to produce working code; - you may want to use your own fork if you wish to alter something in the API). -4. If you plan to submit a PR or just would like the generated code to be - formatted, you should either ensure you have clang-format (version 6 at least) - in your PATH or pass the _absolute_ path to it by adding - `-DCLANG_FORMAT=<absolute path>` to the CMake invocation below. - -#### Generating CS API contents -1. Pass additional configuration to CMake when configuring libQuotient: - `-DMATRIX_DOC_PATH=<path you your matrix-doc repo> -DGTAD_PATH=<path to gtad binary (not the repo!)>`. - If everything's right, these two CMake variables will be mentioned in - CMake output and will trigger configuration of an additional build target, - see the next step. -2. Generate the code: `cmake --build <your build dir> --target update-api`; - if you use CMake with GNU Make, you can just do `make update-api` instead. - Building this target will create (overwriting without warning) `.h` and `.cpp` - files in `lib/csapi`, `lib/identity`, `lib/application-service` for all - YAML files it can find in `matrix-doc/api/client-server` and other files - in `matrix-doc/api` these depend on. -3. Re-run CMake so that the build system knows about new files, if there are any. - -#### Changing generated code -See the more detailed description of what GTAD is and how it works in the documentation on GTAD in its source repo. Only parts specific for libQuotient are described here. - -GTAD uses the following three kinds of sources: -1. OpenAPI files. Each file is treated as a separate source (because this is how GTAD works now). -2. A configuration file, in our case it's lib/csapi/gtad.yaml - this one is common for the whole API. -3. Source code template files: lib/csapi/{{base}}.*.mustache - also common. - -The mustache files have a templated (not in C++ sense) definition of a network -job, deriving from BaseJob; each job class is prepended, if necessary, with -data structure definitions used by this job. The look of those files is hideous -for a newcomer; and the only known highlighter that can handle the combination -of Mustache (originally a web templating language) and C++ is provided in CLion. -To slightly simplify things some more or less generic constructs are defined -in gtad.yaml (see its "mustache:" section). Adventurous souls that would like -to figure what's going on in these files should speak up in the Quotient room - -I (Kitsune) will be very glad to help you out. - -The types map in `gtad.yaml` is the central switchboard when it comes to matching OpenAPI types with C++ (and Qt) ones. It uses the following type attributes aside from pretty obvious "imports:": -* `avoidCopy` - this attribute defines whether a const ref should be used instead of a value. For basic types like int this is obviously unnecessary; but compound types like `QVector` should rather be taken by reference when possible. -* `moveOnly` - some types are not copyable at all and must be moved instead (an obvious example is anything "tainted" with a member of type `std::unique_ptr<>`). The template will use `T&&` instead of `T` or `const T&` to pass such types around. -* `useOmittable` - wrap types that have no value with "null" semantics (i.e. number types and custom-defined data structures) into a special `Omittable<>` template defined in `converters.h` - a substitute for `std::optional` from C++17 (we're still at C++14 yet). -* `omittedValue` - an alternative for `useOmittable`, just provide a value used for an omitted parameter. This is used for bool parameters which normally are considered false if omitted (or they have an explicit default value, passed in the "official" GTAD's `defaultValue` variable). -* `initializer` - this is a _partial_ (see GTAD and Mustache documentation for explanations but basically it's a variable that is a Mustache template itself) that specifies how exactly a default value should be passed to the parameter. E.g., the default value for a `QString` parameter is enclosed into `QStringLiteral`. - -Instead of relying on the event structure definition in the OpenAPI files, `gtad.yaml` uses pointers to libQuotient's event structures: `EventPtr`, `RoomEventPtr` and `StateEventPtr`. Respectively, arrays of events, when encountered in OpenAPI definitions, are converted to `Events`, `RoomEvents` and `StateEvents` containers. When there's no way to figure the type from the definition, an opaque `QJsonObject` is used, leaving the conversion to the library and/or client code. - -### Comments - -Whenever you add a new call to the library API that you expect to be used -from client code, you must supply a proper doc-comment along with the call. -Doxygen style is preferred; but JavaDoc is acceptable too. Some parts are -not documented at all; adding doc-comments to them is highly encouraged. - -Doc-comments for summaries should be separate from those details. Either of -the two following ways is fine, with considerable preference on the first one: -1. Use `///` for the summary comment and `/*! ... */` for details. -2. Use `\brief` (or `@brief`) for the summary, and follow with details after - an empty doc-comment line. You can use either of the delimiters in that case. - -In the code, the advice for commenting is as follows: -* Don't restate what's happening in the code unless it's not really obvious. - We assume the readers to have at least some command of C++ and Qt. If your - code is not obvious, consider rewriting it for clarity. -* Both C++ and Qt still come with their arcane features and dark corners, - and we don't want to limit anybody who'd feels they have a case for - variable templates, raw literals, or use `qAsConst` to avoid container - detachment. Use your experience to figure what might be less well-known to - readers and comment such cases (references to web pages, Quotient wiki etc. - are very much ok). -* Make sure to document not so much "what" but more "why" certain code is done - the way it is. In the worst case, the logic of the code can be - reverse-engineered; you rarely can reverse-engineer the line of reasoning and - the pitfalls avoided. - -### API conventions - -Calls, data structures and other symbols not intended for use by clients -should _not_ be exposed in (public) .h files, unless they are necessary -to declare other public symbols. In particular, this involves private members -(functions, typedefs, or variables) in public classes; use pimpl idiom to hide -implementation details as much as possible. `_impl` namespace is reserved for -definitions that should not be used by clients and are not covered by -API guarantees. - -Note: As of now, all header files of libQuotient are considered public; this may change eventually. - -### Code formatting +### Code style and formatting + +As of Quotient 0.6, the C++ standard for newly written code is C++17, with a few +restrictions, notably: +* standard library's _deduction guides_ cannot be used to lighten up syntax + in template instantiation, i.e. you have to still write + `std::array<int, 2> { 1, 2 }` instead of `std::array { 1, 2 }` or use helpers + like `std::make_pair` - once we move over to the later Apple toolchain, this + will be no more necessary. +* enumerators and slots cannot have `[[attributes]]` because moc of Qt 5.9 + chokes on them. This will be lifted when we move on to Qt 5.12 for the oldest + supported version. +* things from `std::filesystem` cannot be used yet until we push the oldest + required g++/libc to version 8. The code style is defined by `.clang-format`, and in general, all C++ files should follow it. Files with minor deviations from the defined style are still @@ -301,20 +201,68 @@ Additional considerations: [great article by Marc Mutz on Qt containers](https://marcmutz.wordpress.com/effective-qt/containers/) for details. +### API conventions + +Calls, data structures and other symbols not intended for use by clients +should _not_ be exposed in (public) .h files, unless they are necessary +to declare other public symbols. In particular, this involves private members +(functions, typedefs, or variables) in public classes; use pimpl idiom to hide +implementation details as much as possible. `_impl` namespace is reserved for +definitions that should not be used by clients and are not covered by +API guarantees. + +Note: As of now, all header files of libQuotient are considered public; this may change eventually. + +### Comments + +Whenever you add a new call to the library API that you expect to be used +from client code, you must supply a proper doc-comment along with the call. +Doxygen style is preferred; but JavaDoc is acceptable too. Some parts are +not documented at all; adding doc-comments to them is highly encouraged. + +Doc-comments for summaries should be separate from those details. Either of +the two following ways is fine, with considerable preference on the first one: +1. Use `///` for the summary comment and `/*! ... */` for details. +2. Use `\brief` (or `@brief`) for the summary, and follow with details after + an empty doc-comment line. You can use either of the delimiters in that case. + +In the code, the advice for commenting is as follows: +* Don't restate what's happening in the code unless it's not really obvious. + We assume the readers to have at least some command of C++ and Qt. If your + code is not obvious, consider rewriting it for clarity. +* Both C++ and Qt still come with their arcane features and dark corners, + and we don't want to limit anybody who'd feels they have a case for + variable templates, raw literals, or use `qAsConst` to avoid container + detachment. Use your experience to figure what might be less well-known to + readers and comment such cases (references to web pages, Quotient wiki etc. + are very much ok). +* Make sure to document not so much "what" but more "why" certain code is done + the way it is. In the worst case, the logic of the code can be + reverse-engineered; you rarely can reverse-engineer the line of reasoning and + the pitfalls avoided. + ### Automated tests There's no testing framework as of now; either Catch or Qt Test or both will be used eventually. -As a stopgap measure, qmc-example is used for automated functional testing. -Therefore, any significant addition to the library API should be accompanied -by a respective test in qmc-example. To add a test you should: -- Add a new private slot to the `QMCTest` class. -- Add to the beginning of the slot the line `running.push_back("Test name");`. -- Add test logic to the slot, using `QMC_CHECK` macro to assert the test outcome. ALL (even failing) branches should conclude with a QMC_CHECK invocation, unless you intend to have a "DID NOT FINISH" message in the logs under certain conditions. -- Call the slot from `QMCTest::startTests()`. - -`QMCTest` sets up some basic test fixture to help you with testing; notably by the moment `startTests()` is invoked you can rely on having a working connection in `c` member variable and a test room in `targetRoom` member variable. PRs to introduce a proper testing framework are very welcome (make sure to migrate tests from qmc-example though); shifting qmc-example to use Qt Test seems to be a particularly low-hanging fruit. +The `tests/` directory contains a command-line program, quotest, used for +automated functional testing. Any significant addition to the library API +should be accompanied by a respective test in quotest. To add a test you should: +- Add a new test to the `TestSuite` class (technically, each test is a private + slot and there are two macros, `TEST_DECL()` and `TEST_IMPL()`, that conceal + passing the testing handle in `thisTest` variable to the test method). +- Add test logic to the slot, using `FINISH_TEST` macro to assert the test + outcome and complete the test (`FINISH_TEST` contains `return`). ALL + (even failing) branches should conclude with a `FINISH_TEST` (or `FAIL_TEST` + that is a shortcut for a failing `FINISH_TEST`) invocation, unless you + intend to have a "DID NOT FINISH" message in the logs in certain conditions. + +The `TestManager` class sets up some basic test fixture to help you with testing; +notably, the tests can rely on having an initialised `Room` object for the test +room in `targetRoom` member variable. PRs to introduce a proper testing framework +are very welcome (make sure to migrate tests from quotest though). Note that +tests can go async, which is the biggest hurdle for Qt Test adoption. ### Security, privacy, and performance @@ -338,6 +286,79 @@ limit by passing the new value (in microseconds) in PROFILER_LOG_USECS to the compiler. In the future, this parameter will be made changeable at runtime _if_ needed. +### Generated C++ code for CS API +The code in lib/csapi, lib/identity and lib/application-service, although +it resides in Git, is actually generated from (a soft fork of) the official +Matrix Swagger/OpenAPI definition files. Do not edit C++ files +in these directories by hand! + +Now, if you're unhappy with something in there and want to improve the code, +you have to understand the way these files are produced and setup +some additional tooling. The shortest possible procedure resembling +the below text can be found in .travis.yml (our Travis CI configuration +actually regenerates those files upon every build). As described below, +there is a handy build target for CMake; patches with a similar target +for qmake are (you guessed it) very welcome. + +#### Why generate the code at all? +Because before both original authors of libQuotient had to do monkey business of writing boilerplate code, with the same patterns, types etc., literally, for every single API endpoint, and one of the authors got fed up with it at some point in time. By then about 15 job classes were written; the entire API counts about 100 endpoints. Besides, the existing jobs had to be updated according to changes in CS API that have been, and will keep, coming. Other considerations can be found in [this talk about API description languages that briefly touches on GTAD](https://youtu.be/W5TmRozH-rg). + +#### Prerequisites for CS API code generation +1. Get the source code of GTAD and its dependencies, e.g. using the command: + `git clone --recursive https://github.com/KitsuneRal/gtad.git` +2. Build GTAD: in the source code directory, do `cmake . && cmake --build .` + (you might need to pass `-DCMAKE_PREFIX_PATH=<path to Qt>`, + similar to libQuotient itself). +3. Get the Matrix CS API definitions that are included in the matrix-doc repo: + `git clone https://github.com/quotient-im/matrix-doc.git` + (quotient-im/matrix-doc is a fork that's known to produce working code; + you may want to use your own fork if you wish to alter something in the API). +4. If you plan to submit a PR or just would like the generated code to be + formatted, you should either ensure you have clang-format (version 6 at least) + in your PATH or pass the _absolute_ path to it by adding + `-DCLANG_FORMAT=<absolute path>` to the CMake invocation below. + +#### Generating CS API contents +1. Pass additional configuration to CMake when configuring libQuotient: + `-DMATRIX_DOC_PATH=<path you your matrix-doc repo> -DGTAD_PATH=<path to gtad binary (not the repo!)>`. + If everything's right, these two CMake variables will be mentioned in + CMake output and will trigger configuration of an additional build target, + see the next step. +2. Generate the code: `cmake --build <your build dir> --target update-api`; + if you use CMake with GNU Make, you can just do `make update-api` instead. + Building this target will create (overwriting without warning) `.h` and `.cpp` + files in `lib/csapi`, `lib/identity`, `lib/application-service` for all + YAML files it can find in `matrix-doc/api/client-server` and other files + in `matrix-doc/api` these depend on. +3. Re-run CMake so that the build system knows about new files, if there are any. + +#### Changing generated code +See the more detailed description of what GTAD is and how it works in the documentation on GTAD in its source repo. Only parts specific for libQuotient are described here. + +GTAD uses the following three kinds of sources: +1. OpenAPI files. Each file is treated as a separate source (because this is how GTAD works now). +2. A configuration file, in our case it's lib/csapi/gtad.yaml - this one is common for the whole API. +3. Source code template files: lib/csapi/{{base}}.*.mustache - also common. + +The mustache files have a templated (not in C++ sense) definition of a network +job, deriving from BaseJob; each job class is prepended, if necessary, with +data structure definitions used by this job. The look of those files is hideous +for a newcomer; and the only known highlighter that can handle the combination +of Mustache (originally a web templating language) and C++ is provided in CLion. +To slightly simplify things some more or less generic constructs are defined +in gtad.yaml (see its "mustache:" section). Adventurous souls that would like +to figure what's going on in these files should speak up in the Quotient room - +I (Kitsune) will be very glad to help you out. + +The types map in `gtad.yaml` is the central switchboard when it comes to matching OpenAPI types with C++ (and Qt) ones. It uses the following type attributes aside from pretty obvious "imports:": +* `avoidCopy` - this attribute defines whether a const ref should be used instead of a value. For basic types like int this is obviously unnecessary; but compound types like `QVector` should rather be taken by reference when possible. +* `moveOnly` - some types are not copyable at all and must be moved instead (an obvious example is anything "tainted" with a member of type `std::unique_ptr<>`). The template will use `T&&` instead of `T` or `const T&` to pass such types around. +* `useOmittable` - wrap types that have no value with "null" semantics (i.e. number types and custom-defined data structures) into a special `Omittable<>` template defined in `converters.h` - a substitute for `std::optional` from C++17 (we're still at C++14 yet). +* `omittedValue` - an alternative for `useOmittable`, just provide a value used for an omitted parameter. This is used for bool parameters which normally are considered false if omitted (or they have an explicit default value, passed in the "official" GTAD's `defaultValue` variable). +* `initializer` - this is a _partial_ (see GTAD and Mustache documentation for explanations but basically it's a variable that is a Mustache template itself) that specifies how exactly a default value should be passed to the parameter. E.g., the default value for a `QString` parameter is enclosed into `QStringLiteral`. + +Instead of relying on the event structure definition in the OpenAPI files, `gtad.yaml` uses pointers to libQuotient's event structures: `EventPtr`, `RoomEventPtr` and `StateEventPtr`. Respectively, arrays of events, when encountered in OpenAPI definitions, are converted to `Events`, `RoomEvents` and `StateEvents` containers. When there's no way to figure the type from the definition, an opaque `QJsonObject` is used, leaving the conversion to the library and/or client code. + ## How to check proposed changes before submitting them Checking the code on at least one configuration is essential; if you only have @@ -350,8 +371,7 @@ The following warnings configuration is applied with GCC and Clang when using CM `-W -Wall -Wextra -pedantic -Werror=return-type -Wno-unused-parameter -Wno-gnu-zero-variadic-macro-arguments` (the last one is to mute a warning triggered by Qt code for debug logging). We don't turn most of the warnings to errors but please treat them as such. -In Qt Creator, the following line can be used with the Clang code model -(before Qt Creator 4.7 you should explicitly enable the Clang code model plugin): +If you use Qt Creator, the following line can be used with the Clang code model: `-Weverything -Werror=return-type -Wno-c++98-compat -Wno-c++98-compat-pedantic -Wno-unused-macros -Wno-newline-eof -Wno-exit-time-destructors -Wno-global-constructors -Wno-gnu-zero-variadic-macro-arguments -Wno-documentation -Wno-missing-prototypes -Wno-shadow-field-in-constructor -Wno-padded -Wno-weak-vtables -Wno-unknown-attributes -Wno-comma`. ### Continuous Integration @@ -7,6 +7,7 @@ [![release](https://img.shields.io/github/release/quotient-im/libQuotient/all.svg)](https://github.com/quotient-im/libQuotient/releases/latest) [![](https://img.shields.io/cii/percentage/1023.svg?label=CII%20best%20practices)](https://bestpractices.coreinfrastructure.org/projects/1023/badge) ![](https://img.shields.io/github/commit-activity/y/quotient-im/libQuotient.svg) +[![Language grade: C/C++](https://img.shields.io/lgtm/grade/cpp/g/quotient-im/libQuotient.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/quotient-im/libQuotient/context:cpp) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) The Quotient project aims to produce a Qt5-based SDK to develop applications @@ -45,7 +46,7 @@ and bundle it with your application. - CMake 3.10 or newer (from your package management system or [the official website](https://cmake.org/download/)) - or qmake (comes with Qt) -- A C++ toolchain with C++17 support: +- A C++ toolchain with _reasonably complete_ C++17 support: - GCC 7 (Windows, Linux, macOS), Clang 6 (Linux), Apple Clang 10 (macOS) and Visual Studio 2017 (Windows) are the oldest officially supported. - Any build system that works with CMake and/or qmake should be fine: diff --git a/examples/qmc-example.cpp b/examples/qmc-example.cpp deleted file mode 100644 index bf4d04c7..00000000 --- a/examples/qmc-example.cpp +++ /dev/null @@ -1,619 +0,0 @@ - -#include "connection.h" -#include "room.h" -#include "user.h" - -#include "csapi/joining.h" -#include "csapi/leaving.h" -#include "csapi/room_send.h" - -#include "events/reactionevent.h" -#include "events/simplestateevents.h" - -#include <QtCore/QCoreApplication> -#include <QtCore/QFileInfo> -#include <QtCore/QStringBuilder> -#include <QtCore/QTemporaryFile> -#include <QtCore/QTimer> - -#include <functional> -#include <iostream> - -using namespace Quotient; -using std::cout; -using std::endl; -using namespace std::placeholders; - -class QMCTest : public QObject { -public: - QMCTest(Connection* conn, QString testRoomName, QString source); - -private slots: - // clang-format off - void setupAndRun(); - void onNewRoom(Room* r); - void run(); - void doTests(); - void loadMembers(); - void sendMessage(); - void sendReaction(const QString& targetEvtId); - void sendFile(); - void checkFileSendingOutcome(const QString& txnId, - const QString& fileName); - void setTopic(); - void addAndRemoveTag(); - void sendAndRedact(); - bool checkRedactionOutcome(const QString& evtIdToRedact); - void markDirectChat(); - void checkDirectChatOutcome( - const Connection::DirectChatsMap& added); - void conclude(); - void finalize(); - // clang-format on - -private: - QScopedPointer<Connection, QScopedPointerDeleteLater> c; - QStringList running; - QStringList succeeded; - QStringList failed; - QString origin; - QString targetRoomName; - Room* targetRoom = nullptr; - - bool validatePendingEvent(const QString& txnId); -}; - -#define QMC_CHECK(description, condition) \ - { \ - Q_ASSERT(running.removeOne(description)); \ - if (!!(condition)) { \ - succeeded.push_back(description); \ - cout << (description) << " successful" << endl; \ - if (targetRoom) \ - targetRoom->postMessage(origin % ": " % (description) \ - % " successful", \ - MessageEventType::Notice); \ - } else { \ - failed.push_back(description); \ - cout << (description) << " FAILED" << endl; \ - if (targetRoom) \ - targetRoom->postPlainText(origin % ": " % (description) \ - % " FAILED"); \ - } \ - } - -bool QMCTest::validatePendingEvent(const QString& txnId) -{ - auto it = targetRoom->findPendingEvent(txnId); - return it != targetRoom->pendingEvents().end() - && it->deliveryStatus() == EventStatus::Submitted - && (*it)->transactionId() == txnId; -} - -QMCTest::QMCTest(Connection* conn, QString testRoomName, QString source) - : c(conn), origin(std::move(source)), targetRoomName(std::move(testRoomName)) -{ - if (!origin.isEmpty()) - cout << "Origin for the test message: " << origin.toStdString() << endl; - if (!targetRoomName.isEmpty()) - cout << "Test room name: " << targetRoomName.toStdString() << endl; - - connect(c.data(), &Connection::connected, this, &QMCTest::setupAndRun); - connect(c.data(), &Connection::loadedRoomState, this, &QMCTest::onNewRoom); - // Big countdown watchdog - QTimer::singleShot(180000, this, &QMCTest::conclude); -} - -void QMCTest::setupAndRun() -{ - Q_ASSERT(!c->homeserver().isEmpty() && c->homeserver().isValid()); - Q_ASSERT(c->domain() == c->userId().section(':', 1)); - cout << "Connected, server: " - << c->homeserver().toDisplayString().toStdString() << endl; - cout << "Access token: " << c->accessToken().toStdString() << endl; - - if (!targetRoomName.isEmpty()) { - cout << "Joining " << targetRoomName.toStdString() << endl; - running.push_back("Join room"); - auto joinJob = c->joinRoom(targetRoomName); - connect(joinJob, &BaseJob::failure, this, [this] { - QMC_CHECK("Join room", false); - conclude(); - }); - // Connection::joinRoom() creates a Room object upon JoinRoomJob::success - // but this object is empty until the first sync is done. - connect(joinJob, &BaseJob::success, this, [this, joinJob] { - targetRoom = c->room(joinJob->roomId(), JoinState::Join); - QMC_CHECK("Join room", targetRoom != nullptr); - - run(); - }); - } else - run(); -} - -void QMCTest::onNewRoom(Room* r) -{ - cout << "New room: " << r->id().toStdString() << endl - << " Name: " << r->name().toStdString() << endl - << " Canonical alias: " << r->canonicalAlias().toStdString() << endl - << endl; - connect(r, &Room::aboutToAddNewMessages, r, [r](RoomEventsRange timeline) { - cout << timeline.size() << " new event(s) in room " - << r->canonicalAlias().toStdString() << endl; - // for (const auto& item: timeline) - // { - // cout << "From: " - // << r->roomMembername(item->senderId()).toStdString() - // << endl << "Timestamp:" - // << item->timestamp().toString().toStdString() << endl - // << "JSON:" << endl << - // item->originalJson().toStdString() << endl; - // } - }); -} - -void QMCTest::run() -{ - c->setLazyLoading(true); - c->syncLoop(); - connectSingleShot(c.data(), &Connection::syncDone, this, &QMCTest::doTests); - connect(c.data(), &Connection::syncDone, c.data(), [this] { - cout << "Sync complete, " << running.size() - << " test(s) in the air: " << running.join(", ").toStdString() - << endl; - if (running.isEmpty()) - conclude(); - }); -} - -void QMCTest::doTests() -{ - cout << "Starting tests" << endl; - - loadMembers(); - // Add here tests not requiring the test room - if (targetRoomName.isEmpty()) - return; - - sendMessage(); - sendFile(); - setTopic(); - addAndRemoveTag(); - sendAndRedact(); - markDirectChat(); - // Add here tests with the test room -} - -void QMCTest::loadMembers() -{ - running.push_back("Loading members"); - auto* r = c->roomByAlias(QStringLiteral("#quotient:matrix.org"), - JoinState::Join); - if (!r) { - cout << "#test:matrix.org is not found in the test user's rooms" << endl; - QMC_CHECK("Loading members", false); - return; - } - // It's not exactly correct because an arbitrary server might not support - // lazy loading; but in the absence of capabilities framework we assume - // it does. - if (r->memberNames().size() >= r->joinedCount()) { - cout << "Lazy loading doesn't seem to be enabled" << endl; - QMC_CHECK("Loading members", false); - return; - } - r->setDisplayed(); - connect(r, &Room::allMembersLoaded, [this, r] { - QMC_CHECK("Loading members", - r->memberNames().size() >= r->joinedCount()); - }); -} - -void QMCTest::sendMessage() -{ - running.push_back("Message sending"); - cout << "Sending a message" << endl; - auto txnId = targetRoom->postPlainText("Hello, " % origin % " is here"); - if (!validatePendingEvent(txnId)) { - cout << "Invalid pending event right after submitting" << endl; - QMC_CHECK("Message sending", false); - return; - } - - connectUntil( - targetRoom, &Room::pendingEventAboutToMerge, this, - [this, txnId](const RoomEvent* evt, int pendingIdx) { - const auto& pendingEvents = targetRoom->pendingEvents(); - Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); - - if (evt->transactionId() != txnId) - return false; - - QMC_CHECK("Message sending", - is<RoomMessageEvent>(*evt) && !evt->id().isEmpty() - && pendingEvents[size_t(pendingIdx)]->transactionId() - == evt->transactionId()); - sendReaction(evt->id()); - return true; - }); -} - -void QMCTest::sendReaction(const QString& targetEvtId) -{ - running.push_back("Reaction sending"); - cout << "Reacting to the newest message in the room" << endl; - Q_ASSERT(targetRoom->timelineSize() > 0); - const auto key = QStringLiteral("+1"); - auto txnId = targetRoom->postReaction(targetEvtId, key); - if (!validatePendingEvent(txnId)) { - cout << "Invalid pending event right after submitting" << endl; - QMC_CHECK("Reaction sending", false); - return; - } - - // TODO: Check that it came back as a reaction event and that it attached to - // the right event - connectUntil( - targetRoom, &Room::updatedEvent, this, - [this, txnId, key, targetEvtId](const QString& actualTargetEvtId) { - if (actualTargetEvtId != targetEvtId) - return false; - const auto reactions = targetRoom->relatedEvents( - targetEvtId, EventRelation::Annotation()); - // It's a test room, assuming no interference there should - // be exactly one reaction - if (reactions.size() != 1) { - QMC_CHECK("Reaction sending", false); - } else { - const auto* evt = - eventCast<const ReactionEvent>(reactions.back()); - QMC_CHECK("Reaction sending", - is<ReactionEvent>(*evt) && !evt->id().isEmpty() - && evt->relation().key == key - && evt->transactionId() == txnId); - } - return true; - }); -} - -void QMCTest::sendFile() -{ - running.push_back("File sending"); - cout << "Sending a file" << endl; - auto* tf = new QTemporaryFile; - if (!tf->open()) { - cout << "Failed to create a temporary file" << endl; - QMC_CHECK("File sending", false); - return; - } - tf->write("Test"); - tf->close(); - // QFileInfo::fileName brings only the file name; QFile::fileName brings - // the full path - const auto tfName = QFileInfo(*tf).fileName(); - cout << "Sending file" << tfName.toStdString() << endl; - const auto txnId = - targetRoom->postFile("Test file", QUrl::fromLocalFile(tf->fileName())); - if (!validatePendingEvent(txnId)) { - cout << "Invalid pending event right after submitting" << endl; - QMC_CHECK("File sending", false); - delete tf; - return; - } - - // FIXME: Clean away connections (connectUntil doesn't help here). - connect(targetRoom, &Room::fileTransferCompleted, this, - [this, txnId, tf, tfName](const QString& id) { - auto fti = targetRoom->fileTransferInfo(id); - Q_ASSERT(fti.status == FileTransferInfo::Completed); - - if (id != txnId) - return; - - delete tf; - - checkFileSendingOutcome(txnId, tfName); - }); - connect(targetRoom, &Room::fileTransferFailed, this, - [this, txnId, tf](const QString& id, const QString& error) { - if (id != txnId) - return; - - targetRoom->postPlainText(origin % ": File upload failed: " - % error); - delete tf; - - QMC_CHECK("File sending", false); - }); -} - -void QMCTest::checkFileSendingOutcome(const QString& txnId, - const QString& fileName) -{ - auto it = targetRoom->findPendingEvent(txnId); - if (it == targetRoom->pendingEvents().end()) { - cout << "Pending file event dropped before upload completion" << endl; - QMC_CHECK("File sending", false); - return; - } - if (it->deliveryStatus() != EventStatus::FileUploaded) { - cout << "Pending file event status upon upload completion is " - << it->deliveryStatus() << " != FileUploaded(" - << EventStatus::FileUploaded << ')' << endl; - QMC_CHECK("File sending", false); - return; - } - - connectUntil( - targetRoom, &Room::pendingEventAboutToMerge, this, - [this, txnId, fileName](const RoomEvent* evt, int pendingIdx) { - const auto& pendingEvents = targetRoom->pendingEvents(); - Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); - - if (evt->transactionId() != txnId) - return false; - - cout << "File event " << txnId.toStdString() - << " arrived in the timeline" << endl; - visit( - *evt, - [&](const RoomMessageEvent& e) { - QMC_CHECK( - "File sending", - !e.id().isEmpty() - && pendingEvents[size_t(pendingIdx)]->transactionId() - == txnId - && e.hasFileContent() - && e.content()->fileInfo()->originalName == fileName); - }, - [this](const RoomEvent&) { QMC_CHECK("File sending", false); }); - return true; - }); -} - -void QMCTest::setTopic() -{ - static const char* const stateTestName = "State setting test"; - running.push_back(stateTestName); - - const auto newTopic = c->generateTxnId(); // Just a way to get a unique id - targetRoom->setTopic(newTopic); // Sets the state by proper means - const auto fakeTopic = c->generateTxnId(); - const auto fakeTxnId = - targetRoom->postJson(RoomTopicEvent::matrixTypeId(), // Fake state event - RoomTopicEvent(fakeTopic).contentJson()); - - connectUntil(targetRoom, &Room::topicChanged, this, - [this, newTopic] { - if (targetRoom->topic() == newTopic) { - QMC_CHECK(stateTestName, true); - return true; - } - return false; - }); - - // Older Synapses allowed sending fake state events through, although - // did not process them; // https://github.com/matrix-org/synapse/pull/5805 - // changed that and now Synapse 400's in response to fake state events. - // The following two-step approach handles both cases, assuming that - // Room::pendingEventChanged() with EventStatus::ReachedServer is guaranteed - // to be emitted before Room::pendingEventAboutToMerge. - connectUntil( - targetRoom, &Room::pendingEventChanged, this, - [this, fakeTopic, fakeTxnId](int pendingIdx) { - const auto& pendingEvents = targetRoom->pendingEvents(); - Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); - const auto& evt = pendingEvents[pendingIdx]; - if (evt->transactionId() != fakeTxnId) - return false; - - // If Synapse rejected the event, skip the immunity test. - if (evt.deliveryStatus() == EventStatus::SendingFailed) - return true; - - if (evt.deliveryStatus() != EventStatus::ReachedServer) - return false; - - // All before was just a preparation, this is where the test starts. - static const char* const fakeStateTestName = - "Fake state event immunity test"; - running.push_back(fakeStateTestName); - connectUntil( - targetRoom, &Room::pendingEventAboutToMerge, this, - [this, fakeTopic](const RoomEvent* e, int) { - if (e->contentJson().value("topic").toString() != fakeTopic) - return false; // Wait on for the right event - - QMC_CHECK(fakeStateTestName, !e->isStateEvent()); - return true; - }); - return true; - }); -} - -void QMCTest::addAndRemoveTag() -{ - running.push_back("Tagging test"); - static const auto TestTag = QStringLiteral("org.quotient.test"); - // Pre-requisite - if (targetRoom->tags().contains(TestTag)) - targetRoom->removeTag(TestTag); - - // Connect first because the signal is emitted synchronously. - connect(targetRoom, &Room::tagsChanged, targetRoom, [=] { - cout << "Room " << targetRoom->id().toStdString() - << ", tag(s) changed:" << endl - << " " << targetRoom->tagNames().join(", ").toStdString() << endl; - if (targetRoom->tags().contains(TestTag)) { - cout << "Test tag set, removing it now" << endl; - targetRoom->removeTag(TestTag); - QMC_CHECK("Tagging test", !targetRoom->tags().contains(TestTag)); - disconnect(targetRoom, &Room::tagsChanged, nullptr, nullptr); - } - }); - cout << "Adding a tag" << endl; - targetRoom->addTag(TestTag); -} - -void QMCTest::sendAndRedact() -{ - running.push_back("Redaction"); - cout << "Sending a message to redact" << endl; - auto txnId = targetRoom->postPlainText(origin % ": message to redact"); - if (txnId.isEmpty()) { - QMC_CHECK("Redaction", false); - return; - } - connect(targetRoom, &Room::messageSent, this, - [this, txnId](const QString& tId, const QString& evtId) { - if (tId != txnId) - return; - - cout << "Redacting the message" << endl; - targetRoom->redactEvent(evtId, origin); - - connectUntil(targetRoom, &Room::addedMessages, this, - [this, evtId] { - return checkRedactionOutcome(evtId); - }); - }); -} - -bool QMCTest::checkRedactionOutcome(const QString& evtIdToRedact) -{ - // There are two possible (correct) outcomes: either the event comes already - // redacted at the next sync, or the nearest sync completes with - // the unredacted event but the next one brings redaction. - auto it = targetRoom->findInTimeline(evtIdToRedact); - if (it == targetRoom->timelineEdge()) - return false; // Waiting for the next sync - - if ((*it)->isRedacted()) { - cout << "The sync brought already redacted message" << endl; - QMC_CHECK("Redaction", true); - } else { - cout << "Message came non-redacted with the sync, waiting for redaction" - << endl; - connectUntil(targetRoom, &Room::replacedEvent, this, - [this, evtIdToRedact](const RoomEvent* newEvent, - const RoomEvent* oldEvent) { - if (oldEvent->id() != evtIdToRedact) - return false; - - QMC_CHECK("Redaction", - newEvent->isRedacted() - && newEvent->redactionReason() == origin); - return true; - }); - } - return true; -} - -void QMCTest::markDirectChat() -{ - if (targetRoom->directChatUsers().contains(c->user())) { - cout << "Warning: the room is already a direct chat," - " only unmarking will be tested" - << endl; - checkDirectChatOutcome({ { c->user(), targetRoom->id() } }); - return; - } - // Connect first because the signal is emitted synchronously. - connect(c.data(), &Connection::directChatsListChanged, this, - &QMCTest::checkDirectChatOutcome); - cout << "Marking the room as a direct chat" << endl; - c->addToDirectChats(targetRoom, c->user()); -} - -void QMCTest::checkDirectChatOutcome(const Connection::DirectChatsMap& added) -{ - running.push_back("Direct chat test"); - disconnect(c.data(), &Connection::directChatsListChanged, nullptr, nullptr); - if (!targetRoom->isDirectChat()) { - cout << "The room has not been marked as a direct chat" << endl; - QMC_CHECK("Direct chat test", false); - return; - } - if (!added.contains(c->user(), targetRoom->id())) { - cout << "The room has not been listed in new direct chats" << endl; - QMC_CHECK("Direct chat test", false); - return; - } - - cout << "Unmarking the direct chat" << endl; - c->removeFromDirectChats(targetRoom->id(), c->user()); - QMC_CHECK("Direct chat test", !c->isDirectChat(targetRoom->id())); -} - -void QMCTest::conclude() -{ - c->stopSync(); - auto succeededRec = QString::number(succeeded.size()) + " tests succeeded"; - if (!failed.isEmpty() || !running.isEmpty()) - succeededRec += - " of " - % QString::number(succeeded.size() + failed.size() + running.size()) - % " total"; - QString plainReport = origin % ": Testing complete, " % succeededRec; - QString color = failed.isEmpty() && running.isEmpty() ? "00AA00" : "AA0000"; - QString htmlReport = origin % ": <strong><font data-mx-color='#" % color - % "' color='#" % color - % "'>Testing complete</font></strong>, " % succeededRec; - if (!failed.isEmpty()) { - plainReport += "\nFAILED: " % failed.join(", "); - htmlReport += "<br><strong>Failed:</strong> " % failed.join(", "); - } - if (!running.isEmpty()) { - plainReport += "\nDID NOT FINISH: " % running.join(", "); - htmlReport += "<br><strong>Did not finish:</strong> " - % running.join(", "); - } - cout << plainReport.toStdString() << endl; - - if (targetRoom) { - // TODO: Waiting for proper futures to come so that it could be: - // targetRoom->postHtmlText(...) - // .then(this, &QMCTest::finalize); // Qt-style or - // .then([this] { finalize(); }); // STL-style - auto txnId = targetRoom->postHtmlText(plainReport, htmlReport); - connect(targetRoom, &Room::messageSent, this, - [this, txnId](QString serverTxnId) { - if (txnId != serverTxnId) - return; - - cout << "Leaving the room" << endl; - connect(targetRoom->leaveRoom(), &BaseJob::finished, this, - &QMCTest::finalize); - }); - } else - finalize(); -} - -void QMCTest::finalize() -{ - cout << "Logging out" << endl; - c->logout(); - connect(c.data(), &Connection::loggedOut, qApp, [this] { - QCoreApplication::processEvents(); - QCoreApplication::exit(failed.size() + running.size()); - }); -} - -int main(int argc, char* argv[]) -{ - QCoreApplication app(argc, argv); - if (argc < 4) { - cout << "Usage: qmc-example <user> <passwd> <device_name> " - "[<room_alias> [origin]]" - << endl; - return -1; - } - - cout << "Connecting to the server as " << argv[1] << endl; - auto conn = new Connection; - conn->connectToServer(argv[1], argv[2], argv[3]); - QMCTest test { conn, argc >= 5 ? argv[4] : nullptr, - argc >= 6 ? argv[5] : nullptr }; - return app.exec(); -} diff --git a/lib/connection.cpp b/lib/connection.cpp index 2c5bf574..5bddbb83 100644 --- a/lib/connection.cpp +++ b/lib/connection.cpp @@ -99,7 +99,8 @@ public: DirectChatsMap dcLocalAdditions; DirectChatsMap dcLocalRemovals; UnorderedMap<QString, EventPtr> accountData; - int syncLoopTimeout = -1; + QMetaObject::Connection syncLoopConnection {}; + int syncTimeout = -1; GetCapabilitiesJob* capabilitiesJob = nullptr; GetCapabilitiesJob::Capabilities capabilities; @@ -259,8 +260,6 @@ void Connection::doConnectToServer(const QString& user, const QString& password, }); } -void Connection::syncLoopIteration() { sync(d->syncLoopTimeout); } - void Connection::connectWithToken(const QString& userId, const QString& accessToken, const QString& deviceId) @@ -278,16 +277,16 @@ void Connection::reloadCapabilities() else if (d->capabilitiesJob->error() == BaseJob::IncorrectRequestError) qCDebug(MAIN) << "Server doesn't support /capabilities"; - if (d->capabilities.roomVersions.omitted()) { + if (!d->capabilities.roomVersions) { qCWarning(MAIN) << "Pinning supported room version to 1"; d->capabilities.roomVersions = { "1", { { "1", "stable" } } }; } else { qCDebug(MAIN) << "Room versions:" << defaultRoomVersion() << "is default, full list:" << availableRoomVersions(); } - Q_ASSERT(!d->capabilities.roomVersions.omitted()); + Q_ASSERT(d->capabilities.roomVersions.has_value()); emit capabilitiesLoaded(); - for (auto* r : d->roomMap) + for (auto* r : qAsConst(d->roomMap)) r->checkVersion(); }); } @@ -296,7 +295,7 @@ bool Connection::loadingCapabilities() const { // (Ab)use the fact that room versions cannot be omitted after // the capabilities have been loaded (see reloadCapabilities() above). - return d->capabilities.roomVersions.omitted(); + return !d->capabilities.roomVersions; } void Connection::Private::connectWithToken(const QString& userId, @@ -335,25 +334,38 @@ void Connection::checkAndConnect(const QString& userId, void Connection::logout() { - auto job = callApi<LogoutJob>(); - connect(job, &LogoutJob::finished, this, [job, this] { - if (job->status().good() || job->error() == BaseJob::ContentAccessError) { - stopSync(); + // If there's an ongoing sync job, stop it but don't break the sync loop yet + const auto syncWasRunning = bool(d->syncJob); + if (syncWasRunning) + { + d->syncJob->abandon(); + d->syncJob = nullptr; + } + const auto* job = callApi<LogoutJob>(); + connect(job, &LogoutJob::finished, this, [this, job, syncWasRunning] { + if (job->status().good() || job->error() == BaseJob::Unauthorised + || job->error() == BaseJob::ContentAccessError) { + if (d->syncLoopConnection) + disconnect(d->syncLoopConnection); d->data->setToken({}); emit stateChanged(); emit loggedOut(); - } + } else if (syncWasRunning) + syncLoopIteration(); // Resume sync loop (or a single sync) }); } void Connection::sync(int timeout) { - if (d->syncJob) + if (d->syncJob) { + qCInfo(MAIN) << d->syncJob << "is already running"; return; + } + d->syncTimeout = timeout; Filter filter; - filter.room->timeline->limit = 100; - filter.room->state->lazyLoadMembers = d->lazyLoading; + filter.room.edit().timeline.edit().limit.emplace(100); + filter.room.edit().state.edit().lazyLoadMembers.emplace(d->lazyLoading); auto job = d->syncJob = callApi<SyncJob>(BackgroundRequest, d->data->lastEvent(), filter, timeout); @@ -369,9 +381,9 @@ void Connection::sync(int timeout) }); connect(job, &SyncJob::failure, this, [this, job] { d->syncJob = nullptr; - if (job->error() == BaseJob::ContentAccessError) { - qCWarning(SYNCJOB) << "Sync job failed with ContentAccessError - " - "login expired?"; + if (job->error() == BaseJob::Unauthorised) { + qCWarning(SYNCJOB) + << "Sync job failed with Unauthorised - login expired?"; emit loginError(job->errorString(), job->rawDataSample()); } else emit syncError(job->errorString(), job->rawDataSample()); @@ -380,12 +392,26 @@ void Connection::sync(int timeout) void Connection::syncLoop(int timeout) { - d->syncLoopTimeout = timeout; - connect(this, &Connection::syncDone, this, &Connection::syncLoopIteration); - syncLoopIteration(); // initial sync to start the loop + if (d->syncLoopConnection && d->syncTimeout == timeout) { + qCInfo(MAIN) << "Attempt to run sync loop but there's one already " + "running; nothing will be done"; + return; + } + std::swap(d->syncTimeout, timeout); + if (d->syncLoopConnection) { + qCInfo(MAIN) << "Timeout for next syncs changed from" + << timeout << "to" << d->syncTimeout; + } else { + d->syncLoopConnection = connect(this, &Connection::syncDone, + this, &Connection::syncLoopIteration, + Qt::QueuedConnection); + syncLoopIteration(); // initial sync to start the loop + } } -QJsonObject toJson(const Connection::DirectChatsMap& directChats) +void Connection::syncLoopIteration() { sync(d->syncTimeout); } + +QJsonObject toJson(const DirectChatsMap& directChats) { QJsonObject json; for (auto it = directChats.begin(); it != directChats.end();) { @@ -421,7 +447,7 @@ void Connection::onSyncSuccess(SyncData&& data, bool fromCache) r->updateData(std::move(roomData), fromCache); if (d->firstTimeRooms.removeOne(r)) { emit loadedRoomState(r); - if (!d->capabilities.roomVersions.omitted()) + if (d->capabilities.roomVersions) r->checkVersion(); // Otherwise, the version will be checked in reloadCapabilities() } @@ -514,8 +540,7 @@ void Connection::onSyncSuccess(SyncData&& data, bool fromCache) void Connection::stopSync() { // If there's a sync loop, break it - disconnect(this, &Connection::syncDone, this, - &Connection::syncLoopIteration); + disconnect(d->syncLoopConnection); if (d->syncJob) // If there's an ongoing sync job, stop it too { d->syncJob->abandon(); @@ -534,10 +559,14 @@ JoinRoomJob* Connection::joinRoom(const QString& roomAlias, const QStringList& serverNames) { auto job = callApi<JoinRoomJob>(roomAlias, serverNames); - // Upon completion, ensure a room object in Join state is created but only - // if it's not already there due to a sync completing earlier. - connect(job, &JoinRoomJob::success, this, - [this, job] { provideRoom(job->roomId()); }); + // Upon completion, ensure a room object in Join state is created + // (or it might already be there due to a sync completing earlier). + // finished() is used here instead of success() to overtake clients + // that may add their own slots to finished(). + connect(job, &BaseJob::finished, this, [this, job] { + if (job->status().good()) + provideRoom(job->roomId()); + }); return job; } @@ -595,12 +624,17 @@ UploadContentJob* Connection::uploadContent(QIODevice* contentSource, const QString& filename, const QString& overrideContentType) const { + Q_ASSERT(contentSource != nullptr); auto contentType = overrideContentType; if (contentType.isEmpty()) { contentType = QMimeDatabase() .mimeTypeForFileNameAndData(filename, contentSource) .name(); - contentSource->open(QIODevice::ReadOnly); + if (!contentSource->open(QIODevice::ReadOnly)) { + qCWarning(MAIN) << "Couldn't open content source" << filename + << "for reading:" << contentSource->errorString(); + return nullptr; + } } return callApi<UploadContentJob>(contentSource, filename, contentType); } @@ -609,11 +643,6 @@ UploadContentJob* Connection::uploadFile(const QString& fileName, const QString& overrideContentType) { auto sourceFile = new QFile(fileName); - if (!sourceFile->open(QIODevice::ReadOnly)) { - qCWarning(MAIN) << "Couldn't open" << sourceFile->fileName() - << "for reading"; - return nullptr; - } return uploadContent(sourceFile, QFileInfo(*sourceFile).fileName(), overrideContentType); } @@ -775,7 +804,7 @@ ForgetRoomJob* Connection::forgetRoom(const QString& id) if (!room) room = d->roomMap.value({ id, true }); if (room && room->joinState() != JoinState::Leave) { - auto leaveJob = room->leaveRoom(); + auto leaveJob = leaveRoom(room); connect(leaveJob, &BaseJob::result, this, [this, leaveJob, forgetJob, room] { if (leaveJob->error() == BaseJob::Success @@ -1046,7 +1075,7 @@ QVector<Room*> Connection::roomsWithTag(const QString& tagName) const return rooms; } -Connection::DirectChatsMap Connection::directChats() const +DirectChatsMap Connection::directChats() const { return d->directChats; } @@ -1113,7 +1142,7 @@ bool Connection::isIgnored(const User* user) const return ignoredUsers().contains(user->id()); } -Connection::IgnoredUsersList Connection::ignoredUsers() const +IgnoredUsersList Connection::ignoredUsers() const { const auto* event = d->unpackAccountData<IgnoredUsersEvent>(); return event ? event->ignored_users() : IgnoredUsersList(); @@ -1154,7 +1183,7 @@ Room* Connection::provideRoom(const QString& id, Omittable<JoinState> joinState) // TODO: This whole function is a strong case for a RoomManager class. Q_ASSERT_X(!id.isEmpty(), __FUNCTION__, "Empty room id"); - // If joinState.omitted(), all joinState == comparisons below are false. + // If joinState is empty, all joinState == comparisons below are false. const auto roomKey = qMakePair(id, joinState == JoinState::Invite); auto* room = d->roomMap.value(roomKey, nullptr); if (room) { @@ -1163,7 +1192,7 @@ Room* Connection::provideRoom(const QString& id, Omittable<JoinState> joinState) // and emit a signal. For Invite and Join, there's no such problem. if (room->joinState() == joinState && joinState != JoinState::Leave) return room; - } else if (joinState.omitted()) { + } else if (!joinState) { // No Join and Leave, maybe Invite? room = d->roomMap.value({ id, true }, nullptr); if (room) @@ -1172,9 +1201,7 @@ Room* Connection::provideRoom(const QString& id, Omittable<JoinState> joinState) } if (!room) { - room = roomFactory()(this, id, - joinState.omitted() ? JoinState::Join - : joinState.value()); + room = roomFactory()(this, id, joinState.value_or(JoinState::Join)); if (!room) { qCCritical(MAIN) << "Failed to create a room" << id; return nullptr; @@ -1185,20 +1212,20 @@ Room* Connection::provideRoom(const QString& id, Omittable<JoinState> joinState) &Connection::aboutToDeleteRoom); emit newRoom(room); } - if (joinState.omitted()) + if (!joinState) return room; - if (joinState == JoinState::Invite) { + if (*joinState == JoinState::Invite) { // prev is either Leave or nullptr auto* prev = d->roomMap.value({ id, false }, nullptr); emit invitedRoom(room, prev); } else { - room->setJoinState(joinState.value()); + room->setJoinState(*joinState); // Preempt the Invite room (if any) with a room in Join/Leave state. auto* prevInvite = d->roomMap.take({ id, true }); - if (joinState == JoinState::Join) + if (*joinState == JoinState::Join) emit joinedRoom(room, prevInvite); - else if (joinState == JoinState::Leave) + else if (*joinState == JoinState::Leave) emit leftRoom(room, prevInvite); if (prevInvite) { const auto dcUsers = prevInvite->directChatUsers(); @@ -1387,8 +1414,7 @@ void Connection::setLazyLoading(bool newValue) void Connection::run(BaseJob* job, RunningPolicy runningPolicy) const { connect(job, &BaseJob::failure, this, &Connection::requestFailed); - job->prepare(d->data.get(), runningPolicy & BackgroundRequest); - d->data->submit(job); + job->initiate(d->data.get(), runningPolicy & BackgroundRequest); } void Connection::getTurnServers() @@ -1403,13 +1429,13 @@ const QString Connection::SupportedRoomVersion::StableTag = QString Connection::defaultRoomVersion() const { - Q_ASSERT(!d->capabilities.roomVersions.omitted()); + Q_ASSERT(d->capabilities.roomVersions.has_value()); return d->capabilities.roomVersions->defaultVersion; } QStringList Connection::stableRoomVersions() const { - Q_ASSERT(!d->capabilities.roomVersions.omitted()); + Q_ASSERT(d->capabilities.roomVersions.has_value()); QStringList l; const auto& allVersions = d->capabilities.roomVersions->available; for (auto it = allVersions.begin(); it != allVersions.end(); ++it) @@ -1429,7 +1455,7 @@ inline bool roomVersionLess(const Connection::SupportedRoomVersion& v1, QVector<Connection::SupportedRoomVersion> Connection::availableRoomVersions() const { - Q_ASSERT(!d->capabilities.roomVersions.omitted()); + Q_ASSERT(d->capabilities.roomVersions.has_value()); QVector<SupportedRoomVersion> result; result.reserve(d->capabilities.roomVersions->available.size()); for (auto it = d->capabilities.roomVersions->available.begin(); diff --git a/lib/connection.h b/lib/connection.h index 1f1d4cd5..e4109fd4 100644 --- a/lib/connection.h +++ b/lib/connection.h @@ -97,6 +97,14 @@ enum RunningPolicy { ForegroundRequest = 0x0, BackgroundRequest = 0x1 }; Q_ENUM_NS(RunningPolicy) +// Room ids, rather than room pointers, are used in the direct chat +// map types because the library keeps Invite rooms separate from +// rooms in Join and Leave state; and direct chats in account data +// are stored with no regard to their state. +using DirectChatsMap = QMultiHash<const User*, QString>; +using DirectChatUsersMap = QMultiHash<QString, User*>; +using IgnoredUsersList = IgnoredUsersEvent::content_type; + class Connection : public QObject { Q_OBJECT @@ -115,14 +123,6 @@ class Connection : public QObject { lazyLoadingChanged) public: - // Room ids, rather than room pointers, are used in the direct chat - // map types because the library keeps Invite rooms separate from - // rooms in Join and Leave state; and direct chats in account data - // are stored with no regard to their state. - using DirectChatsMap = QMultiHash<const User*, QString>; - using DirectChatUsersMap = QMultiHash<QString, User*>; - using IgnoredUsersList = IgnoredUsersEvent::content_type; - using UsersToDevicesToEvents = UnorderedMap<QString, UnorderedMap<QString, const Event&>>; @@ -153,7 +153,7 @@ public: * from the server. * \sa rooms, room, roomsWithTag */ - Q_INVOKABLE QVector<Room*> allRooms() const; + Q_INVOKABLE QVector<Quotient::Room*> allRooms() const; /// Get rooms that have either of the given join state(s) /*! @@ -163,10 +163,11 @@ public: * Leave rooms from the server. * \sa allRooms, room, roomsWithTag */ - Q_INVOKABLE QVector<Room*> rooms(JoinStates joinStates) const; + Q_INVOKABLE QVector<Quotient::Room*> + rooms(Quotient::JoinStates joinStates) const; /// Get the total number of rooms in the given join state(s) - Q_INVOKABLE int roomsCount(JoinStates joinStates) const; + Q_INVOKABLE int roomsCount(Quotient::JoinStates joinStates) const; /** Check whether the account has data of the given type * Direct chats map is not supported by this method _yet_. @@ -253,10 +254,10 @@ public: QList<User*> directChatUsers(const Room* room) const; /** Check whether a particular user is in the ignore list */ - Q_INVOKABLE bool isIgnored(const User* user) const; + Q_INVOKABLE bool isIgnored(const Quotient::User* user) const; /** Get the whole list of ignored users */ - Q_INVOKABLE IgnoredUsersList ignoredUsers() const; + Q_INVOKABLE Quotient::IgnoredUsersList ignoredUsers() const; /** Add the user to the ignore list * The change signal is emitted synchronously, without waiting @@ -264,14 +265,14 @@ public: * * \sa ignoredUsersListChanged */ - Q_INVOKABLE void addToIgnoredUsers(const User* user); + Q_INVOKABLE void addToIgnoredUsers(const Quotient::User* user); /** Remove the user from the ignore list */ /** Similar to adding, the change signal is emitted synchronously. * * \sa ignoredUsersListChanged */ - Q_INVOKABLE void removeFromIgnoredUsers(const User* user); + Q_INVOKABLE void removeFromIgnoredUsers(const Quotient::User* user); /** Get the full list of users known to this account */ QMap<QString, User*> users() const; @@ -281,13 +282,14 @@ public: /** Get the domain name used for ids/aliases on the server */ QString domain() const; /** Find a room by its id and a mask of applicable states */ - Q_INVOKABLE Room* room(const QString& roomId, - JoinStates states = JoinState::Invite - | JoinState::Join) const; + Q_INVOKABLE Quotient::Room* + room(const QString& roomId, + Quotient::JoinStates states = JoinState::Invite | JoinState::Join) const; /** Find a room by its alias and a mask of applicable states */ - Q_INVOKABLE Room* roomByAlias(const QString& roomAlias, - JoinStates states = JoinState::Invite - | JoinState::Join) const; + Q_INVOKABLE Quotient::Room* + roomByAlias(const QString& roomAlias, + Quotient::JoinStates states = JoinState::Invite + | JoinState::Join) const; /** Update the internal map of room aliases to IDs */ /// This is used to maintain the internal index of room aliases. /// It does NOT change aliases on the server, @@ -295,15 +297,15 @@ public: void updateRoomAliases(const QString& roomId, const QString& aliasServer, const QStringList& previousRoomAliases, const QStringList& roomAliases); - Q_INVOKABLE Room* invitation(const QString& roomId) const; - Q_INVOKABLE User* user(const QString& userId); + Q_INVOKABLE Quotient::Room* invitation(const QString& roomId) const; + Q_INVOKABLE Quotient::User* user(const QString& userId); const User* user() const; User* user(); QString userId() const; QString deviceId() const; QByteArray accessToken() const; QtOlm::Account* olmAccount() const; - Q_INVOKABLE SyncJob* syncJob() const; + Q_INVOKABLE Quotient::SyncJob* syncJob() const; Q_INVOKABLE int millisToReconnect() const; Q_INVOKABLE void getTurnServers(); @@ -589,6 +591,7 @@ public slots: /** @deprecated Use callApi<PostReceiptJob>() or Room::postReceipt() instead */ virtual PostReceiptJob* postReceipt(Room* room, RoomEvent* event) const; + signals: /** * @deprecated @@ -622,7 +625,7 @@ signals: * * @param request - the pointer to the failed job */ - void requestFailed(BaseJob* request); + void requestFailed(Quotient::BaseJob* request); /** A network request (job) failed due to network problems * @@ -640,7 +643,7 @@ signals: void syncDone(); void syncError(QString message, QString details); - void newUser(User* user); + void newUser(Quotient::User* user); /** * \group Signals emitted on room transitions @@ -672,7 +675,7 @@ signals: */ /** A new room object has been created */ - void newRoom(Room* room); + void newRoom(Quotient::Room* room); /** A room invitation is seen for the first time * @@ -680,7 +683,7 @@ signals: * that initial sync will trigger this signal for all rooms in * Invite state. */ - void invitedRoom(Room* room, Room* prev); + void invitedRoom(Quotient::Room* room, Quotient::Room* prev); /** A joined room is seen for the first time * @@ -691,7 +694,7 @@ signals: * this room was in Invite state before, the respective object is * passed in prev (and it will be deleted shortly afterwards). */ - void joinedRoom(Room* room, Room* prev); + void joinedRoom(Quotient::Room* room, Quotient::Room* prev); /** A room has just been left * @@ -702,10 +705,10 @@ signals: * Left rooms upon initial sync (not only those that were left * right before the sync). */ - void leftRoom(Room* room, Room* prev); + void leftRoom(Quotient::Room* room, Quotient::Room* prev); /** The room object is about to be deleted */ - void aboutToDeleteRoom(Room* room); + void aboutToDeleteRoom(Quotient::Room* room); /** The room has just been created by createRoom or requestDirectChat * @@ -716,7 +719,7 @@ signals: * use directChatAvailable signal if you just need to obtain * a direct chat room. */ - void createdRoom(Room* room); + void createdRoom(Quotient::Room* room); /** The first sync for the room has been completed * @@ -726,7 +729,7 @@ signals: * signals (newRoom, joinedRoom etc.) come earlier, when the room * has just been created. */ - void loadedRoomState(Room* room); + void loadedRoomState(Quotient::Room* room); /** Account data (except direct chats) have changed */ void accountDataChanged(QString type); @@ -735,18 +738,18 @@ signals: * This signal is emitted upon any successful outcome from * requestDirectChat. */ - void directChatAvailable(Room* directChat); + void directChatAvailable(Quotient::Room* directChat); /** The list of direct chats has changed * This signal is emitted every time when the mapping of users * to direct chat rooms is changed (because of either local updates * or a different list arrived from the server). */ - void directChatsListChanged(DirectChatsMap additions, - DirectChatsMap removals); + void directChatsListChanged(Quotient::DirectChatsMap additions, + Quotient::DirectChatsMap removals); - void ignoredUsersListChanged(IgnoredUsersList additions, - IgnoredUsersList removals); + void ignoredUsersListChanged(Quotient::IgnoredUsersList additions, + Quotient::IgnoredUsersList removals); void cacheStateChanged(); void lazyLoadingChanged(); @@ -812,4 +815,5 @@ private: static user_factory_t _userFactory; }; } // namespace Quotient -Q_DECLARE_METATYPE(Quotient::Connection*) +Q_DECLARE_METATYPE(Quotient::DirectChatsMap) +Q_DECLARE_METATYPE(Quotient::IgnoredUsersList) diff --git a/lib/connectiondata.cpp b/lib/connectiondata.cpp index a3807fc4..e806f952 100644 --- a/lib/connectiondata.cpp +++ b/lib/connectiondata.cpp @@ -42,6 +42,7 @@ public: QString lastEvent; QString userId; QString deviceId; + std::vector<QString> needToken; mutable unsigned int txnCounter = 0; const qint64 txnBase = QDateTime::currentMSecsSinceEpoch(); @@ -67,7 +68,6 @@ ConnectionData::ConnectionData(QUrl baseUrl) for (auto& q : d->jobs) while (!q.empty()) { auto& job = q.front(); - q.pop(); if (!job || job->error() == BaseJob::Abandoned) continue; if (job->error() != BaseJob::Pending) { @@ -79,19 +79,24 @@ ConnectionData::ConnectionData(QUrl baseUrl) } job->sendRequest(); d->rateLimiter.start(); + q.pop(); return; } qCDebug(MAIN) << d->id() << "job queues are empty"; }); } -ConnectionData::~ConnectionData() = default; +ConnectionData::~ConnectionData() +{ + d->rateLimiter.disconnect(); + d->rateLimiter.stop(); +} void ConnectionData::submit(BaseJob* job) { - Q_ASSERT(job->error() == BaseJob::Pending); + job->setStatus(BaseJob::Pending); if (!d->rateLimiter.isActive()) { - job->sendRequest(); + QTimer::singleShot(0, job, &BaseJob::sendRequest); return; } d->jobs[size_t(job->isBackground())].emplace(job); @@ -143,6 +148,12 @@ const QString& ConnectionData::deviceId() const { return d->deviceId; } const QString& ConnectionData::userId() const { return d->userId; } +bool ConnectionData::needsToken(const QString& requestName) const +{ + return std::find(d->needToken.cbegin(), d->needToken.cend(), requestName) + != d->needToken.cend(); +} + void ConnectionData::setDeviceId(const QString& deviceId) { d->deviceId = deviceId; @@ -150,6 +161,11 @@ void ConnectionData::setDeviceId(const QString& deviceId) void ConnectionData::setUserId(const QString& userId) { d->userId = userId; } +void ConnectionData::setNeedsToken(const QString& requestName) +{ + d->needToken.push_back(requestName); +} + QString ConnectionData::lastEvent() const { return d->lastEvent; } void ConnectionData::setLastEvent(QString identifier) diff --git a/lib/connectiondata.h b/lib/connectiondata.h index b367c977..000099d1 100644 --- a/lib/connectiondata.h +++ b/lib/connectiondata.h @@ -40,6 +40,7 @@ public: QUrl baseUrl() const; const QString& deviceId() const; const QString& userId() const; + bool needsToken(const QString& requestName) const; QNetworkAccessManager* nam() const; void setBaseUrl(QUrl baseUrl); @@ -50,6 +51,7 @@ public: void setPort(int port); void setDeviceId(const QString& deviceId); void setUserId(const QString& userId); + void setNeedsToken(const QString& requestName); QString lastEvent() const; void setLastEvent(QString identifier); diff --git a/lib/converters.h b/lib/converters.h index b753a80b..075af7ef 100644 --- a/lib/converters.h +++ b/lib/converters.h @@ -30,6 +30,7 @@ #include <vector> +#if QT_VERSION < QT_VERSION_CHECK(5,14,0) // Enable std::unordered_map<QString, T> // REMOVEME in favor of UnorderedMap, once we regenerate API files namespace std { @@ -37,15 +38,12 @@ template <> struct hash<QString> { size_t operator()(const QString& s) const Q_DECL_NOEXCEPT { - return qHash(s -#if (QT_VERSION >= QT_VERSION_CHECK(5, 6, 0)) - , - uint(qGlobalQHashSeed()) -#endif + return qHash(s, uint(qGlobalQHashSeed()) ); } }; } // namespace std +#endif class QVariant; @@ -206,7 +204,7 @@ template <typename T> struct JsonConverter<Omittable<T>> { static QJsonValue dump(const Omittable<T>& from) { - return from.omitted() ? QJsonValue() : toJson(from.value()); + return from.has_value() ? toJson(from.value()) : QJsonValue(); } static Omittable<T> load(const QJsonValue& jv) { @@ -378,28 +376,10 @@ namespace _impl { static void impl(ContT& container, const QString& key, const OmittableT& value) { - if (!value.omitted()) - addTo(container, key, value.value()); + if (value) + addTo(container, key, *value); } }; - -#if 0 - // This is a special one that unfolds optional<> - template <typename ValT, bool Force> - struct AddNode<optional<ValT>, Force> - { - template <typename ContT, typename OptionalT> - static void impl(ContT& container, - const QString& key, const OptionalT& value) - { - if (value) - AddNode<ValT>::impl(container, key, value.value()); - else if (Force) // Edge case, no value but must put something - AddNode<ValT>::impl(container, key, QString{}); - } - }; -#endif - } // namespace _impl static constexpr bool IfNotEmpty = false; diff --git a/lib/csapi/gtad.yaml b/lib/csapi/gtad.yaml index 301ee0b6..6d4e080f 100644 --- a/lib/csapi/gtad.yaml +++ b/lib/csapi/gtad.yaml @@ -95,9 +95,9 @@ analyzer: - //: { type: "QVector<{{1}}>", imports: <QtCore/QVector> } - map: # `additionalProperties` in OpenAPI - RoomState: - type: "std::unordered_map<QString, {{1}}>" + type: "UnorderedMap<QString, {{1}}>" moveOnly: - imports: <unordered_map> + imports: '"util.h"' - /.+/: type: "QHash<QString, {{1}}>" imports: <QtCore/QHash> diff --git a/lib/csapi/search.cpp b/lib/csapi/search.cpp deleted file mode 100644 index 9619f340..00000000 --- a/lib/csapi/search.cpp +++ /dev/null @@ -1,191 +0,0 @@ -/****************************************************************************** - * THIS FILE IS GENERATED - ANY EDITS WILL BE OVERWRITTEN - */ - -#include "search.h" - -#include "converters.h" - -#include <QtCore/QStringBuilder> - -using namespace Quotient; - -static const auto basePath = QStringLiteral("/_matrix/client/r0"); - -// Converters -namespace Quotient -{ - -template <> -struct JsonObjectConverter<SearchJob::IncludeEventContext> -{ - static void dumpTo(QJsonObject& jo, - const SearchJob::IncludeEventContext& pod) - { - addParam<IfNotEmpty>(jo, QStringLiteral("before_limit"), - pod.beforeLimit); - addParam<IfNotEmpty>(jo, QStringLiteral("after_limit"), pod.afterLimit); - addParam<IfNotEmpty>(jo, QStringLiteral("include_profile"), - pod.includeProfile); - } -}; - -template <> -struct JsonObjectConverter<SearchJob::Group> -{ - static void dumpTo(QJsonObject& jo, const SearchJob::Group& pod) - { - addParam<IfNotEmpty>(jo, QStringLiteral("key"), pod.key); - } -}; - -template <> -struct JsonObjectConverter<SearchJob::Groupings> -{ - static void dumpTo(QJsonObject& jo, const SearchJob::Groupings& pod) - { - addParam<IfNotEmpty>(jo, QStringLiteral("group_by"), pod.groupBy); - } -}; - -template <> -struct JsonObjectConverter<SearchJob::RoomEventsCriteria> -{ - static void dumpTo(QJsonObject& jo, const SearchJob::RoomEventsCriteria& pod) - { - addParam<>(jo, QStringLiteral("search_term"), pod.searchTerm); - addParam<IfNotEmpty>(jo, QStringLiteral("keys"), pod.keys); - addParam<IfNotEmpty>(jo, QStringLiteral("filter"), pod.filter); - addParam<IfNotEmpty>(jo, QStringLiteral("order_by"), pod.orderBy); - addParam<IfNotEmpty>(jo, QStringLiteral("event_context"), - pod.eventContext); - addParam<IfNotEmpty>(jo, QStringLiteral("include_state"), - pod.includeState); - addParam<IfNotEmpty>(jo, QStringLiteral("groupings"), pod.groupings); - } -}; - -template <> -struct JsonObjectConverter<SearchJob::Categories> -{ - static void dumpTo(QJsonObject& jo, const SearchJob::Categories& pod) - { - addParam<IfNotEmpty>(jo, QStringLiteral("room_events"), pod.roomEvents); - } -}; - -template <> -struct JsonObjectConverter<SearchJob::UserProfile> -{ - static void fillFrom(const QJsonObject& jo, SearchJob::UserProfile& result) - { - fromJson(jo.value("displayname"_ls), result.displayname); - fromJson(jo.value("avatar_url"_ls), result.avatarUrl); - } -}; - -template <> -struct JsonObjectConverter<SearchJob::EventContext> -{ - static void fillFrom(const QJsonObject& jo, SearchJob::EventContext& result) - { - fromJson(jo.value("start"_ls), result.begin); - fromJson(jo.value("end"_ls), result.end); - fromJson(jo.value("profile_info"_ls), result.profileInfo); - fromJson(jo.value("events_before"_ls), result.eventsBefore); - fromJson(jo.value("events_after"_ls), result.eventsAfter); - } -}; - -template <> -struct JsonObjectConverter<SearchJob::Result> -{ - static void fillFrom(const QJsonObject& jo, SearchJob::Result& result) - { - fromJson(jo.value("rank"_ls), result.rank); - fromJson(jo.value("result"_ls), result.result); - fromJson(jo.value("context"_ls), result.context); - } -}; - -template <> -struct JsonObjectConverter<SearchJob::GroupValue> -{ - static void fillFrom(const QJsonObject& jo, SearchJob::GroupValue& result) - { - fromJson(jo.value("next_batch"_ls), result.nextBatch); - fromJson(jo.value("order"_ls), result.order); - fromJson(jo.value("results"_ls), result.results); - } -}; - -template <> -struct JsonObjectConverter<SearchJob::ResultRoomEvents> -{ - static void fillFrom(const QJsonObject& jo, - SearchJob::ResultRoomEvents& result) - { - fromJson(jo.value("count"_ls), result.count); - fromJson(jo.value("highlights"_ls), result.highlights); - fromJson(jo.value("results"_ls), result.results); - fromJson(jo.value("state"_ls), result.state); - fromJson(jo.value("groups"_ls), result.groups); - fromJson(jo.value("next_batch"_ls), result.nextBatch); - } -}; - -template <> -struct JsonObjectConverter<SearchJob::ResultCategories> -{ - static void fillFrom(const QJsonObject& jo, - SearchJob::ResultCategories& result) - { - fromJson(jo.value("room_events"_ls), result.roomEvents); - } -}; - -} // namespace Quotient - -class SearchJob::Private -{ -public: - ResultCategories searchCategories; -}; - -BaseJob::Query queryToSearch(const QString& nextBatch) -{ - BaseJob::Query _q; - addParam<IfNotEmpty>(_q, QStringLiteral("next_batch"), nextBatch); - return _q; -} - -static const auto SearchJobName = QStringLiteral("SearchJob"); - -SearchJob::SearchJob(const Categories& searchCategories, - const QString& nextBatch) - : BaseJob(HttpVerb::Post, SearchJobName, basePath % "/search", - queryToSearch(nextBatch)) - , d(new Private) -{ - QJsonObject _data; - addParam<>(_data, QStringLiteral("search_categories"), searchCategories); - setRequestData(_data); -} - -SearchJob::~SearchJob() = default; - -const SearchJob::ResultCategories& SearchJob::searchCategories() const -{ - return d->searchCategories; -} - -BaseJob::Status SearchJob::parseJson(const QJsonDocument& data) -{ - auto json = data.object(); - if (!json.contains("search_categories"_ls)) - return { IncorrectResponse, - "The key 'search_categories' not found in the response" }; - fromJson(json.value("search_categories"_ls), d->searchCategories); - - return Success; -} diff --git a/lib/csapi/search.h b/lib/csapi/search.h deleted file mode 100644 index 079ac8e9..00000000 --- a/lib/csapi/search.h +++ /dev/null @@ -1,203 +0,0 @@ -/****************************************************************************** - * THIS FILE IS GENERATED - ANY EDITS WILL BE OVERWRITTEN - */ - -#pragma once - -#include "converters.h" - -#include "csapi/definitions/room_event_filter.h" - -#include "events/eventloader.h" -#include "jobs/basejob.h" - -#include <QtCore/QHash> -#include <QtCore/QVector> - -#include <unordered_map> - -namespace Quotient -{ - -// Operations - -/// Perform a server-side search. -/*! - * Performs a full text search across different categories. - */ -class SearchJob : public BaseJob -{ -public: - // Inner data structures - - /// Configures whether any context for the eventsreturned are included in - /// the response. - struct IncludeEventContext - { - /// How many events before the result arereturned. By default, this is - /// ``5``. - Omittable<int> beforeLimit; - /// How many events after the result arereturned. By default, this is - /// ``5``. - Omittable<int> afterLimit; - /// Requests that the server returns thehistoric profile information for - /// the usersthat sent the events that were returned.By default, this is - /// ``false``. - Omittable<bool> includeProfile; - }; - - /// Configuration for group. - struct Group - { - /// Key that defines the group. - QString key; - }; - - /// Requests that the server partitions the result setbased on the provided - /// list of keys. - struct Groupings - { - /// List of groups to request. - QVector<Group> groupBy; - }; - - /// Mapping of category name to search criteria. - struct RoomEventsCriteria - { - /// The string to search events for - QString searchTerm; - /// The keys to search. Defaults to all. - QStringList keys; - /// This takes a `filter`_. - Omittable<RoomEventFilter> filter; - /// The order in which to search for results.By default, this is - /// ``"rank"``. - QString orderBy; - /// Configures whether any context for the eventsreturned are included - /// in the response. - Omittable<IncludeEventContext> eventContext; - /// Requests the server return the current state foreach room returned. - Omittable<bool> includeState; - /// Requests that the server partitions the result setbased on the - /// provided list of keys. - Omittable<Groupings> groupings; - }; - - /// Describes which categories to search in and their criteria. - struct Categories - { - /// Mapping of category name to search criteria. - Omittable<RoomEventsCriteria> roomEvents; - }; - - /// Performs a full text search across different categories. - struct UserProfile - { - /// Performs a full text search across different categories. - QString displayname; - /// Performs a full text search across different categories. - QString avatarUrl; - }; - - /// Context for result, if requested. - struct EventContext - { - /// Pagination token for the start of the chunk - QString begin; - /// Pagination token for the end of the chunk - QString end; - /// The historic profile information of theusers that sent the events - /// returned.The ``string`` key is the user ID for whichthe profile - /// belongs to. - QHash<QString, UserProfile> profileInfo; - /// Events just before the result. - RoomEvents eventsBefore; - /// Events just after the result. - RoomEvents eventsAfter; - }; - - /// The result object. - struct Result - { - /// A number that describes how closely this result matches the search. - /// Higher is closer. - Omittable<double> rank; - /// The event that matched. - RoomEventPtr result; - /// Context for result, if requested. - Omittable<EventContext> context; - }; - - /// The results for a particular group value. - struct GroupValue - { - /// Token that can be used to get the next batchof results in the group, - /// by passing as the`next_batch` parameter to the next call. Ifthis - /// field is absent, there are no moreresults in this group. - QString nextBatch; - /// Key that can be used to order differentgroups. - Omittable<int> order; - /// Which results are in this group. - QStringList results; - }; - - /// Mapping of category name to search criteria. - struct ResultRoomEvents - { - /// An approximate count of the total number of results found. - Omittable<int> count; - /// List of words which should be highlighted, useful for stemming which - /// may change the query terms. - QStringList highlights; - /// List of results in the requested order. - std::vector<Result> results; - /// The current state for every room in the results.This is included if - /// the request had the``include_state`` key set with a value of - /// ``true``.The ``string`` key is the room ID for which the - /// ``StateEvent`` array belongs to. - std::unordered_map<QString, StateEvents> state; - /// Any groups that were requested.The outer ``string`` key is the group - /// key requested (eg: ``room_id``or ``sender``). The inner ``string`` - /// key is the grouped value (eg: a room's ID or a user's ID). - QHash<QString, QHash<QString, GroupValue>> groups; - /// Token that can be used to get the next batch ofresults, by passing - /// as the `next_batch` parameter tothe next call. If this field is - /// absent, there are nomore results. - QString nextBatch; - }; - - /// Describes which categories to search in and their criteria. - struct ResultCategories - { - /// Mapping of category name to search criteria. - Omittable<ResultRoomEvents> roomEvents; - }; - - // Construction/destruction - - /*! Perform a server-side search. - * \param searchCategories - * Describes which categories to search in and their criteria. - * \param nextBatch - * The point to return events from. If given, this should be a - * ``next_batch`` result from a previous call to this endpoint. - */ - explicit SearchJob(const Categories& searchCategories, - const QString& nextBatch = {}); - - ~SearchJob() override; - - // Result properties - - /// Describes which categories to search in and their criteria. - const ResultCategories& searchCategories() const; - -protected: - Status parseJson(const QJsonDocument& data) override; - -private: - class Private; - QScopedPointer<Private> d; -}; - -} // namespace Quotient diff --git a/lib/events/accountdataevents.h b/lib/events/accountdataevents.h index 31176766..a55016d9 100644 --- a/lib/events/accountdataevents.h +++ b/lib/events/accountdataevents.h @@ -1,5 +1,3 @@ -#include <utility> - /****************************************************************************** * Copyright (C) 2018 Kitsune Ral <kitsune-ral@users.sf.net> * @@ -34,13 +32,13 @@ struct TagRecord { order_type order; - TagRecord(order_type order = none) : order(order) {} + TagRecord(order_type order = none) : order(std::move(order)) {} bool operator<(const TagRecord& other) const { - // Per The Spec, rooms with no order should be after those with order - return !order.omitted() - && (other.order.omitted() || order.value() < other.order.value()); + // Per The Spec, rooms with no order should be after those with order, + // against optional<>::operator<() convention. + return order && (!other.order || *order < *other.order); } }; diff --git a/lib/events/roomevent.cpp b/lib/events/roomevent.cpp index 971d8597..a59cd6e0 100644 --- a/lib/events/roomevent.cpp +++ b/lib/events/roomevent.cpp @@ -36,17 +36,15 @@ RoomEvent::RoomEvent(Type type, const QJsonObject& json) : Event(type, json) { const auto unsignedData = json[UnsignedKeyL].toObject(); const auto redaction = unsignedData[RedactedCauseKeyL]; - if (redaction.isObject()) { + if (redaction.isObject()) _redactedBecause = makeEvent<RedactionEvent>(redaction.toObject()); - return; - } } RoomEvent::~RoomEvent() = default; // Let the smart pointer do its job QString RoomEvent::id() const { return fullJson()[EventIdKeyL].toString(); } -QDateTime RoomEvent::timestamp() const +QDateTime RoomEvent::originTimestamp() const { return Quotient::fromJson<QDateTime>(fullJson()["origin_server_ts"_ls]); } diff --git a/lib/events/roomevent.h b/lib/events/roomevent.h index f943bce4..621652cb 100644 --- a/lib/events/roomevent.h +++ b/lib/events/roomevent.h @@ -46,7 +46,10 @@ public: ~RoomEvent() override; QString id() const; - QDateTime timestamp() const; + QDateTime originTimestamp() const; + [[deprecated("Use originTimestamp()")]] QDateTime timestamp() const { + return originTimestamp(); + } QString roomId() const; QString senderId() const; bool isReplaced() const; diff --git a/lib/events/roommemberevent.cpp b/lib/events/roommemberevent.cpp index d0787170..d4b2be45 100644 --- a/lib/events/roommemberevent.cpp +++ b/lib/events/roommemberevent.cpp @@ -52,6 +52,7 @@ MemberEventContent::MemberEventContent(const QJsonObject& json) , isDirect(json["is_direct"_ls].toBool()) , displayName(sanitized(json["displayname"_ls].toString())) , avatarUrl(json["avatar_url"_ls].toString()) + , reason(json["reason"_ls].toString()) {} void MemberEventContent::fillJson(QJsonObject* o) const @@ -64,18 +65,23 @@ void MemberEventContent::fillJson(QJsonObject* o) const o->insert(QStringLiteral("displayname"), displayName); if (avatarUrl.isValid()) o->insert(QStringLiteral("avatar_url"), avatarUrl.toString()); + if (!reason.isEmpty()) + o->insert(QStringLiteral("reason"), reason); +} + +bool RoomMemberEvent::changesMembership() const +{ + return !prevContent() || prevContent()->membership != membership(); } bool RoomMemberEvent::isInvite() const { - return membership() == MembershipType::Invite - && (!prevContent() || prevContent()->membership != membership()); + return membership() == MembershipType::Invite && changesMembership(); } bool RoomMemberEvent::isJoin() const { - return membership() == MembershipType::Join - && (!prevContent() || prevContent()->membership != membership()); + return membership() == MembershipType::Join && changesMembership(); } bool RoomMemberEvent::isLeave() const diff --git a/lib/events/roommemberevent.h b/lib/events/roommemberevent.h index 6a34fd7f..0ca439e1 100644 --- a/lib/events/roommemberevent.h +++ b/lib/events/roommemberevent.h @@ -40,6 +40,7 @@ public: bool isDirect = false; QString displayName; QUrl avatarUrl; + QString reason; protected: void fillJson(QJsonObject* o) const override; @@ -56,8 +57,8 @@ public: explicit RoomMemberEvent(const QJsonObject& obj) : StateEvent(typeId(), obj) {} - [[deprecated("Use RoomMemberEvent(userId, contentArgs) " - "instead")]] RoomMemberEvent(MemberEventContent&& c) + [[deprecated("Use RoomMemberEvent(userId, contentArgs) instead")]] + RoomMemberEvent(MemberEventContent&& c) : StateEvent(typeId(), matrixTypeId(), QString(), c) {} template <typename... ArgTs> @@ -85,6 +86,8 @@ public: bool isDirect() const { return content().isDirect; } QString displayName() const { return content().displayName; } QUrl avatarUrl() const { return content().avatarUrl; } + QString reason() const { return content().reason; } + bool changesMembership() const; bool isInvite() const; bool isJoin() const; bool isLeave() const; diff --git a/lib/events/roommessageevent.cpp b/lib/events/roommessageevent.cpp index 09562d65..078ae70a 100644 --- a/lib/events/roommessageevent.cpp +++ b/lib/events/roommessageevent.cpp @@ -95,6 +95,11 @@ MsgType jsonToMsgType(const QString& matrixType) return MsgType::Unknown; } +inline bool isReplacement(const Omittable<RelatesTo>& rel) +{ + return rel && rel->type == RelatesTo::ReplacementTypeId(); +} + QJsonObject RoomMessageEvent::assembleContentJson(const QString& plainBody, const QString& jsonMsgType, TypedBase* content) @@ -111,6 +116,7 @@ QJsonObject RoomMessageEvent::assembleContentJson(const QString& plainBody, // After the above, we know for sure that the content is TextContent // and that its RelatesTo structure is not omitted auto* textContent = static_cast<const TextContent*>(content); + Q_ASSERT(textContent && textContent->relatesTo.has_value()); if (textContent->relatesTo->type == RelatesTo::ReplacementTypeId()) { auto newContentJson = json.take("m.new_content"_ls).toObject(); newContentJson.insert(BodyKey, plainBody); @@ -243,9 +249,7 @@ QString RoomMessageEvent::replacedEvent() const return {}; const auto& rel = static_cast<const TextContent*>(content())->relatesTo; - return !rel.omitted() && rel->type == RelatesTo::ReplacementTypeId() - ? rel->eventId - : QString(); + return isReplacement(rel) ? rel->eventId : QString(); } QString rawMsgTypeForMimeType(const QMimeType& mimeType) @@ -269,10 +273,10 @@ QString RoomMessageEvent::rawMsgTypeForFile(const QFileInfo& fi) return rawMsgTypeForMimeType(QMimeDatabase().mimeTypeForFile(fi)); } -TextContent::TextContent(const QString& text, const QString& contentType, +TextContent::TextContent(QString text, const QString& contentType, Omittable<RelatesTo> relatesTo) : mimeType(QMimeDatabase().mimeTypeForName(contentType)) - , body(text) + , body(std::move(text)) , relatesTo(std::move(relatesTo)) { if (contentType == HtmlContentTypeId) @@ -304,10 +308,9 @@ TextContent::TextContent(const QJsonObject& json) static const auto PlainTextMimeType = db.mimeTypeForName("text/plain"); static const auto HtmlMimeType = db.mimeTypeForName("text/html"); - const auto actualJson = - relatesTo.omitted() || relatesTo->type != RelatesTo::ReplacementTypeId() - ? json - : json.value("m.new_content"_ls).toObject(); + const auto actualJson = isReplacement(relatesTo) + ? json.value("m.new_content"_ls).toObject() + : json; // Special-casing the custom matrix.org's (actually, Riot's) way // of sending HTML messages. if (actualJson["format"_ls].toString() == HtmlContentTypeId) { @@ -331,7 +334,7 @@ void TextContent::fillJson(QJsonObject* json) const json->insert(FormatKey, HtmlContentTypeId); json->insert(FormattedBodyKey, body); } - if (!relatesTo.omitted()) { + if (relatesTo) { json->insert(QStringLiteral("m.relates_to"), QJsonObject { { relatesTo->type, relatesTo->eventId } }); if (relatesTo->type == RelatesTo::ReplacementTypeId()) { diff --git a/lib/events/roommessageevent.h b/lib/events/roommessageevent.h index e95aabfc..ded5e572 100644 --- a/lib/events/roommessageevent.h +++ b/lib/events/roommessageevent.h @@ -114,7 +114,7 @@ namespace EventContent { */ class TextContent : public TypedBase { public: - TextContent(const QString& text, const QString& contentType, + TextContent(QString text, const QString& contentType, Omittable<RelatesTo> relatesTo = none); explicit TextContent(const QJsonObject& json); diff --git a/lib/jobs/basejob.cpp b/lib/jobs/basejob.cpp index 13e65188..68adeaf6 100644 --- a/lib/jobs/basejob.cpp +++ b/lib/jobs/basejob.cpp @@ -138,8 +138,7 @@ BaseJob::BaseJob(HttpVerb verb, const QString& name, const QString& endpoint, setObjectName(name); connect(&d->timer, &QTimer::timeout, this, &BaseJob::timeout); connect(&d->retryTimer, &QTimer::timeout, this, [this] { - setStatus(Pending); - sendRequest(); + d->connection->submit(this); }); } @@ -226,8 +225,9 @@ void BaseJob::Private::sendRequest() requestQuery) }; if (!requestHeaders.contains("Content-Type")) req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - req.setRawHeader("Authorization", - QByteArray("Bearer ") + connection->accessToken()); + if (needsToken) + req.setRawHeader("Authorization", + QByteArray("Bearer ") + connection->accessToken()); req.setAttribute(QNetworkRequest::BackgroundRequestAttribute, inBackground); req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); req.setMaximumRedirectsAllowed(10); @@ -258,14 +258,27 @@ void BaseJob::onSentRequest(QNetworkReply*) {} void BaseJob::beforeAbandon(QNetworkReply*) {} -void BaseJob::prepare(ConnectionData* connData, bool inBackground) +void BaseJob::initiate(ConnectionData* connData, bool inBackground) { + Q_ASSERT(connData != nullptr); + d->inBackground = inBackground; d->connection = connData; doPrepare(); - if (status().code != Unprepared && status().code != Pending) + + if ((d->verb == HttpVerb::Post || d->verb == HttpVerb::Put) + && d->requestData.source() && !d->requestData.source()->isReadable()) { + setStatus(FileError, "Request data not ready"); + } + Q_ASSERT(status().code != Pending); // doPrepare() must NOT set this + if (status().code == Unprepared) { + d->connection->submit(this); + } else { + qDebug(d->logCat).noquote() + << "Request failed preparation and won't be sent:" + << d->dumpRequest(); QTimer::singleShot(0, this, &BaseJob::finishJob); - setStatus(Pending); + } } void BaseJob::sendRequest() @@ -274,6 +287,7 @@ void BaseJob::sendRequest() return; Q_ASSERT(d->connection && status().code == Pending); qCDebug(d->logCat).noquote() << "Making" << d->dumpRequest(); + d->needsToken |= d->connection->needsToken(objectName()); emit aboutToSendRequest(); d->sendRequest(); Q_ASSERT(d->reply); @@ -290,7 +304,7 @@ void BaseJob::sendRequest() onSentRequest(d->reply.data()); emit sentRequest(); } else - qCWarning(d->logCat).noquote() + qCCritical(d->logCat).noquote() << "Request could not start:" << d->dumpRequest(); } @@ -341,32 +355,32 @@ bool checkContentType(const QByteArray& type, const QByteArrayList& patterns) return false; } -BaseJob::Status BaseJob::Status::fromHttpCode(int httpCode, QString msg) +BaseJob::StatusCode BaseJob::Status::fromHttpCode(int httpCode) { + if (httpCode / 10 == 41) // 41x errors + return httpCode == 410 ? IncorrectRequestError : NotFoundError; + switch (httpCode) { + case 401: + return Unauthorised; + // clang-format off + case 403: case 407: // clang-format on + return ContentAccessError; + case 404: + return NotFoundError; // clang-format off - return { [httpCode]() -> StatusCode { - if (httpCode / 10 == 41) // 41x errors - return httpCode == 410 ? IncorrectRequestError : NotFoundError; - switch (httpCode) { - case 401: case 403: case 407: - return ContentAccessError; - case 404: - return NotFoundError; - case 400: case 405: case 406: case 426: case 428: case 505: - case 494: // Unofficial nginx "Request header too large" - case 497: // Unofficial nginx "HTTP request sent to HTTPS port" - return IncorrectRequestError; - case 429: - return TooManyRequestsError; - case 501: case 510: - return RequestNotImplementedError; - case 511: - return NetworkAuthRequiredError; - default: - return NetworkError; - } - }(), std::move(msg) }; - // clang-format on + case 400: case 405: case 406: case 426: case 428: case 505: // clang-format on + case 494: // Unofficial nginx "Request header too large" + case 497: // Unofficial nginx "HTTP request sent to HTTPS port" + return IncorrectRequestError; + case 429: + return TooManyRequestsError; + case 501: case 510: + return RequestNotImplementedError; + case 511: + return NetworkAuthRequiredError; + default: + return NetworkError; + } } QDebug BaseJob::Status::dumpToLog(QDebug dbg) const @@ -492,10 +506,18 @@ void BaseJob::finishJob() stop(); if (error() == TooManyRequests) { emit rateLimited(); - setStatus(Pending); d->connection->submit(this); return; } + if (error() == Unauthorised && !d->needsToken + && !d->connection->accessToken().isEmpty()) { + // Rerun with access token (extension of the spec while + // https://github.com/matrix-org/matrix-doc/issues/701 is pending) + d->connection->setNeedsToken(objectName()); + qCWarning(d->logCat) << this << "re-running with authentication"; + emit retryScheduled(d->retriesTaken, 0); + d->connection->submit(this); + } if ((error() == NetworkError || error() == Timeout) && d->retriesTaken < d->maxRetries) { // TODO: The whole retrying thing should be put to Connection(Manager) @@ -596,6 +618,8 @@ QString BaseJob::statusCaption() const return tr("Network problems"); case TimeoutError: return tr("Request timed out"); + case Unauthorised: + return tr("Unauthorised request"); case ContentAccessError: return tr("Access error"); case NotFoundError: diff --git a/lib/jobs/basejob.h b/lib/jobs/basejob.h index 6c1b802c..c8046e9e 100644 --- a/lib/jobs/basejob.h +++ b/lib/jobs/basejob.h @@ -57,9 +57,10 @@ public: Unprepared = 25, //< Initial job state is incomplete, hence warning level Abandoned = 50, //< A tiny period between abandoning and object deletion ErrorLevel = 100, //< Errors have codes starting from this - NetworkError = 100, + NetworkError = 101, Timeout, TimeoutError = Timeout, + Unauthorised, ContentAccessError, NotFoundError, IncorrectRequest, @@ -81,6 +82,7 @@ public: UserConsentRequiredError = UserConsentRequired, CannotLeaveRoom, UserDeactivated, + FileError, UserDefinedError = 256 }; Q_ENUM(StatusCode) @@ -113,7 +115,12 @@ public: struct Status { Status(StatusCode c) : code(c) {} Status(int c, QString m) : code(c), message(std::move(m)) {} - static Status fromHttpCode(int httpCode, QString msg = {}); + + static StatusCode fromHttpCode(int httpCode); + static Status fromHttpCode(int httpCode, QString msg) + { + return { fromHttpCode(httpCode), std::move(msg) }; + } bool good() const { return code < ErrorLevel; } QDebug dumpToLog(QDebug dbg) const; @@ -184,11 +191,11 @@ public: using duration_ms_t = std::chrono::milliseconds::rep; // normally int64_t std::chrono::seconds getCurrentTimeout() const; - Q_INVOKABLE duration_ms_t getCurrentTimeoutMs() const; + Q_INVOKABLE Quotient::BaseJob::duration_ms_t getCurrentTimeoutMs() const; std::chrono::seconds getNextRetryInterval() const; - Q_INVOKABLE duration_ms_t getNextRetryMs() const; + Q_INVOKABLE Quotient::BaseJob::duration_ms_t getNextRetryMs() const; std::chrono::milliseconds timeToRetry() const; - Q_INVOKABLE duration_ms_t millisToRetry() const; + Q_INVOKABLE Quotient::BaseJob::duration_ms_t millisToRetry() const; friend QDebug operator<<(QDebug dbg, const BaseJob* j) { @@ -196,7 +203,7 @@ public: } public slots: - void prepare(ConnectionData* connData, bool inBackground); + void initiate(ConnectionData* connData, bool inBackground); /** * Abandons the result of this job, arrived or unarrived. @@ -215,7 +222,7 @@ signals: void sentRequest(); /** The job has changed its status */ - void statusChanged(Status newStatus); + void statusChanged(Quotient::BaseJob::Status newStatus); /** * The previous network request has failed; the next attempt will @@ -225,7 +232,8 @@ signals: * @param inMilliseconds the interval after which the next attempt will be * taken */ - void retryScheduled(int nextAttempt, duration_ms_t inMilliseconds); + void retryScheduled(int nextAttempt, + Quotient::BaseJob::duration_ms_t inMilliseconds); /** * The previous network request has been rate-limited; the next attempt @@ -251,7 +259,7 @@ signals: * * @see result, success, failure */ - void finished(BaseJob* job); + void finished(Quotient::BaseJob* job); /** * Emitted when the job is finished (except when abandoned). @@ -262,14 +270,14 @@ signals: * * @see success, failure */ - void result(BaseJob* job); + void result(Quotient::BaseJob* job); /** * Emitted together with result() in case there's no error. * * @see result, failure */ - void success(BaseJob*); + void success(Quotient::BaseJob*); /** * Emitted together with result() if there's an error. @@ -277,7 +285,7 @@ signals: * * @see result, success */ - void failure(BaseJob*); + void failure(Quotient::BaseJob*); void downloadProgress(qint64 bytesReceived, qint64 bytesTotal); void uploadProgress(qint64 bytesSent, qint64 bytesTotal); diff --git a/lib/jobs/downloadfilejob.h b/lib/jobs/downloadfilejob.h index b7d2d75b..06dc145c 100644 --- a/lib/jobs/downloadfilejob.h +++ b/lib/jobs/downloadfilejob.h @@ -5,8 +5,6 @@ namespace Quotient { class DownloadFileJob : public GetContentJob { public: - enum { FileError = BaseJob::UserDefinedError + 1 }; - using GetContentJob::makeRequestUrl; static QUrl makeRequestUrl(QUrl baseUrl, const QUrl& mxcUri); diff --git a/lib/jobs/requestdata.cpp b/lib/jobs/requestdata.cpp index 0c70f085..cec15954 100644 --- a/lib/jobs/requestdata.cpp +++ b/lib/jobs/requestdata.cpp @@ -11,9 +11,8 @@ using namespace Quotient; auto fromData(const QByteArray& data) { auto source = std::make_unique<QBuffer>(); - source->open(QIODevice::WriteOnly); - source->write(data); - source->close(); + source->setData(data); + source->open(QIODevice::ReadOnly); return source; } diff --git a/lib/room.cpp b/lib/room.cpp index c4cdac04..696a5f1b 100644 --- a/lib/room.cpp +++ b/lib/room.cpp @@ -387,11 +387,31 @@ QString Room::predecessorId() const return d->getCurrentState<RoomCreateEvent>()->predecessor().roomId; } +Room* Room::predecessor(JoinStates statesFilter) const +{ + if (const auto& predId = predecessorId(); !predId.isEmpty()) + if (auto* r = connection()->room(predId, statesFilter); + r && r->successorId() == id()) + return r; + + return nullptr; +} + QString Room::successorId() const { return d->getCurrentState<RoomTombstoneEvent>()->successorRoomId(); } +Room* Room::successor(JoinStates statesFilter) const +{ + if (const auto& succId = successorId(); !succId.isEmpty()) + if (auto* r = connection()->room(succId, statesFilter); + r && r->predecessorId() == id()) + return r; + + return nullptr; +} + const Room::Timeline& Room::messageEvents() const { return d->timeline; } const Room::PendingEvents& Room::pendingEvents() const @@ -515,7 +535,9 @@ void Room::Private::updateUnreadCount(rev_iter_t from, rev_iter_t to) // that has just arrived. In this case we should recalculate // unreadMessages and might need to promote the read marker further // over local-origin messages. - const auto readMarker = q->readMarker(); + auto readMarker = q->readMarker(); + if (readMarker == timeline.crend() && q->allHistoryLoaded()) + --readMarker; // Read marker not found in the timeline, initialise it if (readMarker >= from && readMarker < to) { promoteReadMarker(q->localUser(), readMarker, true); return; @@ -926,12 +948,27 @@ void Room::removeTag(const QString& name) << "not found, nothing to remove"; } -void Room::setTags(TagsMap newTags) +void Room::setTags(TagsMap newTags, ActionScope applyOn) { + bool propagate = applyOn != ActionScope::ThisRoomOnly; + auto joinStates = + applyOn == ActionScope::WithinSameState ? joinState() : + applyOn == ActionScope::OmitLeftState ? JoinState::Join|JoinState::Invite : + JoinState::Join|JoinState::Invite|JoinState::Leave; + if (propagate) { + for (auto* r = this; (r = r->predecessor(joinStates));) + r->setTags(newTags, ActionScope::ThisRoomOnly); + } + d->setTags(move(newTags)); connection()->callApi<SetAccountDataPerRoomJob>( localUser()->id(), id(), TagEvent::matrixTypeId(), TagEvent(d->tags).contentJson()); + + if (propagate) { + for (auto* r = this; (r = r->successor(joinStates));) + r->setTags(newTags, ActionScope::ThisRoomOnly); + } } void Room::Private::setTags(TagsMap newTags) @@ -968,6 +1005,11 @@ QList<User*> Room::directChatUsers() const return connection()->directChatUsers(this); } +QString safeFileName(QString rawName) +{ + return rawName.replace(QRegularExpression("[/\\<>|\"*?:]"), "_"); +} + const RoomMessageEvent* Room::Private::getEventWithFile(const QString& eventId) const { @@ -983,24 +1025,24 @@ Room::Private::getEventWithFile(const QString& eventId) const QString Room::Private::fileNameToDownload(const RoomMessageEvent* event) const { - Q_ASSERT(event->hasFileContent()); + Q_ASSERT(event && event->hasFileContent()); const auto* fileInfo = event->content()->fileInfo(); QString fileName; if (!fileInfo->originalName.isEmpty()) - fileName = QFileInfo(fileInfo->originalName).fileName(); - else if (!event->plainBody().isEmpty()) { - // Having no better options, assume that the body has - // the original file URL or at least the file name. - if (QUrl u { event->plainBody() }; u.isValid()) - fileName = QFileInfo(u.path()).fileName(); + fileName = QFileInfo(safeFileName(fileInfo->originalName)).fileName(); + else if (QUrl u { event->plainBody() }; u.isValid()) { + qDebug(MAIN) << event->id() + << "has no file name supplied but the event body " + "looks like a URL - using the file name from it"; + fileName = u.fileName(); } - // Check the file name for sanity - if (fileName.isEmpty() || !QTemporaryFile(fileName).open()) - return "file." % fileInfo->mimeType.preferredSuffix(); + if (fileName.isEmpty()) + return safeFileName(fileInfo->mediaId()).replace('.', '-') % '.' + % fileInfo->mimeType.preferredSuffix(); if (QSysInfo::productType() == "windows") { if (const auto& suffixes = fileInfo->mimeType.suffixes(); - suffixes.isEmpty() + !suffixes.isEmpty() && std::none_of(suffixes.begin(), suffixes.end(), [&fileName](const QString& s) { return fileName.endsWith(s); @@ -1201,16 +1243,14 @@ QString Room::decryptMessage(QByteArray cipher, const QString& senderKey, int Room::joinedCount() const { - return d->summary.joinedMemberCount.omitted() - ? d->membersMap.size() - : d->summary.joinedMemberCount.value(); + return d->summary.joinedMemberCount.value_or(d->membersMap.size()); } int Room::invitedCount() const { // TODO: Store invited users in Room too - Q_ASSERT(!d->summary.invitedMemberCount.omitted()); - return d->summary.invitedMemberCount.value(); + Q_ASSERT(d->summary.invitedMemberCount.has_value()); + return d->summary.invitedMemberCount.value_or(0); } int Room::totalMemberCount() const { return joinedCount() + invitedCount(); } @@ -1593,19 +1633,16 @@ QString Room::postFile(const QString& plainText, const QUrl& localPath, QFileInfo localFile { localPath.toLocalFile() }; Q_ASSERT(localFile.isFile()); - const auto txnId = connection()->generateTxnId(); + const auto txnId = + d->addAsPending( + makeEvent<RoomMessageEvent>(plainText, localFile, asGenericFile)) + ->transactionId(); // Remote URL will only be known after upload; fill in the local path // to enable the preview while the event is pending. uploadFile(txnId, localPath); - { - auto&& event = - makeEvent<RoomMessageEvent>(plainText, localFile, asGenericFile); - event->setTransactionId(txnId); - d->addAsPending(std::move(event)); - } - auto* context = new QObject(this); - connect(this, &Room::fileTransferCompleted, context, - [context, this, txnId](const QString& id, QUrl, const QUrl& mxcUri) { + // Below, the upload job is used as a context object to clean up connections + connect(this, &Room::fileTransferCompleted, d->fileTransfers[txnId].job, + [this, txnId](const QString& id, QUrl, const QUrl& mxcUri) { if (id == txnId) { auto it = findPendingEvent(txnId); if (it != d->unsyncedEvents.end()) { @@ -1621,11 +1658,10 @@ QString Room::postFile(const QString& plainText, const QUrl& localPath, << "but the event referring to it was " "cancelled"; } - context->deleteLater(); } }); - connect(this, &Room::fileTransferCancelled, this, - [context, this, txnId](const QString& id) { + connect(this, &Room::fileTransferCancelled, d->fileTransfers[txnId].job, + [this, txnId](const QString& id) { if (id == txnId) { auto it = findPendingEvent(txnId); if (it != d->unsyncedEvents.end()) { @@ -1635,7 +1671,6 @@ QString Room::postFile(const QString& plainText, const QUrl& localPath, d->unsyncedEvents.erase(d->unsyncedEvents.begin() + idx); emit pendingEventDiscarded(); } - context->deleteLater(); } }); @@ -1814,7 +1849,7 @@ void Room::uploadFile(const QString& id, const QUrl& localFilename, auto fileName = localFilename.toLocalFile(); auto job = connection()->uploadFile(fileName, overrideContentType); if (isJobRunning(job)) { - d->fileTransfers.insert(id, { job, fileName, true }); + d->fileTransfers[id] = { job, fileName, true }; connect(job, &BaseJob::uploadProgress, this, [this, id](qint64 sent, qint64 total) { d->fileTransfers[id].update(sent, total); @@ -1858,18 +1893,20 @@ void Room::downloadFile(const QString& eventId, const QUrl& localFilename) } const auto fileUrl = fileInfo->url; auto filePath = localFilename.toLocalFile(); - if (filePath.isEmpty()) { - // Build our own file path, starting with temp directory and eventId. - filePath = eventId; - filePath = QDir::tempPath() % '/' - % filePath.replace(QRegularExpression("[/\\<>|\"*?:]"), "_") - % '#' % d->fileNameToDownload(event); + if (filePath.isEmpty()) { // Setup default file path + filePath = + fileInfo->url.path().mid(1) % '_' % d->fileNameToDownload(event); + + if (filePath.size() > 200) // If too long, elide in the middle + filePath.replace(128, filePath.size() - 192, "---"); + + filePath = QDir::tempPath() % '/' % filePath; + qDebug(MAIN) << "File path:" << filePath; } auto job = connection()->downloadFile(fileUrl, filePath); if (isJobRunning(job)) { - // If there was a previous transfer (completed or failed), remove it. - d->fileTransfers.remove(eventId); - d->fileTransfers.insert(eventId, { job, job->targetFileName() }); + // If there was a previous transfer (completed or failed), overwrite it. + d->fileTransfers[eventId] = { job, job->targetFileName() }; connect(job, &BaseJob::downloadProgress, this, [this, eventId](qint64 received, qint64 total) { d->fileTransfers[eventId].update(received, total); @@ -1936,22 +1973,15 @@ RoomEventPtr makeRedacted(const RoomEvent& target, const RedactionEvent& redaction) { auto originalJson = target.originalJsonObject(); - static const QStringList keepKeys { EventIdKey, - TypeKey, - QStringLiteral("room_id"), - QStringLiteral("sender"), - StateKeyKey, - QStringLiteral("prev_content"), - ContentKey, - QStringLiteral("hashes"), - QStringLiteral("signatures"), - QStringLiteral("depth"), - QStringLiteral("prev_events"), - QStringLiteral("prev_state"), - QStringLiteral("auth_events"), - QStringLiteral("origin"), - QStringLiteral("origin_server_ts"), - QStringLiteral("membership") }; + // clang-format off + static const QStringList keepKeys { EventIdKey, TypeKey, + QStringLiteral("room_id"), QStringLiteral("sender"), StateKeyKey, + QStringLiteral("hashes"), QStringLiteral("signatures"), + QStringLiteral("depth"), QStringLiteral("prev_events"), + QStringLiteral("prev_state"), QStringLiteral("auth_events"), + QStringLiteral("origin"), QStringLiteral("origin_server_ts"), + QStringLiteral("membership") }; + // clang-format on std::vector<std::pair<Event::Type, QStringList>> keepContentKeysMap { { RoomMemberEvent::typeId(), { QStringLiteral("membership") } }, @@ -2107,7 +2137,7 @@ inline bool isEditing(const RoomEventPtr& ep) if (is<RedactionEvent>(*ep)) return true; if (auto* msgEvent = eventCast<RoomMessageEvent>(ep)) - return msgEvent->replacedEvent().isEmpty(); + return !msgEvent->replacedEvent().isEmpty(); return false; } @@ -2130,12 +2160,10 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) // Try to find the target in the timeline, then in the batch. if (processRedaction(*r)) continue; - auto targetIt = std::find_if(events.begin(), it, - [id = r->redactedEvent()]( - const RoomEventPtr& ep) { - return ep->id() == id; - }); - if (targetIt != it) + if (auto targetIt = std::find_if(events.begin(), events.end(), + [id = r->redactedEvent()](const RoomEventPtr& ep) { + return ep->id() == id; + }); targetIt != events.end()) *targetIt = makeRedacted(**targetIt, *r); else qCDebug(EVENTS) @@ -2143,25 +2171,23 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) << r->redactedEvent() << "is not found"; // If the target event comes later, it comes already redacted. } - if (auto* msg = eventCast<RoomMessageEvent>(eptr)) { - if (!msg->replacedEvent().isEmpty()) { - if (processReplacement(*msg)) - continue; - auto targetIt = std::find_if(events.begin(), it, - [id = msg->replacedEvent()]( - const RoomEventPtr& ep) { - return ep->id() == id; - }); - if (targetIt != it) - *targetIt = makeReplaced(**targetIt, *msg); - else // FIXME: don't ignore, just show it wherever it arrived - qCDebug(EVENTS) - << "Replacing event" << msg->id() - << "ignored: replaced event" << msg->replacedEvent() - << "is not found"; - // Same as with redactions above, the replaced event coming - // later will come already with the new content. - } + if (auto* msg = eventCast<RoomMessageEvent>(eptr); + msg && !msg->replacedEvent().isEmpty()) { + if (processReplacement(*msg)) + continue; + auto targetIt = std::find_if(events.begin(), it, + [id = msg->replacedEvent()](const RoomEventPtr& ep) { + return ep->id() == id; + }); + if (targetIt != it) + *targetIt = makeReplaced(**targetIt, *msg); + else // FIXME: don't ignore, just show it wherever it arrived + qCDebug(EVENTS) + << "Replacing event" << msg->id() + << "ignored: replaced event" << msg->replacedEvent() + << "is not found"; + // Same as with redactions above, the replaced event coming + // later will come already with the new content. } } } @@ -2326,7 +2352,7 @@ Room::Changes Room::processStateEvent(const RoomEvent& e) || (oldStateEvent->matrixType() == e.matrixType() && oldStateEvent->stateKey() == e.stateKey())); if (!is<RoomMemberEvent>(e)) // Room member events are too numerous - qCDebug(EVENTS) << "Room state event:" << e; + qCDebug(STATE) << "Room state event:" << e; // clang-format off return visit(e @@ -2336,10 +2362,10 @@ Room::Changes Room::processStateEvent(const RoomEvent& e) , [this,oldStateEvent] (const RoomAliasesEvent& ae) { // clang-format on if (ae.aliases().isEmpty()) { - qCDebug(STATE).noquote() - << ae.stateKey() << "no more has aliases for room" - << objectName(); - d->aliasServers.remove(ae.stateKey()); + if (d->aliasServers.remove(ae.stateKey())) + qCDebug(STATE).noquote() + << ae.stateKey() << "no more has aliases for room" + << objectName(); } else { d->aliasServers.insert(ae.stateKey()); qCDebug(STATE).nospace().noquote() @@ -2620,9 +2646,8 @@ QString Room::Private::calculateDisplayname() const const bool emptyRoom = membersMap.isEmpty() || (membersMap.size() == 1 && isLocalUser(*membersMap.begin())); - const bool nonEmptySummary = - !summary.heroes.omitted() && !summary.heroes->empty(); - auto shortlist = nonEmptySummary ? buildShortlist(summary.heroes.value()) + const bool nonEmptySummary = summary.heroes && !summary.heroes->empty(); + auto shortlist = nonEmptySummary ? buildShortlist(*summary.heroes) : !emptyRoom ? buildShortlist(membersMap) : users_shortlist_t {}; @@ -27,6 +27,8 @@ #include "events/accountdataevents.h" #include "events/encryptedevent.h" #include "events/roommessageevent.h" +#include "events/roomcreateevent.h" +#include "events/roomtombstoneevent.h" #include <QtCore/QJsonObject> #include <QtGui/QImage> @@ -167,7 +169,22 @@ public: QString version() const; bool isUnstable() const; QString predecessorId() const; + /// Room predecessor + /** This function validates that the predecessor has a tombstone and + * the tombstone refers to the current room. If that's not the case, + * or if the predecessor is in a join state not matching \p stateFilter, + * the function returns nullptr. + */ + Room* predecessor(JoinStates statesFilter = JoinState::Invite + | JoinState::Join) const; QString successorId() const; + /// Room successor + /** This function validates that the successor room's creation event + * refers to the current room. If that's not the case, or if the successor + * is in a join state not matching \p stateFilter, it returns nullptr. + */ + Room* successor(JoinStates statesFilter = JoinState::Invite + | JoinState::Join) const; QString name() const; /// Room aliases defined on the current user's server /// \sa remoteAliases, setLocalAliases @@ -182,10 +199,10 @@ public: QUrl avatarUrl() const; const Avatar& avatarObject() const; Q_INVOKABLE JoinState joinState() const; - Q_INVOKABLE QList<User*> usersTyping() const; + Q_INVOKABLE QList<Quotient::User*> usersTyping() const; QList<User*> membersLeft() const; - Q_INVOKABLE QList<User*> users() const; + Q_INVOKABLE QList<Quotient::User*> users() const; QStringList memberNames() const; [[deprecated("Use joinedCount(), invitedCount(), totalMemberCount()")]] int memberCount() const; @@ -228,7 +245,7 @@ public: * \note The method will return a valid user regardless of * the membership. */ - Q_INVOKABLE User* user(const QString& userId) const; + Q_INVOKABLE Quotient::User* user(const QString& userId) const; /** * \brief Check the join state of a given user in this room @@ -236,16 +253,15 @@ public: * \note Banned and invited users are not tracked for now (Leave * will be returned for them). * - * \return either of Join, Leave, depending on the given - * user's state in the room + * \return Join if the user is a room member; Leave otherwise */ - Q_INVOKABLE JoinState memberJoinState(User* user) const; + Q_INVOKABLE Quotient::JoinState memberJoinState(Quotient::User* user) const; /** * Get a disambiguated name for a given user in * the context of the room */ - Q_INVOKABLE QString roomMembername(const User* u) const; + Q_INVOKABLE QString roomMembername(const Quotient::User* u) const; /** * Get a disambiguated name for a user with this id in * the context of the room @@ -274,9 +290,10 @@ public: Timeline::const_iterator syncEdge() const; /// \deprecated Use historyEdge instead rev_iter_t timelineEdge() const; - Q_INVOKABLE TimelineItem::index_t minTimelineIndex() const; - Q_INVOKABLE TimelineItem::index_t maxTimelineIndex() const; - Q_INVOKABLE bool isValidIndex(TimelineItem::index_t timelineIndex) const; + Q_INVOKABLE Quotient::TimelineItem::index_t minTimelineIndex() const; + Q_INVOKABLE Quotient::TimelineItem::index_t maxTimelineIndex() const; + Q_INVOKABLE bool + isValidIndex(Quotient::TimelineItem::index_t timelineIndex) const; rev_iter_t findInTimeline(TimelineItem::index_t index) const; rev_iter_t findInTimeline(const QString& evtId) const; @@ -288,6 +305,11 @@ public: const RelatedEvents relatedEvents(const RoomEvent& evt, const char* relType) const; + const RoomCreateEvent* creation() const + { return getCurrentState<RoomCreateEvent>(); } + const RoomTombstoneEvent* tombstone() const + { return getCurrentState<RoomTombstoneEvent>(); } + bool displayed() const; /// Mark the room as currently displayed to the user /** @@ -374,6 +396,19 @@ public: /// Remove a tag from the room Q_INVOKABLE void removeTag(const QString& name); + /// The scope to apply an action on + /*! This enumeration is used to pick a strategy to propagate certain + * actions on the room to its predecessors and successors. + */ + enum ActionScope { + ThisRoomOnly, //< Do not apply to predecessors and successors + WithinSameState, //< Apply to predecessors and successors in the same + //< state as the current one + OmitLeftState, //< Apply to all reachable predecessors and successors + //< except those in Leave state + WholeSequence //< Apply to all reachable predecessors and successors + }; + /** Overwrite the room's tags * This completely replaces the existing room's tags with a set * of new ones and updates the new set on the server. Unlike @@ -381,8 +416,11 @@ public: * immediately, not waiting for confirmation from the server * (because tags are saved in account data rather than in shared * room state). + * \param applyOn setting this to Room::OnAllConversations will set tags + * on this and all _known_ predecessors and successors; + * by default only the current room is changed */ - void setTags(TagsMap newTags); + void setTags(TagsMap newTags, ActionScope applyOn = ThisRoomOnly); /// Check whether the list of tags has m.favourite bool isFavourite() const; @@ -414,7 +452,8 @@ public: * the event is even sent), while downloads are using * the normal event id for identifier. */ - Q_INVOKABLE FileTransferInfo fileTransferInfo(const QString& id) const; + Q_INVOKABLE Quotient::FileTransferInfo + fileTransferInfo(const QString& id) const; /// Get the URL to the actual file source in a unified way /*! @@ -438,9 +477,13 @@ public: /*! This method returns a (potentially empty) state event corresponding * to the pair of event type \p evtType and state key \p stateKey. */ - Q_INVOKABLE const StateEventBase* + Q_INVOKABLE const Quotient::StateEventBase* getCurrentState(const QString& evtType, const QString& stateKey = {}) const; + /// Get a state event with the given event type and state key + /*! This is a typesafe overload that accepts a C++ event type instead of + * its Matrix name. + */ template <typename EvT> const EvT* getCurrentState(const QString& stateKey = {}) const { @@ -452,6 +495,14 @@ public: return evt; } + /// Set a state event of the given type with the given arguments + /*! This typesafe overload attempts to send a state event with the type + * \p EvT and the content defined by \p args. Specifically, the function + * creates a temporary object of type \p EvT passing \p args to + * the constructor, and sends a request to the homeserver using + * the Matrix event type defined by \p EvT and the event content produced + * via EvT::contentJson(). + */ template <typename EvT, typename... ArgTs> auto setState(ArgTs&&... args) const { @@ -549,7 +600,8 @@ signals: /// The remote echo has arrived with the sync and will be merged /// with its local counterpart /** NB: Requires a sync loop to be emitted */ - void pendingEventAboutToMerge(RoomEvent* serverEvent, int pendingEventIndex); + void pendingEventAboutToMerge(Quotient::RoomEvent* serverEvent, + int pendingEventIndex); /// The remote and local copies of the event have been merged /** NB: Requires a sync loop to be emitted */ void pendingEventMerged(); @@ -577,21 +629,21 @@ signals: * upon the last sync * \sa Changes */ - void changed(Changes changes); + void changed(Quotient::Room::Changes changes); /** * \brief The room name, the canonical alias or other aliases changed * * Not triggered when display name changes. */ - void namesChanged(Room* room); - void displaynameAboutToChange(Room* room); - void displaynameChanged(Room* room, QString oldName); + void namesChanged(Quotient::Room* room); + void displaynameAboutToChange(Quotient::Room* room); + void displaynameChanged(Quotient::Room* room, QString oldName); void topicChanged(); void avatarChanged(); - void userAdded(User* user); - void userRemoved(User* user); - void memberAboutToRename(User* user, QString newName); - void memberRenamed(User* user); + void userAdded(Quotient::User* user); + void userRemoved(Quotient::User* user); + void memberAboutToRename(Quotient::User* user, QString newName); + void memberRenamed(Quotient::User* user); /// The list of members has changed /** Emitted no more than once per sync, this is a good signal to * for cases when some action should be done upon any change in @@ -605,7 +657,8 @@ signals: void allMembersLoaded(); void encryption(); - void joinStateChanged(JoinState oldState, JoinState newState); + void joinStateChanged(Quotient::JoinState oldState, + Quotient::JoinState newState); void typingChanged(); void highlightCountChanged(); @@ -614,11 +667,11 @@ signals: void displayedChanged(bool displayed); void firstDisplayedEventChanged(); void lastDisplayedEventChanged(); - void lastReadEventChanged(User* user); + void lastReadEventChanged(Quotient::User* user); void readMarkerMoved(QString fromEventId, QString toEventId); - void readMarkerForUserMoved(User* user, QString fromEventId, + void readMarkerForUserMoved(Quotient::User* user, QString fromEventId, QString toEventId); - void unreadMessagesChanged(Room* room); + void unreadMessagesChanged(Quotient::Room* room); void accountDataAboutToChange(QString type); void accountDataChanged(QString type); @@ -626,7 +679,8 @@ signals: void tagsChanged(); void updatedEvent(QString eventId); - void replacedEvent(const RoomEvent* newEvent, const RoomEvent* oldEvent); + void replacedEvent(const Quotient::RoomEvent* newEvent, + const Quotient::RoomEvent* oldEvent); void newFileTransfer(QString id, QUrl localFile); void fileTransferProgress(QString id, qint64 progress, qint64 total); @@ -634,18 +688,18 @@ signals: void fileTransferFailed(QString id, QString errorMessage = {}); void fileTransferCancelled(QString id); - void callEvent(Room* room, const RoomEvent* event); + void callEvent(Quotient::Room* room, const Quotient::RoomEvent* event); /// The room's version stability may have changed void stabilityUpdated(QString recommendedDefault, QStringList stableVersions); /// This room has been upgraded and won't receive updates any more - void upgraded(QString serverMessage, Room* successor); + void upgraded(QString serverMessage, Quotient::Room* successor); /// An attempted room upgrade has failed void upgradeFailed(QString errorMessage); /// The room is about to be deleted - void beforeDestruction(Room*); + void beforeDestruction(Quotient::Room*); protected: virtual Changes processStateEvent(const RoomEvent& e); diff --git a/lib/syncdata.cpp b/lib/syncdata.cpp index 5b47b30f..89c512a2 100644 --- a/lib/syncdata.cpp +++ b/lib/syncdata.cpp @@ -30,8 +30,7 @@ const QString SyncRoomData::UnreadCountKey = bool RoomSummary::isEmpty() const { - return joinedMemberCount.omitted() && invitedMemberCount.omitted() - && heroes.omitted(); + return !joinedMemberCount && !invitedMemberCount && !heroes; } bool RoomSummary::merge(const RoomSummary& other) @@ -46,12 +45,12 @@ QDebug Quotient::operator<<(QDebug dbg, const RoomSummary& rs) { QDebugStateSaver _(dbg); QStringList sl; - if (!rs.joinedMemberCount.omitted()) - sl << QStringLiteral("joined: %1").arg(rs.joinedMemberCount.value()); - if (!rs.invitedMemberCount.omitted()) - sl << QStringLiteral("invited: %1").arg(rs.invitedMemberCount.value()); - if (!rs.heroes.omitted()) - sl << QStringLiteral("heroes: [%1]").arg(rs.heroes.value().join(',')); + if (rs.joinedMemberCount) + sl << QStringLiteral("joined: %1").arg(*rs.joinedMemberCount); + if (rs.invitedMemberCount) + sl << QStringLiteral("invited: %1").arg(*rs.invitedMemberCount); + if (rs.heroes) + sl << QStringLiteral("heroes: [%1]").arg(rs.heroes->join(',')); dbg.nospace().noquote() << sl.join(QStringLiteral("; ")); return dbg; } @@ -107,9 +107,10 @@ public: qreal hueF() const; const Avatar& avatarObject(const Room* room = nullptr) const; - Q_INVOKABLE QImage avatar(int dimension, const Room* room = nullptr); + Q_INVOKABLE QImage avatar(int dimension, + const Quotient::Room* room = nullptr); Q_INVOKABLE QImage avatar(int requestedWidth, int requestedHeight, - const Room* room = nullptr); + const Quotient::Room* room = nullptr); QImage avatar(int width, int height, const Room* room, const Avatar::get_callback_t& callback); @@ -145,9 +146,10 @@ public slots: signals: void nameAboutToChange(QString newName, QString oldName, - const Room* roomContext); - void nameChanged(QString newName, QString oldName, const Room* roomContext); - void avatarChanged(User* user, const Room* roomContext); + const Quotient::Room* roomContext); + void nameChanged(QString newName, QString oldName, + const Quotient::Room* roomContext); + void avatarChanged(Quotient::User* user, const Quotient::Room* roomContext); private slots: void updateName(const QString& newName, const Room* room = nullptr); @@ -161,4 +163,3 @@ private: QScopedPointer<Private> d; }; } // namespace Quotient -Q_DECLARE_METATYPE(Quotient::User*) diff --git a/lib/util.cpp b/lib/util.cpp index 041a8aba..9f4ac85f 100644 --- a/lib/util.cpp +++ b/lib/util.cpp @@ -56,7 +56,7 @@ void Quotient::linkifyUrls(QString& htmlEscapedText) // https://matrix.org/docs/spec/appendices.html#identifier-grammar static const QRegularExpression MxIdRegExp( QStringLiteral( - R"((^|[^<>/])([!#@][-a-z0-9_=/.]{1,252}:(?:\w|\.|-)+\.\w+(?::\d{1,5})?))"), + R"((^|[^<>/])([!#@][-a-z0-9_=#/.]{1,252}:(?:\w|\.|-)+\.\w+(?::\d{1,5})?))"), RegExpOptions); // NOTE: htmlEscapedText is already HTML-escaped! No literal <,>,&," @@ -24,6 +24,7 @@ #include <functional> #include <memory> #include <unordered_map> +#include <optional> // Along the lines of Q_DISABLE_COPY - the upstream version comes in Qt 5.13 #define DISABLE_MOVE(_ClassName) \ @@ -43,68 +44,83 @@ struct HashQ { template <typename KeyT, typename ValT> using UnorderedMap = std::unordered_map<KeyT, ValT, HashQ<KeyT>>; -struct NoneTag {}; -constexpr NoneTag none {}; +constexpr auto none = std::nullopt; -/** A crude substitute for `optional` while we're not C++17 +/** `std::optional` with tweaks * - * Only works with default-constructible types. + * The tweaks are: + * - streamlined assignment (operator=)/emplace()ment of values that can be + * used to implicitly construct the underlying type, including + * direct-list-initialisation, e.g.: + * \code + * struct S { int a; char b; } + * Omittable<S> o; + * o = { 1, 'a' }; // std::optional would require o = S { 1, 'a' } + * \endcode + * - entirely deleted value(). The technical reason is that Xcode 10 doesn't + * have it; but besides that, value_or() or (after explicit checking) + * `operator*()`/`operator->()` are better alternatives within Quotient + * that doesn't practice throwing exceptions (as doesn't most of Qt). + * - disabled non-const lvalue operator*() and operator->(), as it's too easy + * to inadvertently cause a value change through them. + * - edit() to provide a safe and explicit lvalue accessor instead of those + * above. Requires the underlying type to be default-constructible. + * Allows chained initialisation of nested Omittables: + * \code + * struct Inner { int member = 10; Omittable<int> innermost; }; + * struct Outer { int anotherMember = 10; Omittable<Inner> inner; }; + * Omittable<Outer> o; // = { 10, std::nullopt }; + * o.edit().inner.edit().innermost.emplace(42); + * \endcode + * - merge() - a soft version of operator= that only overwrites its first + * operand with the second one if the second one is not empty. */ template <typename T> -class Omittable { - static_assert(!std::is_reference<T>::value, - "You cannot make an Omittable<> with a reference type"); - +class Omittable : public std::optional<T> { public: + using base_type = std::optional<T>; using value_type = std::decay_t<T>; - explicit Omittable() : Omittable(none) {} - Omittable(NoneTag) : _value(value_type()), _omitted(true) {} - Omittable(const value_type& val) : _value(val) {} - Omittable(value_type&& val) : _value(std::move(val)) {} - Omittable<T>& operator=(const value_type& val) + using std::optional<T>::optional; + + // Overload emplace() and operator=() to allow passing braced-init-lists + // (the standard emplace() does direct-initialisation but + // not direct-list-initialisation). + using base_type::operator=; + Omittable& operator=(const value_type& v) { - _value = val; - _omitted = false; + base_type::operator=(v); return *this; } - Omittable<T>& operator=(value_type&& val) + Omittable& operator=(value_type&& v) { - // For some reason GCC complains about -Wmaybe-uninitialized - // in the context of using Omittable<bool> with converters.h; - // though the logic looks very much benign (GCC bug???) - _value = std::move(val); - _omitted = false; + base_type::operator=(v); return *this; } - - bool operator==(const value_type& rhs) const - { - return !omitted() && value() == rhs; - } - friend bool operator==(const value_type& lhs, - const Omittable<value_type>& rhs) - { - return rhs == lhs; - } - bool operator!=(const value_type& rhs) const { return !operator==(rhs); } - friend bool operator!=(const value_type& lhs, - const Omittable<value_type>& rhs) + using base_type::emplace; + T& emplace(const T& val) { return base_type::emplace(val); } + T& emplace(T&& val) { return base_type::emplace(std::move(val)); } + + // use value_or() or check (with operator! or has_value) before accessing + // with operator-> or operator* + // The technical reason is that Xcode 10 has incomplete std::optional + // that has no value(); but using value() may also mean that you rely + // on the optional throwing an exception (which is not assumed practice + // throughout Quotient) or that you spend unnecessary CPU cycles on + // an extraneous has_value() check. + value_type& value() = delete; + const value_type& value() const = delete; + value_type& edit() { - return !(rhs == lhs); + return this->has_value() ? base_type::operator*() : this->emplace(); } - bool omitted() const { return _omitted; } - const value_type& value() const + [[deprecated("Use '!o' or '!o.has_value()' instead of 'o.omitted()'")]] + bool omitted() const { - Q_ASSERT(!_omitted); - return _value; - } - value_type& editValue() - { - _omitted = false; - return _value; + return !this->has_value(); } + /// Merge the value from another Omittable /// \return true if \p other is not omitted and the value of /// the current Omittable was different (or omitted); @@ -114,26 +130,20 @@ public: auto merge(const Omittable<T1>& other) -> std::enable_if_t<std::is_convertible<T1, T>::value, bool> { - if (other.omitted() || (!_omitted && _value == other.value())) + if (!other || (this->has_value() && **this == *other)) return false; - _omitted = false; - _value = other.value(); + *this = other; return true; } - value_type&& release() - { - _omitted = true; - return std::move(_value); - } - const value_type* operator->() const& { return &value(); } - value_type* operator->() & { return &editValue(); } - const value_type& operator*() const& { return value(); } - value_type& operator*() & { return editValue(); } + // Hide non-const lvalue operator-> and operator* as these are + // a bit too surprising: value() & doesn't lazy-create an object; + // and it's too easy to inadvertently change the underlying value. -private: - T _value; - bool _omitted = false; + const value_type* operator->() const& { return base_type::operator->(); } + value_type* operator->() && { return base_type::operator->(); } + const value_type& operator*() const& { return base_type::operator*(); } + value_type& operator*() && { return base_type::operator*(); } }; namespace _impl { @@ -213,19 +223,19 @@ class Range { using size_type = typename ArrayT::size_type; public: - Range(ArrayT& arr) : from(std::begin(arr)), to(std::end(arr)) {} - Range(iterator from, iterator to) : from(from), to(to) {} + constexpr Range(ArrayT& arr) : from(std::begin(arr)), to(std::end(arr)) {} + constexpr Range(iterator from, iterator to) : from(from), to(to) {} - size_type size() const + constexpr size_type size() const { Q_ASSERT(std::distance(from, to) >= 0); return size_type(std::distance(from, to)); } - bool empty() const { return from == to; } - const_iterator begin() const { return from; } - const_iterator end() const { return to; } - iterator begin() { return from; } - iterator end() { return to; } + constexpr bool empty() const { return from == to; } + constexpr const_iterator begin() const { return from; } + constexpr const_iterator end() const { return to; } + constexpr iterator begin() { return from; } + constexpr iterator end() { return to; } private: iterator from; @@ -239,8 +249,8 @@ private: */ template <typename InputIt, typename ForwardIt, typename Pred> inline std::pair<InputIt, ForwardIt> findFirstOf(InputIt first, InputIt last, - ForwardIt sFirst, - ForwardIt sLast, Pred pred) + ForwardIt sFirst, + ForwardIt sLast, Pred pred) { for (; first != last; ++first) for (auto it = sFirst; it != sLast; ++it) diff --git a/qmc-example.pro b/quotest.pro index a9548df9..433a2ccc 100644 --- a/qmc-example.pro +++ b/quotest.pro @@ -1,12 +1,13 @@ TEMPLATE = app +QT += testlib CONFIG *= c++1z warn_on object_parallel_to_source windows { CONFIG *= console } include(libquotient.pri) -SOURCES += examples/qmc-example.cpp +SOURCES += tests/quotest.cpp DISTFILES += \ - .valgrind.qmc-example.supp + .valgrind.supp diff --git a/tests/.valgrind.supp b/tests/.valgrind.supp new file mode 100644 index 00000000..d65fb52e --- /dev/null +++ b/tests/.valgrind.supp @@ -0,0 +1,68 @@ +{ + libc_dirty_free_on_exit + Memcheck:Free + fun:free + fun:__libc_freeres + fun:_vgnU_freeres + fun:__run_exit_handlers + fun:exit +} + +{ + QAuthenticator + Memcheck:Leak + match-leak-kinds: possible + ... + fun:_ZN14QAuthenticator6detachEv +} + +{ + QTimer + Memcheck:Leak + match-leak-kinds: possible + fun:_Znwm + fun:_ZN7QObjectC1EPS_ + fun:_ZN6QTimerC1EP7QObject +} + +{ + QSslConfiguration + Memcheck:Leak + match-leak-kinds: possible + fun:_Znwm + ... + fun:_ZN17QSslConfigurationC1Ev +} + +{ + libcrypto_ASN1 + Memcheck:Leak + match-leak-kinds: definite + fun:malloc + ... + fun:ASN1_item_ex_d2i +} + +{ + malloc_from_libcrypto + Memcheck:Leak + match-leak-kinds: possible + fun:malloc + fun:CRYPTO_malloc + ... + obj:/lib/x86_64-linux-gnu/libcrypto.so.* +} + +{ + Slot_activation_from_QtNetwork + Memcheck:Leak + match-leak-kinds: definite + fun:malloc + fun:inflateInit2_ + obj:/*/*/*/libQt5Network.so.* + ... + fun:_ZN11QMetaObject8activateEP7QObjectiiPPv + ... + fun:_ZN11QMetaObject8activateEP7QObjectiiPPv + obj:/*/*/*/libQt5Network.so.* +}
\ No newline at end of file diff --git a/examples/CMakeLists.txt b/tests/CMakeLists.txt index 1f512958..490a2506 100644 --- a/examples/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,7 +3,7 @@ cmake_minimum_required(VERSION 3.1) # This CMakeLists file assumes that the library is installed to CMAKE_INSTALL_PREFIX # and ignores the in-tree library code. You can use this to start work on your own client. -project(qmc-example CXX) +project(quotest CXX) include(CheckCXXCompilerFlag) if (NOT WIN32) @@ -45,25 +45,25 @@ foreach (FLAG all "" pedantic extra error=return-type no-unused-parameter no-gnu endif () endforeach () -find_package(Qt5 5.6 REQUIRED Network Gui Multimedia) +find_package(Qt5 5.9 REQUIRED Network Gui Multimedia Test) get_filename_component(Qt5_Prefix "${Qt5_DIR}/../../../.." ABSOLUTE) find_package(Quotient REQUIRED) -get_filename_component(QMC_Prefix "${Quotient_DIR}/../.." ABSOLUTE) +get_filename_component(Quotient_Prefix "${Quotient_DIR}/../.." ABSOLUTE) -message( STATUS "qmc-example configuration:" ) +message( STATUS "${PROJECT_NAME} configuration:" ) if (CMAKE_BUILD_TYPE) message( STATUS " Build type: ${CMAKE_BUILD_TYPE}") endif(CMAKE_BUILD_TYPE) message( STATUS " Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}" ) message( STATUS " Qt: ${Qt5_VERSION} at ${Qt5_Prefix}" ) -message( STATUS " Quotient: ${Quotient_VERSION} at ${QMC_Prefix}" ) +message( STATUS " Quotient: ${Quotient_VERSION} at ${Quotient_Prefix}" ) -set(example_SRCS qmc-example.cpp) +set(example_SRCS quotest.cpp) -add_executable(qmc-example ${example_SRCS}) -target_link_libraries(qmc-example Qt5::Core Quotient) +add_executable(${PROJECT_NAME} ${example_SRCS}) +target_link_libraries(${PROJECT_NAME} Qt5::Core Qt5::Test Quotient) # Installation -install (TARGETS qmc-example RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +install (TARGETS ${PROJECT_NAME} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) diff --git a/tests/quotest.cpp b/tests/quotest.cpp new file mode 100644 index 00000000..194c6a69 --- /dev/null +++ b/tests/quotest.cpp @@ -0,0 +1,675 @@ + +#include "connection.h" +#include "room.h" +#include "user.h" + +#include "csapi/joining.h" +#include "csapi/leaving.h" +#include "csapi/room_send.h" + +#include "events/reactionevent.h" +#include "events/redactionevent.h" +#include "events/simplestateevents.h" + +#include <QtTest/QSignalSpy> +#include <QtCore/QCoreApplication> +#include <QtCore/QFileInfo> +#include <QtCore/QStringBuilder> +#include <QtCore/QTemporaryFile> +#include <QtCore/QTimer> + +#include <functional> +#include <iostream> + +using namespace Quotient; +using std::clog, std::endl; + +class TestSuite; + +class TestManager : public QCoreApplication { +public: + TestManager(int& argc, char** argv); + +private: + void setupAndRun(); + void onNewRoom(Room* r); + void doTests(); + void conclude(); + void finalize(); + +private: + Connection* c = nullptr; + QString origin; + QString targetRoomName; + TestSuite* testSuite = nullptr; + QByteArrayList running {}, succeeded {}, failed {}; +}; + +using TestToken = QByteArray; // return value of QMetaMethod::name +Q_DECLARE_METATYPE(TestToken); +// For now, the token itself is the test name but that may change. +const char* testName(const TestToken& token) { return token.constData(); } + +/// Test function declaration +/*! + * \return true, if the test finished (successfully or unsuccessfully); + * false, if the test went async and will complete later + */ +#define TEST_DECL(Name) bool Name(const TestToken& thisTest); + +/// The holder for the actual tests +/*! + * This class takes inspiration from Qt Test in terms of tests invocation; + * TestManager instantiates it and runs all public slots (cf. private slots in + * Qt Test) one after another. An important diversion from Qt Test is that + * the tests are assumed to by asynchronous rather than synchronous; so it's + * perfectly normal to have a few tests running at the same time. To avoid + * context clashes a special parameter with the name thisTest is passed to + * each test. Each test must conclude (synchronously or asynchronously) with + * an invocation of FINISH_TEST() macro (or FAIL_TEST() macro that expands to + * FINISH_TEST) that expects thisTest variable to be reachable. If FINISH_TEST() + * is invoked twice with the same thisTest, the second call will cause assertion + * failure; if FINISH_TEST() is not invoked at all, the test will be killed + * by a watchdog after a timeout and marked in the final report as not finished. + */ +class TestSuite : public QObject { + Q_OBJECT +public: + TestSuite(Room* testRoom, QString source, TestManager* parent) + : QObject(parent), targetRoom(testRoom), origin(std::move(source)) + { + qRegisterMetaType<TestToken>(); + Q_ASSERT(testRoom && parent); + } + +signals: + void finishedItem(QByteArray /*name*/, bool /*condition*/); + +public slots: + void doTest(const QByteArray& testName); + +private slots: + TEST_DECL(loadMembers) + TEST_DECL(sendMessage) + TEST_DECL(sendReaction) + TEST_DECL(sendFile) + TEST_DECL(setTopic) + TEST_DECL(sendAndRedact) + TEST_DECL(addAndRemoveTag) + TEST_DECL(markDirectChat) + // Add more tests above here + +public: + Room* room() const { return targetRoom; } + Connection* connection() const { return targetRoom->connection(); } + +private: + bool checkFileSendingOutcome(const TestToken& thisTest, + const QString& txnId, const QString& fileName); + bool checkRedactionOutcome(const QByteArray& thisTest, + const QString& evtIdToRedact); + + bool validatePendingEvent(const QString& txnId); + bool checkDirectChat() const; + void finishTest(const TestToken& token, bool condition, const char* file, + int line); + +private: + Room* targetRoom; + QString origin; +}; + +#define TEST_IMPL(Name) bool TestSuite::Name(const TestToken& thisTest) + +// Returning true (rather than a void) allows to reuse the convention with +// connectUntil() to break the QMetaObject::Connection upon finishing the test +// item. +#define FINISH_TEST(Condition) \ + return (finishTest(thisTest, Condition, __FILE__, __LINE__), true) + +#define FAIL_TEST() FINISH_TEST(false) + +void TestSuite::doTest(const QByteArray& testName) +{ + clog << "Starting: " << testName.constData() << endl; + QMetaObject::invokeMethod(this, testName, Qt::DirectConnection, + Q_ARG(TestToken, testName)); +} + +bool TestSuite::validatePendingEvent(const QString& txnId) +{ + auto it = targetRoom->findPendingEvent(txnId); + return it != targetRoom->pendingEvents().end() + && it->deliveryStatus() == EventStatus::Submitted + && (*it)->transactionId() == txnId; +} + +void TestSuite::finishTest(const TestToken& token, bool condition, + const char* file, int line) +{ + const auto& item = testName(token); + if (condition) { + clog << item << " successful" << endl; + if (targetRoom) + targetRoom->postMessage(origin % ": " % item % " successful", + MessageEventType::Notice); + } else { + clog << item << " FAILED at " << file << ":" << line << endl; + if (targetRoom) + targetRoom->postPlainText(origin % ": " % item % " FAILED at " + % file % ", line " % QString::number(line)); + } + + emit finishedItem(item, condition); +} + +TestManager::TestManager(int& argc, char** argv) + : QCoreApplication(argc, argv), c(new Connection(this)) +{ + Q_ASSERT(argc >= 5); + clog << "Connecting to Matrix as " << argv[1] << endl; + c->connectToServer(argv[1], argv[2], argv[3]); + targetRoomName = argv[4]; + clog << "Test room name: " << argv[4] << endl; + if (argc > 5) { + origin = argv[5]; + clog << "Origin for the test message: " << origin.toStdString() << endl; + } + + connect(c, &Connection::connected, this, &TestManager::setupAndRun); + connect(c, &Connection::resolveError, this, + [this](const QString& error) { + clog << "Failed to resolve the server: " << error.toStdString() + << endl; + this->exit(-2); + }, + Qt::QueuedConnection); + connect(c, &Connection::loginError, this, + [this](const QString& message, const QString& details) { + clog << "Failed to login to " + << c->homeserver().toDisplayString().toStdString() << ": " + << message.toStdString() << endl + << "Details:" << endl + << details.toStdString() << endl; + this->exit(-2); + }, + Qt::QueuedConnection); + connect(c, &Connection::loadedRoomState, this, &TestManager::onNewRoom); + + // Big countdown watchdog + QTimer::singleShot(180000, this, [this] { + if (testSuite) + conclude(); + else + finalize(); + }); +} + +void TestManager::setupAndRun() +{ + Q_ASSERT(!c->homeserver().isEmpty() && c->homeserver().isValid()); + Q_ASSERT(c->domain() == c->userId().section(':', 1)); + clog << "Connected, server: " + << c->homeserver().toDisplayString().toStdString() << endl; + clog << "Access token: " << c->accessToken().toStdString() << endl; + + c->setLazyLoading(true); + c->syncLoop(); + + clog << "Joining " << targetRoomName.toStdString() << endl; + auto joinJob = c->joinRoom(targetRoomName); + // Ensure, before this test is completed, that the room has been joined + // and filled with some events so that other tests could use that + connect(joinJob, &BaseJob::success, this, [this, joinJob] { + testSuite = new TestSuite(c->room(joinJob->roomId()), origin, this); + connectSingleShot(c, &Connection::syncDone, this, [this] { + if (testSuite->room()->timelineSize() > 0) + doTests(); + else { + testSuite->room()->getPreviousContent(); + connectSingleShot(testSuite->room(), &Room::addedMessages, this, + &TestManager::doTests); + } + }); + }); + connect(joinJob, &BaseJob::failure, this, [this] { + clog << "Failed to join the test room" << endl; + finalize(); + }); +} + +void TestManager::onNewRoom(Room* r) +{ + clog << "New room: " << r->id().toStdString() << endl + << " Name: " << r->name().toStdString() << endl + << " Canonical alias: " << r->canonicalAlias().toStdString() << endl + << endl; + connect(r, &Room::aboutToAddNewMessages, r, [r](RoomEventsRange timeline) { + clog << timeline.size() << " new event(s) in room " + << r->canonicalAlias().toStdString() << endl; + }); +} + +void TestManager::doTests() +{ + const auto* metaObj = testSuite->metaObject(); + for (auto i = metaObj->methodOffset(); i < metaObj->methodCount(); ++i) { + const auto metaMethod = metaObj->method(i); + if (metaMethod.access() != QMetaMethod::Private + || metaMethod.methodType() != QMetaMethod::Slot) + continue; + + const auto testName = metaMethod.name(); + running.push_back(testName); + // Some tests return the result immediately, so queue everything + // so that we could process all tests asynchronously. + QMetaObject::invokeMethod(testSuite, "doTest", Qt::QueuedConnection, + Q_ARG(QByteArray, testName)); + } + clog << "Tests to do:"; + for (const auto& test: qAsConst(running)) + clog << " " << testName(test); + clog << endl; + connect(testSuite, &TestSuite::finishedItem, this, + [this](const QByteArray& itemName, bool condition) { + if (auto i = running.indexOf(itemName); i != -1) + (condition ? succeeded : failed).push_back(running.takeAt(i)); + else + Q_ASSERT_X(false, itemName, + "Test item is not in running state"); + if (running.empty()) { + clog << "All tests finished" << endl; + conclude(); + } + }); + + connect(c, &Connection::syncDone, this, [this] { + static int i = 0; + clog << "Sync " << ++i << " complete" << endl; + if (auto* r = testSuite->room()) + clog << "Test room timeline size = " << r->timelineSize() + << ", pending size = " << r->pendingEvents().size() << endl; + if (!running.empty()) { + clog << running.size() << " test(s) in the air:"; + for (const auto& test: qAsConst(running)) + clog << " " << testName(test); + clog << endl; + } + }); +} + +TEST_IMPL(loadMembers) +{ + // Trying to load members from another (larger) room + auto* r = connection()->roomByAlias(QStringLiteral("#quotient:matrix.org"), + JoinState::Join); + if (!r) { + clog << "#quotient:matrix.org is not found in the test user's rooms" + << endl; + FAIL_TEST(); + } + // It's not exactly correct because an arbitrary server might not support + // lazy loading; but in the absence of capabilities framework we assume + // it does. + if (r->memberNames().size() >= r->joinedCount()) { + clog << "Lazy loading doesn't seem to be enabled" << endl; + FAIL_TEST(); + } + r->setDisplayed(); + connect(r, &Room::allMembersLoaded, [this, thisTest, r] { + FINISH_TEST(r->memberNames().size() >= r->joinedCount()); + }); + return false; +} + +TEST_IMPL(sendMessage) +{ + auto txnId = targetRoom->postPlainText("Hello, " % origin % " is here"); + if (!validatePendingEvent(txnId)) { + clog << "Invalid pending event right after submitting" << endl; + FAIL_TEST(); + } + connectUntil(targetRoom, &Room::pendingEventAboutToMerge, this, + [this, thisTest, txnId](const RoomEvent* evt, int pendingIdx) { + const auto& pendingEvents = targetRoom->pendingEvents(); + Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); + + if (evt->transactionId() != txnId) + return false; + + FINISH_TEST(is<RoomMessageEvent>(*evt) && !evt->id().isEmpty() + && pendingEvents[size_t(pendingIdx)]->transactionId() + == evt->transactionId()); + }); + return false; +} + +TEST_IMPL(sendReaction) +{ + clog << "Reacting to the newest message in the room" << endl; + Q_ASSERT(targetRoom->timelineSize() > 0); + const auto targetEvtId = targetRoom->messageEvents().back()->id(); + const auto key = QStringLiteral("+1"); + const auto txnId = targetRoom->postReaction(targetEvtId, key); + if (!validatePendingEvent(txnId)) { + clog << "Invalid pending event right after submitting" << endl; + FAIL_TEST(); + } + + // TODO: Check that it came back as a reaction event and that it attached to + // the right event + connectUntil( + targetRoom, &Room::updatedEvent, this, + [this, thisTest, txnId, key, targetEvtId](const QString& actualTargetEvtId) { + if (actualTargetEvtId != targetEvtId) + return false; + const auto reactions = targetRoom->relatedEvents( + targetEvtId, EventRelation::Annotation()); + // It's a test room, assuming no interference there should + // be exactly one reaction + if (reactions.size() != 1) + FAIL_TEST(); + + const auto* evt = + eventCast<const ReactionEvent>(reactions.back()); + FINISH_TEST(is<ReactionEvent>(*evt) && !evt->id().isEmpty() + && evt->relation().key == key + && evt->transactionId() == txnId); + }); + return false; +} + +TEST_IMPL(sendFile) +{ + auto* tf = new QTemporaryFile; + if (!tf->open()) { + clog << "Failed to create a temporary file" << endl; + FAIL_TEST(); + } + tf->write("Test"); + tf->close(); + // QFileInfo::fileName brings only the file name; QFile::fileName brings + // the full path + const auto tfName = QFileInfo(*tf).fileName(); + clog << "Sending file " << tfName.toStdString() << endl; + const auto txnId = + targetRoom->postFile("Test file", QUrl::fromLocalFile(tf->fileName())); + if (!validatePendingEvent(txnId)) { + clog << "Invalid pending event right after submitting" << endl; + delete tf; + FAIL_TEST(); + } + + // Using tf as a context object to clean away both connections + // once either of them triggers. + connectUntil(targetRoom, &Room::fileTransferCompleted, tf, + [this, thisTest, txnId, tf, tfName](const QString& id) { + auto fti = targetRoom->fileTransferInfo(id); + Q_ASSERT(fti.status == FileTransferInfo::Completed); + + if (id != txnId) + return false; + + tf->deleteLater(); + return checkFileSendingOutcome(thisTest, txnId, tfName); + }); + connectUntil(targetRoom, &Room::fileTransferFailed, tf, + [this, thisTest, txnId, tf](const QString& id, const QString& error) { + if (id != txnId) + return false; + + targetRoom->postPlainText(origin % ": File upload failed: " % error); + tf->deleteLater(); + FAIL_TEST(); + }); + return false; +} + +bool TestSuite::checkFileSendingOutcome(const TestToken& thisTest, + const QString& txnId, + const QString& fileName) +{ + auto it = targetRoom->findPendingEvent(txnId); + if (it == targetRoom->pendingEvents().end()) { + clog << "Pending file event dropped before upload completion" << endl; + FAIL_TEST(); + } + if (it->deliveryStatus() != EventStatus::FileUploaded) { + clog << "Pending file event status upon upload completion is " + << it->deliveryStatus() << " != FileUploaded(" + << EventStatus::FileUploaded << ')' << endl; + FAIL_TEST(); + } + + connectUntil(targetRoom, &Room::pendingEventAboutToMerge, this, + [this, thisTest, txnId, fileName](const RoomEvent* evt, int pendingIdx) { + const auto& pendingEvents = targetRoom->pendingEvents(); + Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); + + if (evt->transactionId() != txnId) + return false; + + clog << "File event " << txnId.toStdString() + << " arrived in the timeline" << endl; + // This part tests visit() + return visit( + *evt, + [&](const RoomMessageEvent& e) { + // TODO: actually try to download it to check, e.g., #366 + // (and #368 would help to test against bad file names). + FINISH_TEST( + !e.id().isEmpty() + && pendingEvents[size_t(pendingIdx)]->transactionId() + == txnId + && e.hasFileContent() + && e.content()->fileInfo()->originalName == fileName); + }, + [this, thisTest](const RoomEvent&) { FAIL_TEST(); }); + }); + return true; +} + +TEST_IMPL(setTopic) +{ + const auto newTopic = connection()->generateTxnId(); // Just a way to make + // a unique id + targetRoom->setTopic(newTopic); + connectUntil(targetRoom, &Room::topicChanged, this, + [this, thisTest, newTopic] { + if (targetRoom->topic() == newTopic) + FINISH_TEST(true); + + clog << "Requested topic was " << newTopic.toStdString() << ", " + << targetRoom->topic().toStdString() << " arrived instead" + << endl; + return false; + }); + return false; +} + +TEST_IMPL(sendAndRedact) +{ + clog << "Sending a message to redact" << endl; + auto txnId = targetRoom->postPlainText(origin % ": message to redact"); + if (txnId.isEmpty()) + FAIL_TEST(); + + connect(targetRoom, &Room::messageSent, this, + [this, thisTest, txnId](const QString& tId, const QString& evtId) { + if (tId != txnId) + return; + + clog << "Redacting the message" << endl; + targetRoom->redactEvent(evtId, origin); + + connectUntil(targetRoom, &Room::addedMessages, this, + [this, thisTest, evtId] { + return checkRedactionOutcome(thisTest, evtId); + }); + }); + return false; +} + +bool TestSuite::checkRedactionOutcome(const QByteArray& thisTest, + const QString& evtIdToRedact) +{ + // There are two possible (correct) outcomes: either the event comes already + // redacted at the next sync, or the nearest sync completes with + // the unredacted event but the next one brings redaction. + auto it = targetRoom->findInTimeline(evtIdToRedact); + if (it == targetRoom->timelineEdge()) + return false; // Waiting for the next sync + + if ((*it)->isRedacted()) { + clog << "The sync brought already redacted message" << endl; + FINISH_TEST(true); + } + + clog << "Message came non-redacted with the sync, waiting for redaction" + << endl; + connectUntil(targetRoom, &Room::replacedEvent, this, + [this, thisTest, evtIdToRedact](const RoomEvent* newEvent, + const RoomEvent* oldEvent) { + if (oldEvent->id() != evtIdToRedact) + return false; + + FINISH_TEST(newEvent->isRedacted() + && newEvent->redactionReason() == origin); + }); + return true; +} + +TEST_IMPL(addAndRemoveTag) +{ + static const auto TestTag = QStringLiteral("im.quotient.test"); + // Pre-requisite + if (targetRoom->tags().contains(TestTag)) + targetRoom->removeTag(TestTag); + + // Unlike for most of Quotient, tags are applied and tagsChanged is emitted + // synchronously, with the server being notified async. The test checks + // that the signal is emitted, not only that tags have changed; but there's + // (currently) no way to check that the server has been correctly notified + // of the tag change. + QSignalSpy spy(targetRoom, &Room::tagsChanged); + targetRoom->addTag(TestTag); + if (spy.count() != 1 || !targetRoom->tags().contains(TestTag)) { + clog << "Tag adding failed" << endl; + FAIL_TEST(); + } + clog << "Test tag set, removing it now" << endl; + targetRoom->removeTag(TestTag); + FINISH_TEST(spy.count() == 2 && !targetRoom->tags().contains(TestTag)); +} + +bool TestSuite::checkDirectChat() const +{ + return targetRoom->directChatUsers().contains(connection()->user()); +} + +TEST_IMPL(markDirectChat) +{ + if (checkDirectChat()) + connection()->removeFromDirectChats(targetRoom->id(), + connection()->user()); + + int id = qRegisterMetaType<DirectChatsMap>(); // For QSignalSpy + Q_ASSERT(id != -1); + + // Same as with tags (and unusual for the rest of Quotient), direct chat + // operations are synchronous. + QSignalSpy spy(connection(), &Connection::directChatsListChanged); + clog << "Marking the room as a direct chat" << endl; + connection()->addToDirectChats(targetRoom, connection()->user()); + if (spy.count() != 1 || !checkDirectChat()) + FAIL_TEST(); + + // Check that the first argument (added DCs) actually contains the room + const auto& addedDCs = spy.back().front().value<DirectChatsMap>(); + if (addedDCs.size() != 1 + || !addedDCs.contains(connection()->user(), targetRoom->id())) { + clog << "The room is not in added direct chats" << endl; + FAIL_TEST(); + } + + clog << "Unmarking the direct chat" << endl; + connection()->removeFromDirectChats(targetRoom->id(), connection()->user()); + if (spy.count() != 2 && checkDirectChat()) + FAIL_TEST(); + + // Check that the second argument (removed DCs) actually contains the room + const auto& removedDCs = spy.back().back().value<DirectChatsMap>(); + FINISH_TEST(removedDCs.size() == 1 + && removedDCs.contains(connection()->user(), targetRoom->id())); +} + +void TestManager::conclude() +{ + auto succeededRec = QString::number(succeeded.size()) + " tests succeeded"; + if (!failed.empty() || !running.empty()) + succeededRec += + " of " + % QString::number(succeeded.size() + failed.size() + running.size()) + % " total"; + QString plainReport = origin % ": Testing complete, " % succeededRec; + QString color = failed.empty() && running.empty() ? "00AA00" : "AA0000"; + QString htmlReport = origin % ": <strong><font data-mx-color='#" % color + % "' color='#" % color + % "'>Testing complete</font></strong>, " % succeededRec; + if (!failed.empty()) { + QByteArray failedList; + for (const auto& f : qAsConst(failed)) + failedList += ' ' + f; + plainReport += "\nFAILED:" + failedList; + htmlReport += "<br><strong>Failed:</strong>" + failedList; + } + if (!running.empty()) { + QByteArray dnfList; + for (const auto& r : qAsConst(running)) + dnfList += ' ' + r; + plainReport += "\nDID NOT FINISH:" + dnfList; + htmlReport += "<br><strong>Did not finish:</strong>" + dnfList; + } + clog << plainReport.toStdString() << endl; + + // TODO: Waiting for proper futures to come so that it could be: + // targetRoom->postHtmlText(...) + // .then(this, &TestManager::finalize); // Qt-style or + // .then([this] { finalize(); }); // STL-style + auto* room = testSuite->room(); + auto txnId = room->postHtmlText(plainReport, htmlReport); + connect(room, &Room::messageSent, this, + [this, room, txnId](const QString& serverTxnId) { + if (txnId != serverTxnId) + return; + + clog << "Leaving the room" << endl; + auto* job = room->leaveRoom(); + connect(job, &BaseJob::finished, this, [this, job] { + Q_ASSERT(job->status().good()); + finalize(); + }); + }); +} + +void TestManager::finalize() +{ + clog << "Logging out" << endl; + c->logout(); + connect(c, &Connection::loggedOut, + this, [this] { this->exit(failed.size() + running.size()); }, + Qt::QueuedConnection); +} + +int main(int argc, char* argv[]) +{ + // TODO: use QCommandLineParser + if (argc < 5) { + clog << "Usage: quotest <user> <passwd> <device_name> <room_alias> [origin]" + << endl; + return -1; + } + return TestManager(argc, argv).exec(); +} + +#include "quotest.moc" |