diff --git a/.gitignore b/.gitignore index 52623fd..5e7624c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ node_modules /.clang-tidy **/.test /.appanalyzer + +.bitfun/ diff --git a/AppScope/resources/base/media/tray_icon.png b/AppScope/resources/base/media/tray_icon.png new file mode 100644 index 0000000..52ff8db Binary files /dev/null and b/AppScope/resources/base/media/tray_icon.png differ diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 index 20eceed..5e10689 100644 --- a/entry/build-profile.json5 +++ b/entry/build-profile.json5 @@ -1,10 +1,22 @@ { "apiType": "stageMode", "buildOption": { + "resOptions": {}, + "externalNativeOptions": { + "path": "src/main/cpp/CMakeLists.txt", + "cppFlags": "", + "arguments": "" + } }, "buildOptionSet": [ { "name": "release", + "nativeLib": { + "debugSymbol": { + "strip": true, + "exclude": [] + } + }, "arkOptions": { "obfuscation": { "ruleOptions": { @@ -15,11 +27,11 @@ } } } - }, + } ], "targets": [ { - "name": "default", - }, + "name": "default" + } ] -} +} \ No newline at end of file diff --git a/entry/oh-package-lock.json5 b/entry/oh-package-lock.json5 index 1fd3390..934e8d2 100644 --- a/entry/oh-package-lock.json5 +++ b/entry/oh-package-lock.json5 @@ -7,20 +7,27 @@ "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { "@ohos/axios@^2.2.4": "@ohos/axios@2.2.4", + "libcloudsync.so@src/main/cpp/types/libcloudsync": "libcloudsync.so@src/main/cpp/types/libcloudsync", "nextcloud@../nextcloud": "nextcloud@../nextcloud" }, "packages": { "@ohos/axios@2.2.4": { - "name": "", + "name": "@ohos/axios", "version": "2.2.4", "integrity": "sha512-5oH753kGFwfJP1kvCnI92RRaSqRbN0b24vmNNrUV+T8+5ako98rtVQOhA7ezMPox5Z3PFWndLQTF/P/hEK/yeQ==", "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/axios/-/axios-2.2.4.har", "registryType": "ohpm" }, + "libcloudsync.so@src/main/cpp/types/libcloudsync": { + "name": "libcloudsync.so", + "version": "1.0.0", + "resolved": "src/main/cpp/types/libcloudsync", + "registryType": "local" + }, "nextcloud@../nextcloud": { "name": "nextcloud", "version": "1.0.0", - "resolved": "", + "resolved": "../nextcloud", "registryType": "local", "dependencies": { "@ohos/axios": "^2.2.4" diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 index 3b278fb..d59c8f3 100644 --- a/entry/oh-package.json5 +++ b/entry/oh-package.json5 @@ -7,5 +7,6 @@ "license": "", "dependencies": { "nextcloud": "file:../nextcloud", + "libcloudsync.so": "file:./src/main/cpp/types/libcloudsync" }, } diff --git a/entry/patch.json b/entry/patch.json index 3b4bb26..e2c3c61 100644 --- a/entry/patch.json +++ b/entry/patch.json @@ -1,7 +1,7 @@ { "app": { "bundleName": "me.moody.nextcloud", - "patchVersionCode": 2000865, + "patchVersionCode": 2001296, "versionCode": 1000000 }, "module": { diff --git a/entry/src/main/cpp/.vscode/settings.json b/entry/src/main/cpp/.vscode/settings.json new file mode 100644 index 0000000..7bda452 --- /dev/null +++ b/entry/src/main/cpp/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "clangd.arguments": [ + "--compile-commands-dir=${workspaceFolder}/../../../.cxx/default/default/debug/arm64-v8a/", + "--query-driver=**" + ] +} diff --git a/entry/src/main/cpp/CMakeLists.txt b/entry/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..654b32b --- /dev/null +++ b/entry/src/main/cpp/CMakeLists.txt @@ -0,0 +1,27 @@ +# the minimum version of CMake. +cmake_minimum_required(VERSION 3.5.0) +project(Nextcloud) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED TRUE) +set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR}) + +if(DEFINED PACKAGE_FIND_FILE) + include(${PACKAGE_FIND_FILE}) +endif() + +include_directories(${NATIVERENDER_ROOT_PATH} + ${NATIVERENDER_ROOT_PATH}/include) + +add_library(cloudsync + SHARED + src/napi_init.cpp +) +target_link_libraries( + cloudsync + PUBLIC + libace_napi.z.so + libohclouddiskmanager.so + libhilog_ndk.z.so + libdeviceinfo_ndk.z.so +) diff --git a/entry/src/main/cpp/include/Logging.hpp b/entry/src/main/cpp/include/Logging.hpp new file mode 100644 index 0000000..3a71baf --- /dev/null +++ b/entry/src/main/cpp/include/Logging.hpp @@ -0,0 +1,15 @@ +#pragma once +#define LOG_DOMAIN 0 + +#include + +#define LogFmt(fmt, ...) "[%{public}s:%{public}d] " fmt, __func__, __LINE__, ##__VA_ARGS__ + +#define LogE(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_ERROR, LOG_DOMAIN, LOG_TAG, LogFmt(fmt, ##__VA_ARGS__)) +#define LogW(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_WARN, LOG_DOMAIN, LOG_TAG, LogFmt(fmt, ##__VA_ARGS__)) +#define LogI(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_INFO, LOG_DOMAIN, LOG_TAG, LogFmt(fmt, ##__VA_ARGS__)) +#define LogD(fmt, ...) OH_LOG_Print(LogType::LOG_APP, LogLevel::LOG_DEBUG, LOG_DOMAIN, LOG_TAG, LogFmt(fmt, ##__VA_ARGS__)) + +// clang-format off +#define NAPI_ERR_RET(expr, msg, ...) if ((expr) != napi_ok) { LogI(msg, ##__VA_ARGS__); return; } +// clang-format on diff --git a/entry/src/main/cpp/include/NapiCallback.hpp b/entry/src/main/cpp/include/NapiCallback.hpp new file mode 100644 index 0000000..1dc129f --- /dev/null +++ b/entry/src/main/cpp/include/NapiCallback.hpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include + +struct NapiObject +{ + public: + NapiObject() = default; + virtual ~NapiObject() = default; + virtual napi_value ToNapiValue(napi_env env) const = 0; +}; + +template +requires(std::is_base_of_v) class NapiCallback +{ + public: + explicit NapiCallback(napi_env e, std::string_view name, napi_value jsCallback) : env(e) + { + napi_value workName; + napi_create_string_utf8(env, name.data(), NAPI_AUTO_LENGTH, &workName); + napi_create_threadsafe_function(env, nullptr, nullptr, workName, 0, 1, nullptr, nullptr, nullptr, JsInvoker, &tsFn); + napi_create_reference(env, jsCallback, 1, &callbackRef); + } + + ~NapiCallback() + { + napi_delete_reference(env, callbackRef); + } + + void Invoke(const TArg *arg) const + { + const auto argContext = new CallbackArg(); + argContext->callbackRef = callbackRef; + argContext->arg = arg; + napi_acquire_threadsafe_function(tsFn); + napi_call_threadsafe_function(tsFn, argContext, napi_tsfn_nonblocking); + napi_release_threadsafe_function(tsFn, napi_tsfn_release); + } + + private: + struct CallbackArg + { + napi_ref callbackRef = nullptr; + const TArg *arg = nullptr; + }; + + static void JsInvoker(napi_env env, napi_value js_callBack, void *context, void *data) + { + (void) context; + auto *const argContext = reinterpret_cast(data); + napi_get_reference_value(env, argContext->callbackRef, &js_callBack); + const auto arg = argContext->arg->ToNapiValue(env); + napi_call_function(env, nullptr, js_callBack, 1, &arg, nullptr); + delete argContext; + } + + private: + napi_env env = nullptr; + napi_ref callbackRef = nullptr; + napi_threadsafe_function tsFn = nullptr; +}; diff --git a/entry/src/main/cpp/include/NapiCallbackStorage.hpp b/entry/src/main/cpp/include/NapiCallbackStorage.hpp new file mode 100644 index 0000000..5c212ef --- /dev/null +++ b/entry/src/main/cpp/include/NapiCallbackStorage.hpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "Logging.hpp" +#include "NapiCallback.hpp" + +#include +#include +#include + +template +struct NapiCallbackStorage +{ + public: + explicit NapiCallbackStorage() = default; + + void AddCallback(napi_env env, std::string_view name, napi_value jsCallback) + { + LogD("enter"); + const auto callback = std::make_shared>(env, name, jsCallback); + callbacks.push_back(callback); + } + + void InvokeAll(const TArg *arg) + { + for (const auto &callback : callbacks) + { + callback->Invoke(arg); + } + } + + private: + std::list>> callbacks; +}; diff --git a/entry/src/main/cpp/include/NapiUtils.hpp b/entry/src/main/cpp/include/NapiUtils.hpp new file mode 100644 index 0000000..5ec66cc --- /dev/null +++ b/entry/src/main/cpp/include/NapiUtils.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include +#include + +template +struct NapiValueConverter +{ + static constexpr napi_valuetype type = napi_undefined; + void Get(napi_env, napi_value){}; +}; + +#define DeclareNapiValueConverter(_type, _type_t, _convert_func) \ + template<> \ + struct NapiValueConverter<_type> \ + { \ + using type_t = _type_t; \ + using optional_t = std::optional; \ + static constexpr napi_valuetype type = _type; \ + static optional_t Get(napi_env env, napi_value value) \ + { \ + type_t ret; \ + napi_valuetype arg_type = napi_undefined; \ + napi_typeof(env, value, &arg_type); \ + if (arg_type != _type) \ + return std::nullopt; \ + _convert_func(env, value, &ret); \ + return ret; \ + } \ + } + +DeclareNapiValueConverter(napi_function, napi_value, [](napi_env, napi_value value, napi_value *ret) { *ret = value; }); +DeclareNapiValueConverter(napi_object, napi_value, [](napi_env, napi_value value, napi_value *ret) { *ret = value; }); +DeclareNapiValueConverter(napi_number, long, napi_get_value_int64); +DeclareNapiValueConverter(napi_boolean, bool, napi_get_value_bool); +DeclareNapiValueConverter(napi_string, std::string, + [](napi_env env, napi_value value, std::string *ret) + { + size_t len = 0; + napi_get_value_string_utf8(env, value, nullptr, 0, &len); + ret->resize(len, '\0'); + napi_get_value_string_utf8(env, value, ret->data(), len + 1, &len); + }); + +template +struct NapiArgs +{ + static constexpr auto NTypes = sizeof...(types); + using Result = std::tuple::type_t...>; + using OptionalResult = std::tuple::optional_t...>; + using NapiValueArray = std::array; + + template> + struct DoConvert; // doesn't need to be defined + + template + struct DoConvert> + { + static OptionalResult impl(napi_env env, NapiValueArray args) + { + return std::make_tuple(NapiValueConverter::Get(env, args[Is])...); + } + }; + + static std::optional Get(napi_env env, napi_callback_info info) + { + NapiValueArray args; + size_t argc = NTypes; + napi_get_cb_info(env, info, &argc, args.data(), nullptr, nullptr); + + if (argc != NTypes) + { + napi_throw_type_error(env, nullptr, "Wrong number of arguments"); + return {}; + } + + const auto optional_result = DoConvert<>::impl(env, args); + const bool is_all_valid = std::apply([](const auto &...args) { return (... && args.has_value()); }, optional_result); + if (!is_all_valid) + { + napi_throw_type_error(env, nullptr, "Wrong arguments"); + return {}; + } + + const auto result = std::apply([](const auto &...args) { return std::make_tuple(args.value()...); }, optional_result); + return std::make_optional(result); + } +}; diff --git a/entry/src/main/cpp/include/magic_enum.hpp b/entry/src/main/cpp/include/magic_enum.hpp new file mode 100644 index 0000000..158207e --- /dev/null +++ b/entry/src/main/cpp/include/magic_enum.hpp @@ -0,0 +1,1508 @@ +// __ __ _ ______ _____ +// | \/ | (_) | ____| / ____|_ _ +// | \ / | __ _ __ _ _ ___ | |__ _ __ _ _ _ __ ___ | | _| |_ _| |_ +// | |\/| |/ _` |/ _` | |/ __| | __| | '_ \| | | | '_ ` _ \ | | |_ _|_ _| +// | | | | (_| | (_| | | (__ | |____| | | | |_| | | | | | | | |____|_| |_| +// |_| |_|\__,_|\__, |_|\___| |______|_| |_|\__,_|_| |_| |_| \_____| +// __/ | https://github.com/Neargye/magic_enum +// |___/ version 0.9.7 +// +// Licensed under the MIT License . +// SPDX-License-Identifier: MIT +// Copyright (c) 2019 - 2024 Daniil Goncharov . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#ifndef NEARGYE_MAGIC_ENUM_HPP +#define NEARGYE_MAGIC_ENUM_HPP + +#define MAGIC_ENUM_VERSION_MAJOR 0 +#define MAGIC_ENUM_VERSION_MINOR 9 +#define MAGIC_ENUM_VERSION_PATCH 7 + +#ifndef MAGIC_ENUM_USE_STD_MODULE +#include +#include +#include +#include +#include +#include +#include +#endif + +#if defined(MAGIC_ENUM_CONFIG_FILE) +# include MAGIC_ENUM_CONFIG_FILE +#endif + +#ifndef MAGIC_ENUM_USE_STD_MODULE +#if !defined(MAGIC_ENUM_USING_ALIAS_OPTIONAL) +# include +#endif +#if !defined(MAGIC_ENUM_USING_ALIAS_STRING) +# include +#endif +#if !defined(MAGIC_ENUM_USING_ALIAS_STRING_VIEW) +# include +#endif +#endif + +#if defined(MAGIC_ENUM_NO_ASSERT) +# define MAGIC_ENUM_ASSERT(...) static_cast(0) +#elif !defined(MAGIC_ENUM_ASSERT) +# include +# define MAGIC_ENUM_ASSERT(...) assert((__VA_ARGS__)) +#endif + +#if defined(__clang__) +# pragma clang diagnostic push +# pragma clang diagnostic ignored "-Wunknown-warning-option" +# pragma clang diagnostic ignored "-Wenum-constexpr-conversion" +# pragma clang diagnostic ignored "-Wuseless-cast" // suppresses 'static_cast('\0')' for char_type = char (common on Linux). +#elif defined(__GNUC__) +# pragma GCC diagnostic push +# pragma GCC diagnostic ignored "-Wmaybe-uninitialized" // May be used uninitialized 'return {};'. +# pragma GCC diagnostic ignored "-Wuseless-cast" // suppresses 'static_cast('\0')' for char_type = char (common on Linux). +#elif defined(_MSC_VER) +# pragma warning(push) +# pragma warning(disable : 26495) // Variable 'static_str::chars_' is uninitialized. +# pragma warning(disable : 28020) // Arithmetic overflow: Using operator '-' on a 4 byte value and then casting the result to a 8 byte value. +# pragma warning(disable : 26451) // The expression '0<=_Param_(1)&&_Param_(1)<=1-1' is not true at this call. +# pragma warning(disable : 4514) // Unreferenced inline function has been removed. +#endif + +// Checks magic_enum compiler compatibility. +#if defined(__clang__) && __clang_major__ >= 5 || defined(__GNUC__) && __GNUC__ >= 9 || defined(_MSC_VER) && _MSC_VER >= 1910 || defined(__RESHARPER__) +# undef MAGIC_ENUM_SUPPORTED +# define MAGIC_ENUM_SUPPORTED 1 +#endif + +// Checks magic_enum compiler aliases compatibility. +#if defined(__clang__) && __clang_major__ >= 5 || defined(__GNUC__) && __GNUC__ >= 9 || defined(_MSC_VER) && _MSC_VER >= 1920 +# undef MAGIC_ENUM_SUPPORTED_ALIASES +# define MAGIC_ENUM_SUPPORTED_ALIASES 1 +#endif + +// Enum value must be greater or equals than MAGIC_ENUM_RANGE_MIN. By default MAGIC_ENUM_RANGE_MIN = -128. +// If need another min range for all enum types by default, redefine the macro MAGIC_ENUM_RANGE_MIN. +#if !defined(MAGIC_ENUM_RANGE_MIN) +# define MAGIC_ENUM_RANGE_MIN -128 +#endif + +// Enum value must be less or equals than MAGIC_ENUM_RANGE_MAX. By default MAGIC_ENUM_RANGE_MAX = 127. +// If need another max range for all enum types by default, redefine the macro MAGIC_ENUM_RANGE_MAX. +#if !defined(MAGIC_ENUM_RANGE_MAX) +# define MAGIC_ENUM_RANGE_MAX 127 +#endif + +// Improve ReSharper C++ intellisense performance with builtins, avoiding unnecessary template instantiations. +#if defined(__RESHARPER__) +# undef MAGIC_ENUM_GET_ENUM_NAME_BUILTIN +# undef MAGIC_ENUM_GET_TYPE_NAME_BUILTIN +# if __RESHARPER__ >= 20230100 +# define MAGIC_ENUM_GET_ENUM_NAME_BUILTIN(V) __rscpp_enumerator_name(V) +# define MAGIC_ENUM_GET_TYPE_NAME_BUILTIN(T) __rscpp_type_name() +# else +# define MAGIC_ENUM_GET_ENUM_NAME_BUILTIN(V) nullptr +# define MAGIC_ENUM_GET_TYPE_NAME_BUILTIN(T) nullptr +# endif +#endif + +namespace magic_enum { + +// If need another optional type, define the macro MAGIC_ENUM_USING_ALIAS_OPTIONAL. +#if defined(MAGIC_ENUM_USING_ALIAS_OPTIONAL) +MAGIC_ENUM_USING_ALIAS_OPTIONAL +#else +using std::optional; +#endif + +// If need another string_view type, define the macro MAGIC_ENUM_USING_ALIAS_STRING_VIEW. +#if defined(MAGIC_ENUM_USING_ALIAS_STRING_VIEW) +MAGIC_ENUM_USING_ALIAS_STRING_VIEW +#else +using std::string_view; +#endif + +// If need another string type, define the macro MAGIC_ENUM_USING_ALIAS_STRING. +#if defined(MAGIC_ENUM_USING_ALIAS_STRING) +MAGIC_ENUM_USING_ALIAS_STRING +#else +using std::string; +#endif + +using char_type = string_view::value_type; +static_assert(std::is_same_v, "magic_enum::customize requires same string_view::value_type and string::value_type"); +static_assert([] { + if constexpr (std::is_same_v) { + constexpr const char c[] = "abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789|"; + constexpr const wchar_t wc[] = L"abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789|"; + static_assert(std::size(c) == std::size(wc), "magic_enum::customize identifier characters are multichars in wchar_t."); + + for (std::size_t i = 0; i < std::size(c); ++i) { + if (c[i] != wc[i]) { + return false; + } + } + } + return true; +} (), "magic_enum::customize wchar_t is not compatible with ASCII."); + +namespace customize { + +// Enum value must be in range [MAGIC_ENUM_RANGE_MIN, MAGIC_ENUM_RANGE_MAX]. By default MAGIC_ENUM_RANGE_MIN = -128, MAGIC_ENUM_RANGE_MAX = 127. +// If need another range for all enum types by default, redefine the macro MAGIC_ENUM_RANGE_MIN and MAGIC_ENUM_RANGE_MAX. +// If need another range for specific enum type, add specialization enum_range for necessary enum type. +template +struct enum_range { + static constexpr int min = MAGIC_ENUM_RANGE_MIN; + static constexpr int max = MAGIC_ENUM_RANGE_MAX; +}; + +static_assert(MAGIC_ENUM_RANGE_MAX > MAGIC_ENUM_RANGE_MIN, "MAGIC_ENUM_RANGE_MAX must be greater than MAGIC_ENUM_RANGE_MIN."); + +namespace detail { + +enum class customize_tag { + default_tag, + invalid_tag, + custom_tag +}; + +} // namespace magic_enum::customize::detail + +class customize_t : public std::pair { + public: + constexpr customize_t(string_view srt) : std::pair{detail::customize_tag::custom_tag, srt} {} + constexpr customize_t(const char_type* srt) : customize_t{string_view{srt}} {} + constexpr customize_t(detail::customize_tag tag) : std::pair{tag, string_view{}} { + MAGIC_ENUM_ASSERT(tag != detail::customize_tag::custom_tag); + } +}; + +// Default customize. +inline constexpr auto default_tag = customize_t{detail::customize_tag::default_tag}; +// Invalid customize. +inline constexpr auto invalid_tag = customize_t{detail::customize_tag::invalid_tag}; + +// If need custom names for enum, add specialization enum_name for necessary enum type. +template +constexpr customize_t enum_name(E) noexcept { + return default_tag; +} + +// If need custom type name for enum, add specialization enum_type_name for necessary enum type. +template +constexpr customize_t enum_type_name() noexcept { + return default_tag; +} + +} // namespace magic_enum::customize + +namespace detail { + +template +struct supported +#if defined(MAGIC_ENUM_SUPPORTED) && MAGIC_ENUM_SUPPORTED || defined(MAGIC_ENUM_NO_CHECK_SUPPORT) + : std::true_type {}; +#else + : std::false_type {}; +#endif + +template , std::enable_if_t, int> = 0> +using enum_constant = std::integral_constant; + +template +inline constexpr bool always_false_v = false; + +template +struct has_is_flags : std::false_type {}; + +template +struct has_is_flags::is_flags)>> : std::bool_constant::is_flags)>>> {}; + +template +struct range_min : std::integral_constant {}; + +template +struct range_min::min)>> : std::integral_constant::min), customize::enum_range::min> {}; + +template +struct range_max : std::integral_constant {}; + +template +struct range_max::max)>> : std::integral_constant::max), customize::enum_range::max> {}; + +struct str_view { + const char* str_ = nullptr; + std::size_t size_ = 0; +}; + +template +class static_str { + public: + constexpr explicit static_str(str_view str) noexcept : static_str{str.str_, std::make_integer_sequence{}} { + MAGIC_ENUM_ASSERT(str.size_ == N); + } + + constexpr explicit static_str(string_view str) noexcept : static_str{str.data(), std::make_integer_sequence{}} { + MAGIC_ENUM_ASSERT(str.size() == N); + } + + constexpr const char_type* data() const noexcept { return chars_; } + + constexpr std::uint16_t size() const noexcept { return N; } + + constexpr operator string_view() const noexcept { return {data(), size()}; } + + private: + template + constexpr static_str(const char* str, std::integer_sequence) noexcept : chars_{static_cast(str[I])..., static_cast('\0')} {} + + template + constexpr static_str(string_view str, std::integer_sequence) noexcept : chars_{str[I]..., static_cast('\0')} {} + + char_type chars_[static_cast(N) + 1]; +}; + +template <> +class static_str<0> { + public: + constexpr explicit static_str() = default; + + constexpr explicit static_str(str_view) noexcept {} + + constexpr explicit static_str(string_view) noexcept {} + + constexpr const char_type* data() const noexcept { return nullptr; } + + constexpr std::uint16_t size() const noexcept { return 0; } + + constexpr operator string_view() const noexcept { return {}; } +}; + +template > +class case_insensitive { + static constexpr char_type to_lower(char_type c) noexcept { + return (c >= static_cast('A') && c <= static_cast('Z')) ? static_cast(c + (static_cast('a') - static_cast('A'))) : c; + } + + public: + template + constexpr auto operator()(L lhs, R rhs) const noexcept -> std::enable_if_t, char_type> && std::is_same_v, char_type>, bool> { + return Op{}(to_lower(lhs), to_lower(rhs)); + } +}; + +constexpr std::size_t find(string_view str, char_type c) noexcept { +#if defined(__clang__) && __clang_major__ < 9 && defined(__GLIBCXX__) || defined(_MSC_VER) && _MSC_VER < 1920 && !defined(__clang__) +// https://stackoverflow.com/questions/56484834/constexpr-stdstring-viewfind-last-of-doesnt-work-on-clang-8-with-libstdc +// https://developercommunity.visualstudio.com/content/problem/360432/vs20178-regression-c-failed-in-test.html + constexpr bool workaround = true; +#else + constexpr bool workaround = false; +#endif + + if constexpr (workaround) { + for (std::size_t i = 0; i < str.size(); ++i) { + if (str[i] == c) { + return i; + } + } + + return string_view::npos; + } else { + return str.find(c); + } +} + +template +constexpr bool is_default_predicate() noexcept { + return std::is_same_v, std::equal_to> || + std::is_same_v, std::equal_to<>>; +} + +template +constexpr bool is_nothrow_invocable() { + return is_default_predicate() || + std::is_nothrow_invocable_r_v; +} + +template +constexpr bool cmp_equal(string_view lhs, string_view rhs, [[maybe_unused]] BinaryPredicate&& p) noexcept(is_nothrow_invocable()) { +#if defined(_MSC_VER) && _MSC_VER < 1920 && !defined(__clang__) + // https://developercommunity.visualstudio.com/content/problem/360432/vs20178-regression-c-failed-in-test.html + // https://developercommunity.visualstudio.com/content/problem/232218/c-constexpr-string-view.html + constexpr bool workaround = true; +#else + constexpr bool workaround = false; +#endif + + if constexpr (!is_default_predicate() || workaround) { + if (lhs.size() != rhs.size()) { + return false; + } + + const auto size = lhs.size(); + for (std::size_t i = 0; i < size; ++i) { + if (!p(lhs[i], rhs[i])) { + return false; + } + } + + return true; + } else { + return lhs == rhs; + } +} + +template +constexpr bool cmp_less(L lhs, R rhs) noexcept { + static_assert(std::is_integral_v && std::is_integral_v, "magic_enum::detail::cmp_less requires integral type."); + + if constexpr (std::is_signed_v == std::is_signed_v) { + // If same signedness (both signed or both unsigned). + return lhs < rhs; + } else if constexpr (std::is_same_v) { // bool special case + return static_cast(lhs) < rhs; + } else if constexpr (std::is_same_v) { // bool special case + return lhs < static_cast(rhs); + } else if constexpr (std::is_signed_v) { + // If 'right' is negative, then result is 'false', otherwise cast & compare. + return rhs > 0 && lhs < static_cast>(rhs); + } else { + // If 'left' is negative, then result is 'true', otherwise cast & compare. + return lhs < 0 || static_cast>(lhs) < rhs; + } +} + +template +constexpr I log2(I value) noexcept { + static_assert(std::is_integral_v, "magic_enum::detail::log2 requires integral type."); + + if constexpr (std::is_same_v) { // bool special case + return MAGIC_ENUM_ASSERT(false), value; + } else { + auto ret = I{0}; + for (; value > I{1}; value >>= I{1}, ++ret) {} + + return ret; + } +} + +#if defined(__cpp_lib_array_constexpr) && __cpp_lib_array_constexpr >= 201603L +# define MAGIC_ENUM_ARRAY_CONSTEXPR 1 +#else +template +constexpr std::array, N> to_array(T (&a)[N], std::index_sequence) noexcept { + return {{a[I]...}}; +} +#endif + +template +inline constexpr bool is_enum_v = std::is_enum_v && std::is_same_v>; + +template +constexpr auto n() noexcept { + static_assert(is_enum_v, "magic_enum::detail::n requires enum type."); + + if constexpr (supported::value) { +#if defined(MAGIC_ENUM_GET_TYPE_NAME_BUILTIN) + constexpr auto name_ptr = MAGIC_ENUM_GET_TYPE_NAME_BUILTIN(E); + constexpr auto name = name_ptr ? str_view{name_ptr, std::char_traits::length(name_ptr)} : str_view{}; +#elif defined(__clang__) + str_view name; + if constexpr (sizeof(__PRETTY_FUNCTION__) == sizeof(__FUNCTION__)) { + static_assert(always_false_v, "magic_enum::detail::n requires __PRETTY_FUNCTION__."); + return str_view{}; + } else { + name.size_ = sizeof(__PRETTY_FUNCTION__) - 36; + name.str_ = __PRETTY_FUNCTION__ + 34; + } +#elif defined(__GNUC__) + auto name = str_view{__PRETTY_FUNCTION__, sizeof(__PRETTY_FUNCTION__) - 1}; + if constexpr (sizeof(__PRETTY_FUNCTION__) == sizeof(__FUNCTION__)) { + static_assert(always_false_v, "magic_enum::detail::n requires __PRETTY_FUNCTION__."); + return str_view{}; + } else if (name.str_[name.size_ - 1] == ']') { + name.size_ -= 50; + name.str_ += 49; + } else { + name.size_ -= 40; + name.str_ += 37; + } +#elif defined(_MSC_VER) + // CLI/C++ workaround (see https://github.com/Neargye/magic_enum/issues/284). + str_view name; + name.str_ = __FUNCSIG__; + name.str_ += 40; + name.size_ += sizeof(__FUNCSIG__) - 57; +#else + auto name = str_view{}; +#endif + std::size_t p = 0; + for (std::size_t i = name.size_; i > 0; --i) { + if (name.str_[i] == ':') { + p = i + 1; + break; + } + } + if (p > 0) { + name.size_ -= p; + name.str_ += p; + } + return name; + } else { + return str_view{}; // Unsupported compiler or Invalid customize. + } +} + +template +constexpr auto type_name() noexcept { + [[maybe_unused]] constexpr auto custom = customize::enum_type_name(); + static_assert(std::is_same_v, customize::customize_t>, "magic_enum::customize requires customize_t type."); + if constexpr (custom.first == customize::detail::customize_tag::custom_tag) { + constexpr auto name = custom.second; + static_assert(!name.empty(), "magic_enum::customize requires not empty string."); + return static_str{name}; + } else if constexpr (custom.first == customize::detail::customize_tag::invalid_tag) { + return static_str<0>{}; + } else if constexpr (custom.first == customize::detail::customize_tag::default_tag) { + constexpr auto name = n(); + return static_str{name}; + } else { + static_assert(always_false_v, "magic_enum::customize invalid."); + } +} + +template +inline constexpr auto type_name_v = type_name(); + +template +constexpr auto n() noexcept { + static_assert(is_enum_v, "magic_enum::detail::n requires enum type."); + + if constexpr (supported::value) { +#if defined(MAGIC_ENUM_GET_ENUM_NAME_BUILTIN) + constexpr auto name_ptr = MAGIC_ENUM_GET_ENUM_NAME_BUILTIN(V); + auto name = name_ptr ? str_view{name_ptr, std::char_traits::length(name_ptr)} : str_view{}; +#elif defined(__clang__) + str_view name; + if constexpr (sizeof(__PRETTY_FUNCTION__) == sizeof(__FUNCTION__)) { + static_assert(always_false_v, "magic_enum::detail::n requires __PRETTY_FUNCTION__."); + return str_view{}; + } else { + name.size_ = sizeof(__PRETTY_FUNCTION__) - 36; + name.str_ = __PRETTY_FUNCTION__ + 34; + } + if (name.size_ > 22 && name.str_[0] == '(' && name.str_[1] == 'a' && name.str_[10] == ' ' && name.str_[22] == ':') { + name.size_ -= 23; + name.str_ += 23; + } + if (name.str_[0] == '(' || name.str_[0] == '-' || (name.str_[0] >= '0' && name.str_[0] <= '9')) { + name = str_view{}; + } +#elif defined(__GNUC__) + auto name = str_view{__PRETTY_FUNCTION__, sizeof(__PRETTY_FUNCTION__) - 1}; + if constexpr (sizeof(__PRETTY_FUNCTION__) == sizeof(__FUNCTION__)) { + static_assert(always_false_v, "magic_enum::detail::n requires __PRETTY_FUNCTION__."); + return str_view{}; + } else if (name.str_[name.size_ - 1] == ']') { + name.size_ -= 55; + name.str_ += 54; + } else { + name.size_ -= 40; + name.str_ += 37; + } + if (name.str_[0] == '(') { + name = str_view{}; + } +#elif defined(_MSC_VER) + str_view name; + if ((__FUNCSIG__[5] == '_' && __FUNCSIG__[35] != '(') || (__FUNCSIG__[5] == 'c' && __FUNCSIG__[41] != '(')) { + // CLI/C++ workaround (see https://github.com/Neargye/magic_enum/issues/284). + name.str_ = __FUNCSIG__; + name.str_ += 35; + name.size_ = sizeof(__FUNCSIG__) - 52; + } +#else + auto name = str_view{}; +#endif + std::size_t p = 0; + for (std::size_t i = name.size_; i > 0; --i) { + if (name.str_[i] == ':') { + p = i + 1; + break; + } + } + if (p > 0) { + name.size_ -= p; + name.str_ += p; + } + return name; + } else { + return str_view{}; // Unsupported compiler or Invalid customize. + } +} + +#if defined(_MSC_VER) && !defined(__clang__) && _MSC_VER < 1920 +# define MAGIC_ENUM_VS_2017_WORKAROUND 1 +#endif + +#if defined(MAGIC_ENUM_VS_2017_WORKAROUND) +template +constexpr auto n() noexcept { + static_assert(is_enum_v, "magic_enum::detail::n requires enum type."); + +# if defined(MAGIC_ENUM_GET_ENUM_NAME_BUILTIN) + constexpr auto name_ptr = MAGIC_ENUM_GET_ENUM_NAME_BUILTIN(V); + auto name = name_ptr ? str_view{name_ptr, std::char_traits::length(name_ptr)} : str_view{}; +# else + // CLI/C++ workaround (see https://github.com/Neargye/magic_enum/issues/284). + str_view name; + name.str_ = __FUNCSIG__; + name.size_ = sizeof(__FUNCSIG__) - 17; + std::size_t p = 0; + for (std::size_t i = name.size_; i > 0; --i) { + if (name.str_[i] == ',' || name.str_[i] == ':') { + p = i + 1; + break; + } + } + if (p > 0) { + name.size_ -= p; + name.str_ += p; + } + if (name.str_[0] == '(' || name.str_[0] == '-' || (name.str_[0] >= '0' && name.str_[0] <= '9')) { + name = str_view{}; + } + return name; +# endif +} +#endif + +template +constexpr auto enum_name() noexcept { + [[maybe_unused]] constexpr auto custom = customize::enum_name(V); + static_assert(std::is_same_v, customize::customize_t>, "magic_enum::customize requires customize_t type."); + if constexpr (custom.first == customize::detail::customize_tag::custom_tag) { + constexpr auto name = custom.second; + static_assert(!name.empty(), "magic_enum::customize requires not empty string."); + return static_str{name}; + } else if constexpr (custom.first == customize::detail::customize_tag::invalid_tag) { + return static_str<0>{}; + } else if constexpr (custom.first == customize::detail::customize_tag::default_tag) { +#if defined(MAGIC_ENUM_VS_2017_WORKAROUND) + constexpr auto name = n(); +#else + constexpr auto name = n(); +#endif + return static_str{name}; + } else { + static_assert(always_false_v, "magic_enum::customize invalid."); + } +} + +template +inline constexpr auto enum_name_v = enum_name(); + +template +constexpr bool is_valid() noexcept { +#if defined(__clang__) && __clang_major__ >= 16 + // https://reviews.llvm.org/D130058, https://reviews.llvm.org/D131307 + constexpr E v = __builtin_bit_cast(E, V); +#else + constexpr E v = static_cast(V); +#endif + [[maybe_unused]] constexpr auto custom = customize::enum_name(v); + static_assert(std::is_same_v, customize::customize_t>, "magic_enum::customize requires customize_t type."); + if constexpr (custom.first == customize::detail::customize_tag::custom_tag) { + constexpr auto name = custom.second; + static_assert(!name.empty(), "magic_enum::customize requires not empty string."); + return name.size() != 0; + } else if constexpr (custom.first == customize::detail::customize_tag::default_tag) { +#if defined(MAGIC_ENUM_VS_2017_WORKAROUND) + return n().size_ != 0; +#else + return n().size_ != 0; +#endif + } else { + return false; + } +} + +enum class enum_subtype { + common, + flags +}; + +template > +constexpr U ualue(std::size_t i) noexcept { + if constexpr (std::is_same_v) { // bool special case + static_assert(O == 0, "magic_enum::detail::ualue requires valid offset."); + + return static_cast(i); + } else if constexpr (S == enum_subtype::flags) { + return static_cast(U{1} << static_cast(static_cast(i) + O)); + } else { + return static_cast(static_cast(i) + O); + } +} + +template > +constexpr E value(std::size_t i) noexcept { + return static_cast(ualue(i)); +} + +template > +constexpr int reflected_min() noexcept { + if constexpr (S == enum_subtype::flags) { + return 0; + } else { + constexpr auto lhs = range_min::value; + constexpr auto rhs = (std::numeric_limits::min)(); + + if constexpr (cmp_less(rhs, lhs)) { + return lhs; + } else { + return rhs; + } + } +} + +template > +constexpr int reflected_max() noexcept { + if constexpr (S == enum_subtype::flags) { + return std::numeric_limits::digits - 1; + } else { + constexpr auto lhs = range_max::value; + constexpr auto rhs = (std::numeric_limits::max)(); + + if constexpr (cmp_less(lhs, rhs)) { + return lhs; + } else { + return rhs; + } + } +} + +#define MAGIC_ENUM_FOR_EACH_256(T) \ + T( 0)T( 1)T( 2)T( 3)T( 4)T( 5)T( 6)T( 7)T( 8)T( 9)T( 10)T( 11)T( 12)T( 13)T( 14)T( 15)T( 16)T( 17)T( 18)T( 19)T( 20)T( 21)T( 22)T( 23)T( 24)T( 25)T( 26)T( 27)T( 28)T( 29)T( 30)T( 31) \ + T( 32)T( 33)T( 34)T( 35)T( 36)T( 37)T( 38)T( 39)T( 40)T( 41)T( 42)T( 43)T( 44)T( 45)T( 46)T( 47)T( 48)T( 49)T( 50)T( 51)T( 52)T( 53)T( 54)T( 55)T( 56)T( 57)T( 58)T( 59)T( 60)T( 61)T( 62)T( 63) \ + T( 64)T( 65)T( 66)T( 67)T( 68)T( 69)T( 70)T( 71)T( 72)T( 73)T( 74)T( 75)T( 76)T( 77)T( 78)T( 79)T( 80)T( 81)T( 82)T( 83)T( 84)T( 85)T( 86)T( 87)T( 88)T( 89)T( 90)T( 91)T( 92)T( 93)T( 94)T( 95) \ + T( 96)T( 97)T( 98)T( 99)T(100)T(101)T(102)T(103)T(104)T(105)T(106)T(107)T(108)T(109)T(110)T(111)T(112)T(113)T(114)T(115)T(116)T(117)T(118)T(119)T(120)T(121)T(122)T(123)T(124)T(125)T(126)T(127) \ + T(128)T(129)T(130)T(131)T(132)T(133)T(134)T(135)T(136)T(137)T(138)T(139)T(140)T(141)T(142)T(143)T(144)T(145)T(146)T(147)T(148)T(149)T(150)T(151)T(152)T(153)T(154)T(155)T(156)T(157)T(158)T(159) \ + T(160)T(161)T(162)T(163)T(164)T(165)T(166)T(167)T(168)T(169)T(170)T(171)T(172)T(173)T(174)T(175)T(176)T(177)T(178)T(179)T(180)T(181)T(182)T(183)T(184)T(185)T(186)T(187)T(188)T(189)T(190)T(191) \ + T(192)T(193)T(194)T(195)T(196)T(197)T(198)T(199)T(200)T(201)T(202)T(203)T(204)T(205)T(206)T(207)T(208)T(209)T(210)T(211)T(212)T(213)T(214)T(215)T(216)T(217)T(218)T(219)T(220)T(221)T(222)T(223) \ + T(224)T(225)T(226)T(227)T(228)T(229)T(230)T(231)T(232)T(233)T(234)T(235)T(236)T(237)T(238)T(239)T(240)T(241)T(242)T(243)T(244)T(245)T(246)T(247)T(248)T(249)T(250)T(251)T(252)T(253)T(254)T(255) + +template +constexpr void valid_count(bool* valid, std::size_t& count) noexcept { +#define MAGIC_ENUM_V(O) \ + if constexpr ((I + O) < Size) { \ + if constexpr (is_valid(I + O)>()) { \ + valid[I + O] = true; \ + ++count; \ + } \ + } + + MAGIC_ENUM_FOR_EACH_256(MAGIC_ENUM_V) + + if constexpr ((I + 256) < Size) { + valid_count(valid, count); + } +#undef MAGIC_ENUM_V +} + +template +struct valid_count_t { + std::size_t count = 0; + bool valid[N] = {}; +}; + +template +constexpr auto valid_count() noexcept { + valid_count_t vc; + valid_count(vc.valid, vc.count); + return vc; +} + +template +constexpr auto values() noexcept { + constexpr auto vc = valid_count(); + + if constexpr (vc.count > 0) { +#if defined(MAGIC_ENUM_ARRAY_CONSTEXPR) + std::array values = {}; +#else + E values[vc.count] = {}; +#endif + for (std::size_t i = 0, v = 0; v < vc.count; ++i) { + if (vc.valid[i]) { + values[v++] = value(i); + } + } +#if defined(MAGIC_ENUM_ARRAY_CONSTEXPR) + return values; +#else + return to_array(values, std::make_index_sequence{}); +#endif + } else { + return std::array{}; + } +} + +template > +constexpr auto values() noexcept { + constexpr auto min = reflected_min(); + constexpr auto max = reflected_max(); + constexpr auto range_size = max - min + 1; + static_assert(range_size > 0, "magic_enum::enum_range requires valid size."); + + return values(); +} + +template > +constexpr enum_subtype subtype(std::true_type) noexcept { + if constexpr (std::is_same_v) { // bool special case + return enum_subtype::common; + } else if constexpr (has_is_flags::value) { + return customize::enum_range::is_flags ? enum_subtype::flags : enum_subtype::common; + } else { +#if defined(MAGIC_ENUM_AUTO_IS_FLAGS) + constexpr auto flags_values = values(); + constexpr auto default_values = values(); + if (flags_values.size() == 0 || default_values.size() > flags_values.size()) { + return enum_subtype::common; + } + for (std::size_t i = 0; i < default_values.size(); ++i) { + const auto v = static_cast(default_values[i]); + if (v != 0 && (v & (v - 1)) != 0) { + return enum_subtype::common; + } + } + return enum_subtype::flags; +#else + return enum_subtype::common; +#endif + } +} + +template +constexpr enum_subtype subtype(std::false_type) noexcept { + // For non-enum type return default common subtype. + return enum_subtype::common; +} + +template > +inline constexpr auto subtype_v = subtype(std::is_enum{}); + +template +inline constexpr auto values_v = values(); + +template > +using values_t = decltype((values_v)); + +template +inline constexpr auto count_v = values_v.size(); + +template > +inline constexpr auto min_v = (count_v > 0) ? static_cast(values_v.front()) : U{0}; + +template > +inline constexpr auto max_v = (count_v > 0) ? static_cast(values_v.back()) : U{0}; + +template +constexpr auto names(std::index_sequence) noexcept { + constexpr auto names = std::array{{enum_name_v[I]>...}}; + return names; +} + +template +inline constexpr auto names_v = names(std::make_index_sequence>{}); + +template > +using names_t = decltype((names_v)); + +template +constexpr auto entries(std::index_sequence) noexcept { + constexpr auto entries = std::array, sizeof...(I)>{{{values_v[I], enum_name_v[I]>}...}}; + return entries; +} + +template +inline constexpr auto entries_v = entries(std::make_index_sequence>{}); + +template > +using entries_t = decltype((entries_v)); + +template > +constexpr bool is_sparse() noexcept { + if constexpr (count_v == 0) { + return false; + } else if constexpr (std::is_same_v) { // bool special case + return false; + } else { + constexpr auto max = (S == enum_subtype::flags) ? log2(max_v) : max_v; + constexpr auto min = (S == enum_subtype::flags) ? log2(min_v) : min_v; + constexpr auto range_size = max - min + 1; + + return range_size != count_v; + } +} + +template > +inline constexpr bool is_sparse_v = is_sparse(); + +template +struct is_reflected +#if defined(MAGIC_ENUM_NO_CHECK_REFLECTED_ENUM) + : std::true_type {}; +#else + : std::bool_constant && (count_v != 0)> {}; +#endif + +template +inline constexpr bool is_reflected_v = is_reflected, S>{}; + +template +struct enable_if_enum {}; + +template +struct enable_if_enum { + using type = R; + static_assert(supported::value, "magic_enum unsupported compiler (https://github.com/Neargye/magic_enum#compiler-compatibility)."); +}; + +template , typename D = std::decay_t> +using enable_if_t = typename enable_if_enum && std::is_invocable_r_v, R>::type; + +template >, int> = 0> +using enum_concept = T; + +template > +struct is_scoped_enum : std::false_type {}; + +template +struct is_scoped_enum : std::bool_constant>> {}; + +template > +struct is_unscoped_enum : std::false_type {}; + +template +struct is_unscoped_enum : std::bool_constant>> {}; + +template >> +struct underlying_type {}; + +template +struct underlying_type : std::underlying_type> {}; + +#if defined(MAGIC_ENUM_ENABLE_HASH) || defined(MAGIC_ENUM_ENABLE_HASH_SWITCH) + +template +struct constexpr_hash_t; + +template +struct constexpr_hash_t>> { + constexpr auto operator()(Value value) const noexcept { + using U = typename underlying_type::type; + if constexpr (std::is_same_v) { // bool special case + return static_cast(value); + } else { + return static_cast(value); + } + } + using secondary_hash = constexpr_hash_t; +}; + +template +struct constexpr_hash_t>> { + static constexpr std::uint32_t crc_table[256] { + 0x00000000L, 0x77073096L, 0xee0e612cL, 0x990951baL, 0x076dc419L, 0x706af48fL, 0xe963a535L, 0x9e6495a3L, + 0x0edb8832L, 0x79dcb8a4L, 0xe0d5e91eL, 0x97d2d988L, 0x09b64c2bL, 0x7eb17cbdL, 0xe7b82d07L, 0x90bf1d91L, + 0x1db71064L, 0x6ab020f2L, 0xf3b97148L, 0x84be41deL, 0x1adad47dL, 0x6ddde4ebL, 0xf4d4b551L, 0x83d385c7L, + 0x136c9856L, 0x646ba8c0L, 0xfd62f97aL, 0x8a65c9ecL, 0x14015c4fL, 0x63066cd9L, 0xfa0f3d63L, 0x8d080df5L, + 0x3b6e20c8L, 0x4c69105eL, 0xd56041e4L, 0xa2677172L, 0x3c03e4d1L, 0x4b04d447L, 0xd20d85fdL, 0xa50ab56bL, + 0x35b5a8faL, 0x42b2986cL, 0xdbbbc9d6L, 0xacbcf940L, 0x32d86ce3L, 0x45df5c75L, 0xdcd60dcfL, 0xabd13d59L, + 0x26d930acL, 0x51de003aL, 0xc8d75180L, 0xbfd06116L, 0x21b4f4b5L, 0x56b3c423L, 0xcfba9599L, 0xb8bda50fL, + 0x2802b89eL, 0x5f058808L, 0xc60cd9b2L, 0xb10be924L, 0x2f6f7c87L, 0x58684c11L, 0xc1611dabL, 0xb6662d3dL, + 0x76dc4190L, 0x01db7106L, 0x98d220bcL, 0xefd5102aL, 0x71b18589L, 0x06b6b51fL, 0x9fbfe4a5L, 0xe8b8d433L, + 0x7807c9a2L, 0x0f00f934L, 0x9609a88eL, 0xe10e9818L, 0x7f6a0dbbL, 0x086d3d2dL, 0x91646c97L, 0xe6635c01L, + 0x6b6b51f4L, 0x1c6c6162L, 0x856530d8L, 0xf262004eL, 0x6c0695edL, 0x1b01a57bL, 0x8208f4c1L, 0xf50fc457L, + 0x65b0d9c6L, 0x12b7e950L, 0x8bbeb8eaL, 0xfcb9887cL, 0x62dd1ddfL, 0x15da2d49L, 0x8cd37cf3L, 0xfbd44c65L, + 0x4db26158L, 0x3ab551ceL, 0xa3bc0074L, 0xd4bb30e2L, 0x4adfa541L, 0x3dd895d7L, 0xa4d1c46dL, 0xd3d6f4fbL, + 0x4369e96aL, 0x346ed9fcL, 0xad678846L, 0xda60b8d0L, 0x44042d73L, 0x33031de5L, 0xaa0a4c5fL, 0xdd0d7cc9L, + 0x5005713cL, 0x270241aaL, 0xbe0b1010L, 0xc90c2086L, 0x5768b525L, 0x206f85b3L, 0xb966d409L, 0xce61e49fL, + 0x5edef90eL, 0x29d9c998L, 0xb0d09822L, 0xc7d7a8b4L, 0x59b33d17L, 0x2eb40d81L, 0xb7bd5c3bL, 0xc0ba6cadL, + 0xedb88320L, 0x9abfb3b6L, 0x03b6e20cL, 0x74b1d29aL, 0xead54739L, 0x9dd277afL, 0x04db2615L, 0x73dc1683L, + 0xe3630b12L, 0x94643b84L, 0x0d6d6a3eL, 0x7a6a5aa8L, 0xe40ecf0bL, 0x9309ff9dL, 0x0a00ae27L, 0x7d079eb1L, + 0xf00f9344L, 0x8708a3d2L, 0x1e01f268L, 0x6906c2feL, 0xf762575dL, 0x806567cbL, 0x196c3671L, 0x6e6b06e7L, + 0xfed41b76L, 0x89d32be0L, 0x10da7a5aL, 0x67dd4accL, 0xf9b9df6fL, 0x8ebeeff9L, 0x17b7be43L, 0x60b08ed5L, + 0xd6d6a3e8L, 0xa1d1937eL, 0x38d8c2c4L, 0x4fdff252L, 0xd1bb67f1L, 0xa6bc5767L, 0x3fb506ddL, 0x48b2364bL, + 0xd80d2bdaL, 0xaf0a1b4cL, 0x36034af6L, 0x41047a60L, 0xdf60efc3L, 0xa867df55L, 0x316e8eefL, 0x4669be79L, + 0xcb61b38cL, 0xbc66831aL, 0x256fd2a0L, 0x5268e236L, 0xcc0c7795L, 0xbb0b4703L, 0x220216b9L, 0x5505262fL, + 0xc5ba3bbeL, 0xb2bd0b28L, 0x2bb45a92L, 0x5cb36a04L, 0xc2d7ffa7L, 0xb5d0cf31L, 0x2cd99e8bL, 0x5bdeae1dL, + 0x9b64c2b0L, 0xec63f226L, 0x756aa39cL, 0x026d930aL, 0x9c0906a9L, 0xeb0e363fL, 0x72076785L, 0x05005713L, + 0x95bf4a82L, 0xe2b87a14L, 0x7bb12baeL, 0x0cb61b38L, 0x92d28e9bL, 0xe5d5be0dL, 0x7cdcefb7L, 0x0bdbdf21L, + 0x86d3d2d4L, 0xf1d4e242L, 0x68ddb3f8L, 0x1fda836eL, 0x81be16cdL, 0xf6b9265bL, 0x6fb077e1L, 0x18b74777L, + 0x88085ae6L, 0xff0f6a70L, 0x66063bcaL, 0x11010b5cL, 0x8f659effL, 0xf862ae69L, 0x616bffd3L, 0x166ccf45L, + 0xa00ae278L, 0xd70dd2eeL, 0x4e048354L, 0x3903b3c2L, 0xa7672661L, 0xd06016f7L, 0x4969474dL, 0x3e6e77dbL, + 0xaed16a4aL, 0xd9d65adcL, 0x40df0b66L, 0x37d83bf0L, 0xa9bcae53L, 0xdebb9ec5L, 0x47b2cf7fL, 0x30b5ffe9L, + 0xbdbdf21cL, 0xcabac28aL, 0x53b39330L, 0x24b4a3a6L, 0xbad03605L, 0xcdd70693L, 0x54de5729L, 0x23d967bfL, + 0xb3667a2eL, 0xc4614ab8L, 0x5d681b02L, 0x2a6f2b94L, 0xb40bbe37L, 0xc30c8ea1L, 0x5a05df1bL, 0x2d02ef8dL + }; + constexpr std::uint32_t operator()(string_view value) const noexcept { + auto crc = static_cast(0xffffffffL); + for (const auto c : value) { + crc = (crc >> 8) ^ crc_table[(crc ^ static_cast(c)) & 0xff]; + } + return crc ^ 0xffffffffL; + } + + struct secondary_hash { + constexpr std::uint32_t operator()(string_view value) const noexcept { + auto acc = static_cast(2166136261ULL); + for (const auto c : value) { + acc = ((acc ^ static_cast(c)) * static_cast(16777619ULL)) & (std::numeric_limits::max)(); + } + return static_cast(acc); + } + }; +}; + +template +inline constexpr Hash hash_v{}; + +template +constexpr auto calculate_cases(std::size_t Page) noexcept { + constexpr std::array values = *GlobValues; + constexpr std::size_t size = values.size(); + + using switch_t = std::invoke_result_t; + static_assert(std::is_integral_v && !std::is_same_v); + const std::size_t values_to = (std::min)(static_cast(256), size - Page); + + std::array result{}; + auto fill = result.begin(); + { + auto first = values.begin() + static_cast(Page); + auto last = values.begin() + static_cast(Page + values_to); + while (first != last) { + *fill++ = hash_v(*first++); + } + } + + // dead cases, try to avoid case collisions + for (switch_t last_value = result[values_to - 1]; fill != result.end() && last_value != (std::numeric_limits::max)(); *fill++ = ++last_value) { + } + + { + auto it = result.begin(); + auto last_value = (std::numeric_limits::min)(); + for (; fill != result.end(); *fill++ = last_value++) { + while (last_value == *it) { + ++last_value, ++it; + } + } + } + + return result; +} + +template +constexpr R invoke_r(F&& f, Args&&... args) noexcept(std::is_nothrow_invocable_r_v) { + if constexpr (std::is_void_v) { + std::forward(f)(std::forward(args)...); + } else { + return static_cast(std::forward(f)(std::forward(args)...)); + } +} + +enum class case_call_t { + index, + value +}; + +template +inline constexpr auto default_result_type_lambda = []() noexcept(std::is_nothrow_default_constructible_v) { return T{}; }; + +template <> +inline constexpr auto default_result_type_lambda = []() noexcept {}; + +template +constexpr bool has_duplicate() noexcept { + using value_t = std::decay_t; + using hash_value_t = std::invoke_result_t; + std::arraysize()> hashes{}; + std::size_t size = 0; + for (auto elem : *Arr) { + hashes[size] = hash_v(elem); + for (auto i = size++; i > 0; --i) { + if (hashes[i] < hashes[i - 1]) { + auto tmp = hashes[i]; + hashes[i] = hashes[i - 1]; + hashes[i - 1] = tmp; + } else if (hashes[i] == hashes[i - 1]) { + return false; + } else { + break; + } + } + } + return true; +} + +#define MAGIC_ENUM_CASE(val) \ + case cases[val]: \ + if constexpr ((val) + Page < size) { \ + if (!pred(values[val + Page], searched)) { \ + break; \ + } \ + if constexpr (CallValue == case_call_t::index) { \ + if constexpr (std::is_invocable_r_v>) { \ + return detail::invoke_r(std::forward(lambda), std::integral_constant{}); \ + } else if constexpr (std::is_invocable_v>) { \ + MAGIC_ENUM_ASSERT(false && "magic_enum::detail::constexpr_switch wrong result type."); \ + } \ + } else if constexpr (CallValue == case_call_t::value) { \ + if constexpr (std::is_invocable_r_v>) { \ + return detail::invoke_r(std::forward(lambda), enum_constant{}); \ + } else if constexpr (std::is_invocable_r_v>) { \ + MAGIC_ENUM_ASSERT(false && "magic_enum::detail::constexpr_switch wrong result type."); \ + } \ + } \ + break; \ + } else [[fallthrough]]; + +template ::value_type>, + typename BinaryPredicate = std::equal_to<>, + typename Lambda, + typename ResultGetterType> +constexpr decltype(auto) constexpr_switch( + Lambda&& lambda, + typename std::decay_t::value_type searched, + ResultGetterType&& def, + BinaryPredicate&& pred = {}) { + using result_t = std::invoke_result_t; + using hash_t = std::conditional_t(), Hash, typename Hash::secondary_hash>; + static_assert(has_duplicate(), "magic_enum::detail::constexpr_switch duplicated hash found, please report it: https://github.com/Neargye/magic_enum/issues."); + constexpr std::array values = *GlobValues; + constexpr std::size_t size = values.size(); + constexpr std::array cases = calculate_cases(Page); + + switch (hash_v(searched)) { + MAGIC_ENUM_FOR_EACH_256(MAGIC_ENUM_CASE) + default: + if constexpr (size > 256 + Page) { + return constexpr_switch(std::forward(lambda), searched, std::forward(def)); + } + break; + } + return def(); +} + +#undef MAGIC_ENUM_CASE + +#endif + +} // namespace magic_enum::detail + +// Checks is magic_enum supported compiler. +inline constexpr bool is_magic_enum_supported = detail::supported::value; + +template +using Enum = detail::enum_concept; + +// Checks whether T is an Unscoped enumeration type. +// Provides the member constant value which is equal to true, if T is an [Unscoped enumeration](https://en.cppreference.com/w/cpp/language/enum#Unscoped_enumeration) type. Otherwise, value is equal to false. +template +struct is_unscoped_enum : detail::is_unscoped_enum {}; + +template +inline constexpr bool is_unscoped_enum_v = is_unscoped_enum::value; + +// Checks whether T is an Scoped enumeration type. +// Provides the member constant value which is equal to true, if T is an [Scoped enumeration](https://en.cppreference.com/w/cpp/language/enum#Scoped_enumerations) type. Otherwise, value is equal to false. +template +struct is_scoped_enum : detail::is_scoped_enum {}; + +template +inline constexpr bool is_scoped_enum_v = is_scoped_enum::value; + +// If T is a complete enumeration type, provides a member typedef type that names the underlying type of T. +// Otherwise, if T is not an enumeration type, there is no member type. Otherwise (T is an incomplete enumeration type), the program is ill-formed. +template +struct underlying_type : detail::underlying_type {}; + +template +using underlying_type_t = typename underlying_type::type; + +template +using enum_constant = detail::enum_constant; + +// Returns type name of enum. +template +[[nodiscard]] constexpr auto enum_type_name() noexcept -> detail::enable_if_t { + constexpr string_view name = detail::type_name_v>; + static_assert(!name.empty(), "magic_enum::enum_type_name enum type does not have a name."); + + return name; +} + +// Returns number of enum values. +template > +[[nodiscard]] constexpr auto enum_count() noexcept -> detail::enable_if_t { + return detail::count_v, S>; +} + +// Returns enum value at specified index. +// No bounds checking is performed: the behavior is undefined if index >= number of enum values. +template > +[[nodiscard]] constexpr auto enum_value(std::size_t index) noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + if constexpr (detail::is_sparse_v) { + return MAGIC_ENUM_ASSERT(index < detail::count_v), detail::values_v[index]; + } else { + constexpr auto min = (S == detail::enum_subtype::flags) ? detail::log2(detail::min_v) : detail::min_v; + + return MAGIC_ENUM_ASSERT(index < detail::count_v), detail::value(index); + } +} + +// Returns enum value at specified index. +template > +[[nodiscard]] constexpr auto enum_value() noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + static_assert(I < detail::count_v, "magic_enum::enum_value out of range."); + + return enum_value(I); +} + +// Returns std::array with enum values, sorted by enum value. +template > +[[nodiscard]] constexpr auto enum_values() noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + return detail::values_v; +} + +// Returns integer value from enum value. +template +[[nodiscard]] constexpr auto enum_integer(E value) noexcept -> detail::enable_if_t> { + return static_cast>(value); +} + +// Returns underlying value from enum value. +template +[[nodiscard]] constexpr auto enum_underlying(E value) noexcept -> detail::enable_if_t> { + return static_cast>(value); +} + +// Obtains index in enum values from enum value. +// Returns optional with index. +template > +[[nodiscard]] constexpr auto enum_index(E value) noexcept -> detail::enable_if_t> { + using D = std::decay_t; + using U = underlying_type_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + if constexpr (detail::is_sparse_v || (S == detail::enum_subtype::flags)) { +#if defined(MAGIC_ENUM_ENABLE_HASH) + return detail::constexpr_switch<&detail::values_v, detail::case_call_t::index>( + [](std::size_t i) { return optional{i}; }, + value, + detail::default_result_type_lambda>); +#else + for (std::size_t i = 0; i < detail::count_v; ++i) { + if (enum_value(i) == value) { + return i; + } + } + return {}; // Invalid value or out of range. +#endif + } else { + const auto v = static_cast(value); + if (v >= detail::min_v && v <= detail::max_v) { + return static_cast(v - detail::min_v); + } + return {}; // Invalid value or out of range. + } +} + +// Obtains index in enum values from enum value. +// Returns optional with index. +template +[[nodiscard]] constexpr auto enum_index(E value) noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + return enum_index(value); +} + +// Obtains index in enum values from static storage enum variable. +template >> +[[nodiscard]] constexpr auto enum_index() noexcept -> detail::enable_if_t {\ + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + constexpr auto index = enum_index(V); + static_assert(index, "magic_enum::enum_index enum value does not have a index."); + + return *index; +} + +// Returns name from static storage enum variable. +// This version is much lighter on the compile times and is not restricted to the enum_range limitation. +template +[[nodiscard]] constexpr auto enum_name() noexcept -> detail::enable_if_t { + constexpr string_view name = detail::enum_name_v, V>; + static_assert(!name.empty(), "magic_enum::enum_name enum value does not have a name."); + + return name; +} + +// Returns name from enum value. +// If enum value does not have name or value out of range, returns empty string. +template > +[[nodiscard]] constexpr auto enum_name(E value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + if (const auto i = enum_index(value)) { + return detail::names_v[*i]; + } + return {}; +} + +// Returns name from enum value. +// If enum value does not have name or value out of range, returns empty string. +template +[[nodiscard]] constexpr auto enum_name(E value) -> detail::enable_if_t { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + return enum_name(value); +} + +// Returns std::array with names, sorted by enum value. +template > +[[nodiscard]] constexpr auto enum_names() noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + return detail::names_v; +} + +// Returns std::array with pairs (value, name), sorted by enum value. +template > +[[nodiscard]] constexpr auto enum_entries() noexcept -> detail::enable_if_t> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + return detail::entries_v; +} + +// Allows you to write magic_enum::enum_cast("bar", magic_enum::case_insensitive); +inline constexpr auto case_insensitive = detail::case_insensitive<>{}; + +// Obtains enum value from integer value. +// Returns optional with enum value. +template > +[[nodiscard]] constexpr auto enum_cast(underlying_type_t value) noexcept -> detail::enable_if_t>> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + + if constexpr (detail::is_sparse_v || (S == detail::enum_subtype::flags)) { +#if defined(MAGIC_ENUM_ENABLE_HASH) + return detail::constexpr_switch<&detail::values_v, detail::case_call_t::value>( + [](D v) { return optional{v}; }, + static_cast(value), + detail::default_result_type_lambda>); +#else + for (std::size_t i = 0; i < detail::count_v; ++i) { + if (value == static_cast>(enum_value(i))) { + return static_cast(value); + } + } + return {}; // Invalid value or out of range. +#endif + } else { + if (value >= detail::min_v && value <= detail::max_v) { + return static_cast(value); + } + return {}; // Invalid value or out of range. + } +} + +// Obtains enum value from name. +// Returns optional with enum value. +template , typename BinaryPredicate = std::equal_to<>> +[[nodiscard]] constexpr auto enum_cast(string_view value, [[maybe_unused]] BinaryPredicate p = {}) noexcept(detail::is_nothrow_invocable()) -> detail::enable_if_t>, BinaryPredicate> { + using D = std::decay_t; + static_assert(detail::is_reflected_v, "magic_enum requires enum implementation and valid max and min."); + +#if defined(MAGIC_ENUM_ENABLE_HASH) + if constexpr (detail::is_default_predicate()) { + return detail::constexpr_switch<&detail::names_v, detail::case_call_t::index>( + [](std::size_t i) { return optional{detail::values_v[i]}; }, + value, + detail::default_result_type_lambda>, + [&p](string_view lhs, string_view rhs) { return detail::cmp_equal(lhs, rhs, p); }); + } +#endif + for (std::size_t i = 0; i < detail::count_v; ++i) { + if (detail::cmp_equal(value, detail::names_v[i], p)) { + return enum_value(i); + } + } + return {}; // Invalid value or out of range. +} + +// Checks whether enum contains value with such value. +template > +[[nodiscard]] constexpr auto enum_contains(E value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + using U = underlying_type_t; + + return static_cast(enum_cast(static_cast(value))); +} + +// Checks whether enum contains value with such value. +template +[[nodiscard]] constexpr auto enum_contains(E value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + using U = underlying_type_t; + + return static_cast(enum_cast(static_cast(value))); +} + +// Checks whether enum contains value with such integer value. +template > +[[nodiscard]] constexpr auto enum_contains(underlying_type_t value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + + return static_cast(enum_cast(value)); +} + +// Checks whether enum contains enumerator with such name. +template , typename BinaryPredicate = std::equal_to<>> +[[nodiscard]] constexpr auto enum_contains(string_view value, BinaryPredicate p = {}) noexcept(detail::is_nothrow_invocable()) -> detail::enable_if_t { + using D = std::decay_t; + + return static_cast(enum_cast(value, std::move(p))); +} + +// Returns true if the enum integer value is in the range of values that can be reflected. +template > +[[nodiscard]] constexpr auto enum_reflected(underlying_type_t value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + + if constexpr (detail::is_reflected_v) { + constexpr auto min = detail::reflected_min(); + constexpr auto max = detail::reflected_max(); + return value >= min && value <= max; + } else { + return false; + } +} + +// Returns true if the enum value is in the range of values that can be reflected. +template > +[[nodiscard]] constexpr auto enum_reflected(E value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + + return enum_reflected(static_cast>(value)); +} + +// Returns true if the enum value is in the range of values that can be reflected. +template +[[nodiscard]] constexpr auto enum_reflected(E value) noexcept -> detail::enable_if_t { + using D = std::decay_t; + + return enum_reflected(value); +} + +template +inline constexpr auto as_flags = AsFlags ? detail::enum_subtype::flags : detail::enum_subtype::common; + +template +inline constexpr auto as_common = AsFlags ? detail::enum_subtype::common : detail::enum_subtype::flags; + +namespace bitwise_operators { + +template = 0> +constexpr E operator~(E rhs) noexcept { + return static_cast(~static_cast>(rhs)); +} + +template = 0> +constexpr E operator|(E lhs, E rhs) noexcept { + return static_cast(static_cast>(lhs) | static_cast>(rhs)); +} + +template = 0> +constexpr E operator&(E lhs, E rhs) noexcept { + return static_cast(static_cast>(lhs) & static_cast>(rhs)); +} + +template = 0> +constexpr E operator^(E lhs, E rhs) noexcept { + return static_cast(static_cast>(lhs) ^ static_cast>(rhs)); +} + +template = 0> +constexpr E& operator|=(E& lhs, E rhs) noexcept { + return lhs = (lhs | rhs); +} + +template = 0> +constexpr E& operator&=(E& lhs, E rhs) noexcept { + return lhs = (lhs & rhs); +} + +template = 0> +constexpr E& operator^=(E& lhs, E rhs) noexcept { + return lhs = (lhs ^ rhs); +} + +} // namespace magic_enum::bitwise_operators + +} // namespace magic_enum + +#if defined(__clang__) +# pragma clang diagnostic pop +#elif defined(__GNUC__) +# pragma GCC diagnostic pop +#elif defined(_MSC_VER) +# pragma warning(pop) +#endif + +#undef MAGIC_ENUM_GET_ENUM_NAME_BUILTIN +#undef MAGIC_ENUM_GET_TYPE_NAME_BUILTIN +#undef MAGIC_ENUM_VS_2017_WORKAROUND +#undef MAGIC_ENUM_ARRAY_CONSTEXPR +#undef MAGIC_ENUM_FOR_EACH_256 + +#endif // NEARGYE_MAGIC_ENUM_HPP diff --git a/entry/src/main/cpp/src/napi_init.cpp b/entry/src/main/cpp/src/napi_init.cpp new file mode 100644 index 0000000..1ff1a70 --- /dev/null +++ b/entry/src/main/cpp/src/napi_init.cpp @@ -0,0 +1,353 @@ +#define LOG_TAG "Native" +#include "Logging.hpp" +#include "NapiCallback.hpp" +#include "NapiUtils.hpp" +#include "filemanagement/clouddiskmanager/oh_cloud_disk_manager.h" +#include "include/NapiCallbackStorage.hpp" +#include "magic_enum.hpp" +#include "napi/native_api.h" + +using namespace std::chrono_literals; + +#include + +struct FileChangeData : public NapiObject { + FileChangeData(std::string_view fileId, std::string_view parentId, std::string_view path, + CloudDisk_OperationType opType, uint64_t sz, uint64_t mtimeVal) + : fileId(fileId), parentFileId(parentId), relativePath(path), operationType(opType), size(sz), mtime(mtimeVal) { + } + + std::string_view fileId; + std::string_view parentFileId; + std::string_view relativePath; + CloudDisk_OperationType operationType; + uint64_t size; + uint64_t mtime; + + napi_value ToNapiValue(napi_env env) const override { + napi_value obj; + napi_create_object(env, &obj); + + napi_value napiFileId; + napi_create_string_utf8(env, fileId.data(), fileId.length(), &napiFileId); + napi_set_named_property(env, obj, "fileId", napiFileId); + + napi_value napiParentFileId; + napi_create_string_utf8(env, parentFileId.data(), parentFileId.length(), &napiParentFileId); + napi_set_named_property(env, obj, "parentFileId", napiParentFileId); + + napi_value napiRelativePath; + napi_create_string_utf8(env, relativePath.data(), relativePath.length(), &napiRelativePath); + napi_set_named_property(env, obj, "relativePath", napiRelativePath); + + napi_value napiOperationType; + napi_create_int32(env, static_cast(operationType), &napiOperationType); + napi_set_named_property(env, obj, "operationType", napiOperationType); + + napi_value napiSize; + napi_create_int64(env, static_cast(size), &napiSize); + napi_set_named_property(env, obj, "size", napiSize); + + napi_value napiMtime; + napi_create_int64(env, static_cast(mtime), &napiMtime); + napi_set_named_property(env, obj, "mtime", napiMtime); + + return obj; + } +}; + +enum class SyncState { + IDLE = CloudDisk_SyncState::IDLE, + SYNCING = CloudDisk_SyncState::SYNCING, + SUCCEEDED = CloudDisk_SyncState::SYNC_SUCCEEDED, + FAILED = CloudDisk_SyncState::SYNC_FAILED, + CANCELED = CloudDisk_SyncState::SYNC_CANCELED, + CONFLICTED = CloudDisk_SyncState::SYNC_CONFLICTED, +}; + +struct SyncFileState { + std::string name; + SyncState syncState; + + static std::optional FromNapiValue(napi_env env, napi_value value) { + if (value == nullptr) + return std::nullopt; + + // check if is object + { + napi_valuetype valueType = napi_undefined; + napi_typeof(env, value, &valueType); + if (valueType != napi_object) + return std::nullopt; + } + + SyncFileState entry; + for (const std::string_view prop : {"name", "size", "syncState"}) { + napi_value napiProp; + const auto ret = napi_get_named_property(env, value, prop.data(), &napiProp); + if (ret != napi_ok) { + LogE("Failed to get property %{public}s", prop.data()); + return std::nullopt; + } + + if (prop == "name") { + const auto nameOpt = NapiValueConverter::Get(env, napiProp); + if (!nameOpt.has_value()) { + LogE("Property %{public}s is not a string", prop.data()); + return std::nullopt; + } + entry.name = *nameOpt; + } else if (prop == "syncState") { + const auto stateOpt = NapiValueConverter::Get(env, napiProp); + if (!stateOpt.has_value()) { + LogE("Property %{public}s is not a number", prop.data()); + return std::nullopt; + } + entry.syncState = static_cast(*stateOpt); + } + } + + return entry; + } + + std::string ToString() const { + return "SyncFileState{ name: " + name + ", syncState: " + magic_enum::enum_name(syncState).data() + " }"; + } +}; + +static NapiCallbackStorage g_callbackContexts; + +static napi_value DoAdd(napi_env env, napi_callback_info info) { + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [alias, filePath] = *input; + LogI("input: %{public}s", filePath.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) { + CloudDisk_SyncFolder folder; + folder.displayNameInfo.customAlias = (char *)alias.data(); + folder.displayNameInfo.customAliasLength = alias.length(); + folder.displayNameInfo.displayNameResId = 0; + folder.state = CloudDisk_SyncFolderState::ACTIVE; + folder.path.value = strdup(filePath.c_str()); + folder.path.length = filePath.length(); + const auto ret = OH_CloudDisk_RegisterSyncFolder(&folder); + + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static void OnChangedCallback(const CloudDisk_SyncFolderPath syncFolderPath, const CloudDisk_ChangeData changeData[], + size_t bufferLength) { + LogI("received %{public}zu events in %{public}s", bufferLength, syncFolderPath.value); + for (int i = 0; i < bufferLength; i++) { + const CloudDisk_ChangeData &data = changeData[i]; + const auto change = new FileChangeData{ + std::string_view{data.fileId.value, data.fileId.length}, + std::string_view{data.parentFileId.value, data.parentFileId.length}, + std::string_view{data.relativePathInfo.value, data.relativePathInfo.length}, + data.operationType, + data.size, + data.mtime, + }; + g_callbackContexts.InvokeAll(change); + } +} + +static napi_value DoConnect(napi_env env, napi_callback_info info) { + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath] = *input; + LogI("input: %{public}s", filePath.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) { + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + const auto ret = OH_CloudDisk_ActiveSyncFolder(path); + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + OH_CloudDisk_RegisterSyncFolderChanges(path, OnChangedCallback); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static napi_value DoDisconnect(napi_env env, napi_callback_info info) { + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath] = *input; + LogI("input: %{public}s", filePath.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) { + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + OH_CloudDisk_UnregisterSyncFolderChanges(path); + const auto ret = OH_CloudDisk_DeactiveSyncFolder(path); + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static napi_value DoRemove(napi_env env, napi_callback_info info) { + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath] = *input; + + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + const auto ret = OH_CloudDisk_UnregisterSyncFolder(path); + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; +} + +static napi_value DoRegisterFileChangeMonitor(napi_env env, napi_callback_info info) { + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [callback] = *input; + g_callbackContexts.AddCallback(env, "FileChangeMonitor", callback); + return nullptr; +} + +static napi_value DoSetLocalFileState(napi_env env, napi_callback_info info) { + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [dirPath, entries] = *input; + LogI("input: %{public}s", dirPath.c_str()); + + std::list dirEntries; + { + bool isArray = false; + napi_is_array(env, entries, &isArray); + if (!isArray) { + LogE("entries is not an array"); + return nullptr; + } + + uint32_t length = 0; + napi_get_array_length(env, entries, &length); + for (uint32_t i = 0; i < length; i++) { + napi_value element; + napi_get_element(env, entries, i, &element); + const auto entryOpt = SyncFileState::FromNapiValue(env, element); + if (!entryOpt.has_value()) { + LogE("Failed to parse entry at index %{public}u", i); + return nullptr; + } + + LogI("Parsed entry at index %{public}u: %{public}s", i, entryOpt->ToString().c_str()); + dirEntries.push_back(*entryOpt); + } + } + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) { + CloudDisk_SyncFolderPath path; + path.value = strdup(dirPath.c_str()); + path.length = dirPath.length(); + + std::vector fileSyncStates; + for (const auto &entry : dirEntries) { + CloudDisk_FileSyncState state; + state.filePathInfo.value = strdup((dirPath + '/' + entry.name).c_str()); + state.filePathInfo.length = strlen(state.filePathInfo.value); + state.syncState = static_cast(entry.syncState); + fileSyncStates.push_back(state); + } + + CloudDisk_FailedList *failedLists = nullptr; + size_t failedCount = 0; + const auto ret = OH_CloudDisk_SetFileSyncStates(path, fileSyncStates.data(), fileSyncStates.size(), + &failedLists, &failedCount); + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + for (size_t i = 0; i < failedCount; i++) { + const CloudDisk_FailedList &failed = failedLists[i]; + std::string failedPath(failed.pathInfo.value, failed.pathInfo.length); + LogW("Failed to set state for file %{public}s, reason: %{public}s", failedPath.c_str(), + magic_enum::enum_name(failed.errorReason).data()); + } + + napi_value retValue; + napi_create_int32(env, 0, &retValue); + return retValue; + } + + return 0; +} + +static napi_value DoUpdateCustomAlias(napi_env env, napi_callback_info info) { + const auto input = NapiArgs::Get(env, info); + if (!input.has_value()) + return nullptr; + + const auto [filePath, alias] = *input; + LogI("input: %{public}s, %{public}s", filePath.c_str(), alias.c_str()); + + if (canIUse("SystemCapability.FileManagement.CloudDiskManager")) { + CloudDisk_SyncFolderPath path; + path.value = strdup(filePath.c_str()); + path.length = filePath.length(); + + const auto ret = OH_CloudDisk_UpdateCustomAlias(path, alias.c_str(), alias.length()); + + LogI("returned: %{public}s", magic_enum::enum_name(ret).data()); + + napi_value retValue; + napi_create_int32(env, ret, &retValue); + return retValue; + } + + return 0; +} + +static napi_value Init(napi_env env, napi_value exports) { + const napi_property_descriptor desc[] = { + {"add", nullptr, DoAdd, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"remove", nullptr, DoRemove, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"connect", nullptr, DoConnect, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"disconnect", nullptr, DoDisconnect, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"registerFileChangeMonitor", nullptr, DoRegisterFileChangeMonitor, nullptr, nullptr, nullptr, napi_default, + nullptr}, + {"setLocalFileState", nullptr, DoSetLocalFileState, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"updateCustomAlias", nullptr, DoUpdateCustomAlias, nullptr, nullptr, nullptr, napi_default, nullptr}, + }; + + // add methods to export + napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); + + return exports; +} + +NAPI_MODULE_X(cloudsync, Init, nullptr, 0) diff --git a/entry/src/main/cpp/types/libcloudsync/Index.d.ts b/entry/src/main/cpp/types/libcloudsync/Index.d.ts new file mode 100644 index 0000000..4bdb3e7 --- /dev/null +++ b/entry/src/main/cpp/types/libcloudsync/Index.d.ts @@ -0,0 +1,46 @@ +export enum FileChangeOperationType { + CREATE = 0, + DELETE = 1, + MOVE_FROM = 2, + MOVE_TO = 3, + CLOSE_WRITE = 4, + SYNC_FOLDER_INVALID = 5, +} + +interface FileChangeInfo { + fileId: string; + parentFileId: string; + relativePath: string; + operationType: FileChangeOperationType; + size: number; + mtime: number; +} + +export enum SyncState { + IDLE = 0, + SYNCING = 1, + SUCCEEDED = 2, + FAILED = 3, + CANCELED = 4, + CONFLICTED = 5, +} + +interface SyncFileState { + name: string; + syncState: SyncState; +} + + +export function add(alias: string, path: string): number; + +export function remove(path: string): number; + +export function connect(path: string): number; + +export function disconnect(path: string): number; + +export function registerFileChangeMonitor(callback: (fileChangeInfo: FileChangeInfo) => void): void; + +export function setLocalFileState(path: string, entries: SyncFileState[]): number; + +export function updateCustomAlias(path: string, newAlias: string): number; diff --git a/entry/src/main/cpp/types/libcloudsync/oh-package.json5 b/entry/src/main/cpp/types/libcloudsync/oh-package.json5 new file mode 100644 index 0000000..9a83d42 --- /dev/null +++ b/entry/src/main/cpp/types/libcloudsync/oh-package.json5 @@ -0,0 +1,6 @@ +{ + "name": "libcloudsync.so", + "types": "./Index.d.ts", + "version": "1.0.0", + "description": "Please describe the basic information." +} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 8cfdbac..0780611 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,16 +1,34 @@ -import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; +import { AbilityConstant, ConfigurationConstant, UIAbility, Want, wantAgent } from '@kit.AbilityKit'; import { window } from '@kit.ArkUI'; -import { Logger, NCommon } from 'nextcloud'; +import { AsyncSleep, InitialiseNextcloud, Logger, NCommon, Nextcloud, PhotoAssetsManager } from 'nextcloud'; import { Constants, FolderViewMode } from '../utils/Common'; import { GlobalStorage } from '../utils/GlobalStorage'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { deviceInfo } from '@kit.BasicServicesKit'; +import { statusBarManager } from '@kit.DeskTopExtensionKit'; +import { BUNDLE_NAME } from 'BuildProfile'; +import backgroundTaskManager from '@ohos.resourceschedule.backgroundTaskManager'; const TAG = 'EntryAbility'; export default class EntryAbility extends UIAbility { - onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { - this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + private initCompleted: boolean = false; + private readonly isPc: boolean; + + constructor() { + super(); + this.isPc = deviceInfo.deviceType === deviceInfo.DeviceTypes.TYPE_2IN1; + } + + async onCreate(_want: Want, _launchParam: AbilityConstant.LaunchParam) { Logger.Info(TAG, 'Ability onCreate'); - NCommon.Initialise(this.context); + try { + this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + } catch (error) { + Logger.Error(TAG, `failed to set color mode!`); + } + await InitialiseNextcloud(this.context); + this.initCompleted = true; } onDestroy() { @@ -18,14 +36,22 @@ export default class EntryAbility extends UIAbility { } async onWindowStageCreate(windowStage: window.WindowStage) { - Logger.Info(TAG, 'Main window is created, set main page for this ability'); - + await this.ensureInitCompleted(); let context = this.context; GlobalStorage.SandboxBasePath = context.filesDir; try { - await windowStage.loadContent('pages/Index'); - PersistentStorage.persistProp(Constants.VIEW_MODE_KEY, FolderViewMode.TwoColumns); + let page = this.isPc ? 'pages/PcMainPage' : 'pages/Index'; + await this.startBackgroundRunning(); + NCommon.pHelper = photoAccessHelper.getPhotoAccessHelper(this.context); + PhotoAssetsManager.initialize(Nextcloud.getSyncFolderManager().allLocalFolders); + await windowStage.loadContent(page); + if (this.isPc) { + this.createTrayIcon(); + } else { + PersistentStorage.persistProp(Constants.VIEW_MODE_KEY, FolderViewMode.TwoColumns); + PersistentStorage.persistProp(Constants.PHONE_ACTIVE_ACCOUNT_ID, 0); + } Logger.Info(TAG, 'Succeeded in loading the content.'); } catch (e) { Logger.Error(TAG, 'Failed to load the content. Cause: ' + JSON.stringify(e) ?? ''); @@ -35,15 +61,103 @@ export default class EntryAbility extends UIAbility { Logger.Info(TAG, 'Main window created.'); } + /** + * @throws + */ + async onPrepareToTerminateAsync(): Promise { + if (this.isPc) { + await this.context.hideAbility(); + return true; + } else { + return false; + } + } + onWindowStageDestroy() { Logger.Info(TAG, 'Main window is destroyed, release UI related resources'); } - onForeground() { + async onForeground() { Logger.Info(TAG, 'Ability has brought to foreground'); } - onBackground() { + async onWillBackground() { Logger.Info(TAG, 'Ability has back to background'); } + + async onMemoryLevel(level: AbilityConstant.MemoryLevel) { + Logger.Info(TAG, `onMemoryLevel: level = ${AbilityConstant.MemoryLevel[level]}`); + } + + onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { + Logger.Info(TAG, `want: ${JSON.stringify(want)}, ${JSON.stringify(launchParam)}`); + } + + private async startBackgroundRunning() { + Logger.Info(TAG, `startBackgroundRunning`); + try { + const WANT_AGENT_INFO: wantAgent.WantAgentInfo = { + wants: [{ bundleName: BUNDLE_NAME, abilityName: "EntryAbility" }], + actionType: wantAgent.OperationType.START_ABILITY, + requestCode: 0, + wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] + }; + let wantAgentObj = await wantAgent.getWantAgent(WANT_AGENT_INFO); + let req = new backgroundTaskManager.ContinuousTaskRequest(); + req.backgroundTaskModes = [backgroundTaskManager.BackgroundTaskMode.MODE_TASK_KEEPING]; + req.backgroundTaskSubmodes = [backgroundTaskManager.BackgroundTaskSubmode.SUBMODE_NORMAL_NOTIFICATION]; + req.wantAgent = wantAgentObj; + + let res = await backgroundTaskManager.startBackgroundRunning(this.context, req); + Logger.Info(TAG, `startBackgroundRunning: succeeded: ${JSON.stringify(res)}`); + } catch (error) { + let e = error as BusinessError; + Logger.Error(TAG, `startBackgroundRunning: failed. code is ${e.code} message is ${e.message}`); + } + } + + private async ensureInitCompleted() { + while (!this.initCompleted) { + Logger.Info(TAG, 'Waiting for initialization to complete...'); + await AsyncSleep(100); + } + Logger.Info(TAG, 'Initialization completed, proceeding to create main window.'); + } + + /** + * Convert Resource to PixelMap + * @throws + */ + private convertToPixelMap(res: Resource): PixelMap { + const resManager = this.context.resourceManager; + const drawableDesc = resManager?.getDrawableDescriptor(res.id); + return drawableDesc.getPixelMap(); + } + + private createTrayIcon() { + const menuItem: statusBarManager.StatusBarMenuItem = { + title: "Main Window", + menuAction: { + abilityName: "EntryAbility", + } + }; + + try { + let trayIconImage = this.convertToPixelMap($r('app.media.tray_icon')); + statusBarManager.addToStatusBar(this.context, { + icons: { + black: trayIconImage, + white: trayIconImage, + }, + quickOperation: { + title: this.context.resourceManager.getStringSync($r('app.string.app_name').id), + height: 400, + abilityName: 'StatusBarAbility', + }, + statusBarGroupMenu: [[menuItem]], + }); + } catch (e) { + Logger.Error(TAG, `failed to add to status bar: ${JSON.stringify(e)}`); + } + } } diff --git a/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets index 26d5ae4..5386247 100644 --- a/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +++ b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets @@ -5,9 +5,7 @@ import { Logger, NCommon } from 'nextcloud'; const TAG = 'entry/Backup'; interface BackupContentV1 { - server: string; - username: string; - appPassword: string; + nothing: 'to backup'; } interface BackupObject { @@ -20,9 +18,7 @@ export default class EntryBackupAbility extends BackupExtensionAbility { let object: BackupObject = { version: 1, settings: { - server: NCommon.Preference.hostname, - username: NCommon.Preference.username, - appPassword: NCommon.Preference.password + nothing: 'to backup' } }; let jsonString = JSON.stringify(object); @@ -49,9 +45,6 @@ export default class EntryBackupAbility extends BackupExtensionAbility { } let object: BackupObject = JSON.parse(data); - NCommon.Preference.hostname = object.settings.server; - NCommon.Preference.username = object.settings.username; - NCommon.Preference.password = object.settings.appPassword; Logger.Info(TAG, `onRestore completed with data: ${JSON.stringify(object)}`); file.close(); diff --git a/entry/src/main/ets/models/Category.ets b/entry/src/main/ets/models/Category.ets deleted file mode 100644 index 1bae934..0000000 --- a/entry/src/main/ets/models/Category.ets +++ /dev/null @@ -1,109 +0,0 @@ -import { Logger, NCommon } from 'nextcloud'; -import { ImageListDataSource } from './ImageListDataSource'; -import { PhotoItem, UploadResult } from './PhotoItem'; -import { promptAction } from '@kit.ArkUI'; - -const TAG = 'models/Category'; - -export enum AddAssetResult { - Added, - Unmatched, - Throttled -} - -const IMAGE_LIST_MAX_ITEMS = 100; - -@Observed -export class Category implements NCommon.CategoryConfig { - name: string; - patterns: string[]; - location: string; - /** - * Items that are currently visible on the UI (not all assets are listed for performance reason) - */ - visibleItems: ImageListDataSource = new ImageListDataSource(); - backedItems: PhotoItem[] = []; - - constructor(other: NCommon.CategoryConfig) { - this.name = other.name; - this.patterns = other.patterns; - this.location = other.location; - } - - tryAddAsset(asset: PhotoItem): AddAssetResult { - for (let p of this.patterns) { - if (RegExp(p).test(asset.displayName)) { - if (this.visibleItems.totalCount() > IMAGE_LIST_MAX_ITEMS) { - this.backedItems.push(asset); - return AddAssetResult.Throttled; - } else { - this.visibleItems.addAsset(asset) - return AddAssetResult.Added; - } - } - } - return AddAssetResult.Unmatched; - } - - showNext50(): void { - Logger.Info(TAG, `loadNext50 enter`); - let count = 0; - while (this.backedItems.length > 0 && count < 50) { - let asset = this.backedItems.shift()!; - this.visibleItems.addAsset(asset); - count++; - } - } - - getAssets(start: number, length: number): PhotoItem[] { - let result: PhotoItem[] = []; - // Get from items if it's completely within range - if (start + length <= this.visibleItems.totalCount()) { - result = this.visibleItems.getDataRange(start, length); - } else if (start >= this.visibleItems.totalCount()) { - // Get from backedItems if it's completely within backedItems - let backedStart = start - this.visibleItems.totalCount(); - for (let i = backedStart; i < Math.min(backedStart + length, this.backedItems.length); i++) { - result.push(this.backedItems[i]); - } - } else { - // Get from both items and backedItems - let itemsPartLength = this.visibleItems.totalCount() - start; - result = this.visibleItems.getDataRange(start, itemsPartLength); - let backedPartLength = length - itemsPartLength; - for (let i = 0; i < Math.min(backedPartLength, this.backedItems.length); i++) { - result.push(this.backedItems[i]); - } - } - return result; - } - - getTotalAvailableCount(): number { - return this.visibleItems.totalCount() + this.backedItems.length; - } - - async uploadAllAssets() { - const BATCH_SIZE = 8; - let total = Math.ceil(this.getTotalAvailableCount() / BATCH_SIZE) * BATCH_SIZE; - for (let i = 0; i < total; i += BATCH_SIZE) { - let batch = this.getAssets(i, BATCH_SIZE); - - let canStop = true; - - let futures = batch.map(asset => { - return asset.upload(NCommon.Preference.baseUploadDirectory + this.location).then((result) => { - canStop = canStop && result === UploadResult.EXISTED; - }); - }); - - await Promise.all(futures); - if (canStop) { - promptAction.showToast({ - message: `Upload is probably completed`, - duration: 2000, - }); - break; - } - } - } -} diff --git a/entry/src/main/ets/models/PhotoItem.ets b/entry/src/main/ets/models/PhotoItem.ets deleted file mode 100644 index 8099bc5..0000000 --- a/entry/src/main/ets/models/PhotoItem.ets +++ /dev/null @@ -1,107 +0,0 @@ -import { photoAccessHelper as PHelper } from '@kit.MediaLibraryKit'; -import { CreatePath, Logger, UploadFile } from 'nextcloud'; -import { promptAction } from '@kit.ArkUI'; -import { TimeToPath } from '../utils/Utils'; -import { image } from '@kit.ImageKit'; - -const TAG = 'PhotoItem'; - -export enum UploadStatus { - None, - Uploading, - Uploaded, - Failed, - Existed, -} - -export enum UploadResult { - FAILED, - EXISTED, - SUCCEEDED, -} - -@Observed -export class PhotoItem { - uploadStatus: UploadStatus = UploadStatus.None; - private innerAsset: PHelper.PhotoAsset; - - constructor(asset: PHelper.PhotoAsset) { - this.innerAsset = asset; - } - - get displayName(): string { - return this.innerAsset.displayName ?? ''; - } - - get uri(): string { - return this.innerAsset.uri ?? ''; - } - - get dateTaken(): Date { - return new Date(this.innerAsset.get(PHelper.PhotoKeys.DATE_TAKEN_MS) as number); - } - - get dateTakenMs(): number { - return this.innerAsset.get(PHelper.PhotoKeys.DATE_TAKEN_MS) as number; - } - - get size(): number { - return this.innerAsset.get(PHelper.PhotoKeys.SIZE) as number ?? 0; - } - - get photoType(): PHelper.PhotoType { - return this.innerAsset.get(PHelper.PhotoKeys.PHOTO_TYPE) as PHelper.PhotoType; - } - - get coverPosition(): number { - return this.innerAsset.get(PHelper.PhotoKeys.COVER_POSITION) as number; - } - - get isVideo(): boolean { - return this.photoType === PHelper.PhotoType.VIDEO; - } - - async getThumbnail(size?: image.Size): Promise { - return await this.innerAsset.getThumbnail(size); - } - - async upload(targetPath: string): Promise { - this.uploadStatus = UploadStatus.Uploading; - let path = targetPath + TimeToPath(this.dateTaken); - let mkdirResult = await CreatePath(path); - if (mkdirResult !== 201 && mkdirResult !== 409 && mkdirResult !== 405) { - // 405 is the status code for "already exists" - Logger.Error(TAG, `Failed to create path: ${path}`); - promptAction.showToast({ - message: `Failed to create path: ${path}: ${mkdirResult}`, - duration: 2000, - }); - } - - let ret: UploadResult = UploadResult.FAILED; - - let message = ""; - let fd = await this.innerAsset.getReadOnlyFd(); - let uploadResult = await UploadFile(this.displayName, this.dateTakenMs, path, fd); - let uploadResult = await UploadFile(this.displayName, this.dateTakenMs, path, this.uri); - if (uploadResult !== 201 && uploadResult !== 204) { - message = `Failed: ${this.displayName}: ${uploadResult}`; - this.uploadStatus = UploadStatus.Failed; - ret = UploadResult.FAILED; - } else if (uploadResult === 204) { - message = `File Existed: ${this.displayName}`; - this.uploadStatus = UploadStatus.Existed; - ret = UploadResult.EXISTED; - } else { - message = `Uploaded: ${this.displayName}`; - this.uploadStatus = UploadStatus.Uploaded; - ret = UploadResult.SUCCEEDED; - } - - Logger.Info(TAG, `upload result: "${message}"`); - if (ret !== UploadResult.EXISTED) { - promptAction.showToast({ message, duration: 2000 }); - } - return ret; - } -} diff --git a/entry/src/main/ets/pages/FolderPage.ets b/entry/src/main/ets/pages/FolderPage.ets index 13b5080..cff6539 100644 --- a/entry/src/main/ets/pages/FolderPage.ets +++ b/entry/src/main/ets/pages/FolderPage.ets @@ -1,19 +1,18 @@ -import { DirectoryEntry, FilesDataSource, Logger, NCommon } from 'nextcloud'; +import { Account, DirectoryEntry, FilesDataSource, Logger } from 'nextcloud'; import { Path } from '../utils/Path'; import { PREVIEW_PAGE_NAME, PreviewPageProps } from './PreviewPage'; -import { LengthMetrics, promptAction } from '@kit.ArkUI'; +import { LengthMetrics } from '@kit.ArkUI'; import { FormatBytes } from '../utils/Utils'; -import { Constants, FolderViewMode } from '../utils/Common'; const TAG = "Page-FolderPage"; export let FOLDER_PAGE_NAME: string = "FolderPage"; -@Component +@ComponentV2 export struct FolderPage { - @StorageLink(Constants.VIEW_MODE_KEY) viewMode: FolderViewMode = FolderViewMode.TwoColumns; - @Consume('FolderList.PathStack') pageInfos: NavPathStack; - @Prop currentPath: Path; + @Require @Param account: Account; + @Require @Param currentPath: Path; + @Consumer('FolderList.PathStack') pageInfos: NavPathStack = new NavPathStack(); static formatSubtitle(mime: string, size: string) { let mimeParts = mime.split('/'); @@ -24,15 +23,27 @@ export struct FolderPage { let pathInfo: NavPathInfo; Logger.Info(TAG, JSON.stringify(entry)); if (entry.isDirectory) { - pathInfo = new NavPathInfo(FOLDER_PAGE_NAME, this.currentPath.getChild(entry.name)); + let props: FolderPageProps = { + account: this.account, + currentPath: this.currentPath.getChild(entry.name), + }; + pathInfo = new NavPathInfo(FOLDER_PAGE_NAME, props); } else if (entry.has_preview) { - let props: PreviewPageProps = { entry: entry, fullPath: this.currentPath.getChild(entry.name) }; + let props: PreviewPageProps = { + account: this.account, + entry: entry, + fullPath: this.currentPath.getChild(entry.name), + }; pathInfo = new NavPathInfo(PREVIEW_PAGE_NAME, props); } else { - promptAction.showToast({ - message: `No preview available for ${entry.name}`, - duration: 2000, - }); + try { + this.getUIContext().getPromptAction().showToast({ + message: `No preview available for ${entry.name}`, + duration: 2000, + }); + } catch (error) { + Logger.Error(TAG, `Failed to show toast: ${JSON.stringify(error)}`); + } return; } @@ -91,12 +102,12 @@ export struct FolderPage { build() { NavDestination() { List({ space: 5 }) { - LazyForEach(new FilesDataSource(this.currentPath.path()), (d: DirectoryEntry) => { + LazyForEach(new FilesDataSource(this.account, this.currentPath.path()), (d: DirectoryEntry) => { ListItem() { Button({ type: ButtonType.Normal }) { Row() { if (d.has_preview) { - Image(NCommon.MakePreviewUrl(d.fileId)) + Image(this.account.getPreviewUrl(d.fileId)) .width(32) .height(32) .margin({ right: 10 }) @@ -121,10 +132,10 @@ export struct FolderPage { .fontStyle(FontStyle.Italic) } .alignItems(HorizontalAlign.Start) - .width(this.viewMode == 1 ? '75%' : '70%') + .width('70%') } } - .backgroundColor('#00000000') + .backgroundColor(Color.Transparent) .borderRadius(0) .onClick(async () => { await this.onItemClicked(d); @@ -136,7 +147,7 @@ export struct FolderPage { }) } .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(80) }) - .lanes(this.viewMode) + .lanes(2) .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) .width('auto') .height('auto') @@ -147,7 +158,12 @@ export struct FolderPage { } } +export interface FolderPageProps { + account: Account; + currentPath: Path; +} + @Builder -export function FolderPageBuilder(_name: string, currentPath: Path) { - FolderPage({ currentPath }); +export function FolderPageBuilder(_name: string, props: FolderPageProps) { + FolderPage({ account: props.account, currentPath: props.currentPath }); } diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 8b15365..57a50c1 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -1,23 +1,30 @@ -import { Logger, NCommon } from 'nextcloud'; +import { Account, Logger, Nextcloud } from 'nextcloud'; import { App, promptAction } from '@kit.ArkUI'; import { Path } from '../utils/Path'; import { UploadPage } from './UploadPage'; import { LoginPage } from './LoginPage'; import { SettingsPage } from './SettingsPage'; import { FolderPage } from './FolderPage'; -import { Constants, FolderViewMode } from '../utils/Common'; +import { PermissionManager } from '../utils/PermissionManager'; +import { PhotoAssetsManager } from 'nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager'; +import { PhotoMonitor } from '../utils/PhotoMonitor'; +import { ShowToast } from '../utils/Utils'; +import { Permissions } from '@kit.AbilityKit'; +import { TransferProgress } from './TransferProgress'; const TAG = "Page-Index"; const EXIT_TOAST_DURATION: number = 2000; @Entry -@Component +@ComponentV2 struct Index { - @State currentIndex: number = 0; - @StorageLink(Constants.VIEW_MODE_KEY) viewMode: FolderViewMode = FolderViewMode.TwoColumns; - @Provide('FolderList.PathStack') pathStack: NavPathStack = new NavPathStack(); - @State private loginPageDialogId: number = 0; + @Local currentIndex: number = 0; + // @StorageLink(Constants.VIEW_MODE_KEY) viewMode: FolderViewMode = FolderViewMode.TwoColumns; + @Provider('FolderList.PathStack') pathStack: NavPathStack = new NavPathStack(); + private readonly activeAccountId: number = 0; + @Local private readonly account: Account = Nextcloud.getAccountById(this.activeAccountId); + @Local private loginPageDialogId: number = 0; private backedOnce: boolean = false; onBackPress(): boolean { @@ -38,32 +45,34 @@ struct Index { this.backedOnce = false; }, EXIT_TOAST_DURATION); - try { - this.getUIContext().getPromptAction().showToast({ - message: $r('app.string.swipe_back_again_to_exit'), - duration: EXIT_TOAST_DURATION, - }); - } catch (error) { - Logger.Error(TAG, `Failed to show toast?`); - } + ShowToast(this.getUIContext(), $r('app.string.swipe_back_again_to_exit'), EXIT_TOAST_DURATION); return true; } } - aboutToAppear(): void { + async aboutToAppear() { Logger.Info(TAG, `aboutToAppear`); + let context = this.getUIContext(); + const PERMISSIONS: Permissions[] = [ + 'ohos.permission.READ_IMAGEVIDEO', + 'ohos.permission.MEDIA_LOCATION', + ]; + let result = await PermissionManager.RequestPermission(context, PERMISSIONS); + Logger.Info(TAG, `Request permission result: ${result}`); + PhotoMonitor.beginMonitorPhotoChange(); } async doLogin() { + let context = this.getUIContext(); + let options: promptAction.CustomDialogOptions = { + builder: () => { + return this.LoginPageDialog(); + }, + isModal: true, + autoCancel: false, + }; + try { - let context = this.getUIContext(); - let options: promptAction.CustomDialogOptions = { - builder: () => { - this.LoginPageDialog() - }, - isModal: true, - autoCancel: false, - }; this.loginPageDialogId = await context.getPromptAction().openCustomDialog(options); Logger.Info(TAG, `done`); } catch (error) { @@ -76,7 +85,7 @@ struct Index { App.setImageRawDataCacheSize(1024 * 1024 * 100); // 100MB App.setImageFileCacheSize(1024 * 1024 * 500); // 100MB - if (!NCommon.Preference.hasLogon()) { + if (!Nextcloud.hasAccounts()) { this.doLogin(); } } @@ -86,41 +95,47 @@ struct Index { TabContent() { Flex({ direction: FlexDirection.Column }) { Navigation(this.pathStack) { - FolderPage({ currentPath: new Path() }) + FolderPage({ account: this.account, currentPath: new Path() }) } .navBarWidth(0) .mode(NavigationMode.Stack) + } + } + .tabBar(SubTabBarStyle.of($r("app.string.tab_files")) + .labelStyle({ selectedColor: 'white', unselectedColor: 'white' }) + .board({ borderRadius: 0 }) + ) - Row({ space: 10 }) { - Button(FolderViewMode[this.viewMode]) - .onClick(() => { - this.viewMode = (this.viewMode % 3) + 1; - }) - .width(100) - } - .width('100%') - .alignItems(VerticalAlign.Center) - .justifyContent(FlexAlign.Center) - .height(60) - .margin({ top: 10, bottom: 10 }) - } + TabContent() { + UploadPage({ account: this.account }) + .width('100%') + .height('100%') } - .tabBar($r("app.string.tab_files")) + .tabBar(SubTabBarStyle.of($r('app.string.tab_upload')) + .labelStyle({ selectedColor: 'white', unselectedColor: 'white' }) + .board({ borderRadius: 0 }) + ) TabContent() { - UploadPage() + TransferProgress() .width('100%') .height('100%') } - .tabBar($r('app.string.tab_upload')) + .tabBar(SubTabBarStyle.of('Transfer') + .labelStyle({ selectedColor: 'white', unselectedColor: 'white' }) + .board({ borderRadius: 0 }) + ) TabContent() { - SettingsPage() + SettingsPage({ currentAccount: this.account }) .width('100%') .height('100%') } - .tabBar($r('app.string.tab_settings')) + .tabBar(SubTabBarStyle.of($r('app.string.tab_settings')) + .labelStyle({ selectedColor: 'white', unselectedColor: 'white' }) + .board({ borderRadius: 0 }) + ) } .onChange((index: number) => { this.currentIndex = index; @@ -128,6 +143,10 @@ struct Index { .width('100%') .height('100%') .scrollable(false) + .linearGradient({ + angle: 45, + colors: [[0x0082C9, 0], [0x1CAFFF, 1]] + }) } @Builder diff --git a/entry/src/main/ets/pages/LoginPage.ets b/entry/src/main/ets/pages/LoginPage.ets index ed22796..59a8771 100644 --- a/entry/src/main/ets/pages/LoginPage.ets +++ b/entry/src/main/ets/pages/LoginPage.ets @@ -1,64 +1,52 @@ -import { LoginProcess, NCommon } from 'nextcloud'; -import { LengthMetrics, promptAction } from '@kit.ArkUI'; -import { Logger } from 'nextcloud/src/main/ets/Logger'; +import { Logger, LoginFlow, Nextcloud } from 'nextcloud'; +import { LengthMetrics } from '@kit.ArkUI'; import { webview } from '@kit.ArkWeb'; +import { ShowAlertDialog, ShowToast } from '../utils/Utils'; -const TAG = 'pages/LoginPage' +const TAG = 'Pages/LoginPage'; -@Preview -@Component +@ComponentV2 export struct LoginPage { - @Link public dialogId: number; + @Require @Param public dialogId: number; private webController: webview.WebviewController = new webview.WebviewController(); - private loginProcess = new LoginProcess(); - @State private loginStarted: boolean = false; - - async doLogin() { - if (!this.loginProcess.hasAppPassword()) { - Logger.Debug(TAG, "No app password, start login process"); - try { - await webview.WebCookieManager.clearAllCookies(); - let initResult = await this.loginProcess.initLogin(); - if (!initResult.success) { - Logger.Error(TAG, `Login failed: ${initResult.errorMsg}`); - this.getUIContext().showAlertDialog({ - title: "Login Failed", - message: `Login failed with domain: '${NCommon.Preference.hostname}', ${initResult.errorMsg}`, - }); - return; - } + private loginFlow = new LoginFlow(); + @Local private hostname: string = ''; + @Local private loginStarted: boolean = false; - this.loginStarted = true; - this.webController.loadUrl(initResult.loginUrl); - Logger.Info(TAG, "Successfully opened login url"); - - let result = await this.loginProcess.loginPollRepeatedly(); - Logger.Info(TAG, "Login result: " + JSON.stringify(result)); - - if (result.cancelled) { - this.getUIContext().showAlertDialog({ - title: "Login Failed", - message: "Operation Cancelled", - }); - } else { - this.getUIContext().showAlertDialog({ - title: "Welcome to use NextCloud", - message: "Welcome, " + result.loginName, - }); - this.closeDialog(); - } - } catch (e) { - this.getUIContext().showAlertDialog({ - title: "Login failed", - message: JSON.stringify(e), - }) + async doLogin(): Promise { + await webview.WebCookieManager.clearAllCookies(); + let initResult = await this.loginFlow.initLogin(this.hostname); + if (initResult.kind === 'error') { + Logger.Error(TAG, `Login failed: ${initResult.error}`); + ShowAlertDialog(this.getUIContext(), "Login Failed", `Login failed with domain: '${this.hostname}', ${initResult.error}`); + return; + } + + this.startUserLoginProcess(initResult.data); + + let loginResult = await this.loginFlow.loginPollRepeatedly(); + switch (loginResult.kind) { + case 'ok': { + await Nextcloud.addAccount(loginResult.account); + ShowAlertDialog(this.getUIContext(), "Login Successful", `Welcome, ${loginResult.account.username}!`); + this.closeDialog(); + break; } - } else { - Logger.Info(TAG, "Already have app password, skip login process"); - this.getUIContext().showAlertDialog({ - title: "Welcome", - message: NCommon.Preference.username, - }); + case 'cancelled': { + ShowAlertDialog(this.getUIContext(), "Login Failed", "Operation Cancelled"); + break; + } + } + } + + startUserLoginProcess(loginUrl: string) { + try { + this.webController.loadUrl(loginUrl); + Logger.Info(TAG, "Successfully opened login url"); + this.loginStarted = true; + } catch (error) { + ShowAlertDialog(this.getUIContext(), "Login Failed", `Failed to open login url: ${JSON.stringify(error)}`); + Logger.Error(TAG, `Failed to open login url: ${JSON.stringify(error)}`); } } @@ -80,13 +68,9 @@ export struct LoginPage { .backgroundColor(Color.Red) .height(32) .onClick(() => { - this.loginProcess.cancelPolling(); + this.loginFlow.cancelPolling(); this.loginStarted = false; - NCommon.Preference.password = ""; - this.getUIContext().getPromptAction().showToast({ - message: "Logged out", - duration: 2000, - }); + ShowToast(this.getUIContext(), "Cancelled", 2000); }) .alignRules({ left: { anchor: '__container__', align: HorizontalAlign.Start }, @@ -125,11 +109,9 @@ export struct LoginPage { if (!this.loginStarted) { Text('Type the URL of your Nextcloud instance:') .margin({ left: 24, right: 24, bottom: 8 }) + Flex({ alignItems: ItemAlign.Center, direction: FlexDirection.Row, space: { main: LengthMetrics.vp(8) } }) { - TextInput({ placeholder: 'https://nextcloud.com', text: NCommon.Preference.hostname }) - .onChange((value: string) => { - NCommon.Preference.hostname = value; - }) + TextInput({ placeholder: 'https://nextcloud.com', text: this.hostname!! }) .flexGrow(1) Button({ type: ButtonType.Circle }) { @@ -162,3 +144,16 @@ export struct LoginPage { this.getUIContext().getPromptAction().closeCustomDialog(this.dialogId); } } + + +@Preview +@Component +struct LoginPagePreview { + @State private dialogId: number = 0; + + build() { + LoginPage({ dialogId: this.dialogId }) + .width(300) + .height(500) + } +} diff --git a/entry/src/main/ets/pages/PcMainPage.ets b/entry/src/main/ets/pages/PcMainPage.ets new file mode 100644 index 0000000..7ca8d33 --- /dev/null +++ b/entry/src/main/ets/pages/PcMainPage.ets @@ -0,0 +1,332 @@ +import { + Account, + DirectoryEntry, + JobManager, + ListFiles, + Logger, + Nextcloud, + SyncConfiguration, + SyncFolderManager, + TransferJob +} from 'nextcloud'; +import { common } from '@kit.AbilityKit'; +import { fileIo, fileShare, fileUri, picker } from '@kit.CoreFileKit'; +import * as CloudSyncApi from '../utils/CloudSyncApi'; +import { PermissionManager } from '../utils/PermissionManager'; +import { LengthMetrics } from '@kit.ArkUI'; +import { DownloadQueue } from '../utils/DownloadQueue'; +import { SettingsPage } from './SettingsPage'; +import { UploadPage } from './UploadPage'; +import { emitter } from '@kit.BasicServicesKit'; +import { Constants } from '../utils/Common'; +import { PhotoMonitor } from '../utils/PhotoMonitor'; +import { TransferProgress } from './TransferProgress'; + +const TAG = 'PcAbility' +const OPERATION_MODES = fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE; + +@ComponentV2 +struct SyncConfigurationRow { + @Require @Param readonly syncFolderManager: SyncFolderManager; + @Require @Param readonly configuration: SyncConfiguration; + @Require @Param readonly account: Account; + + build() { + Row({ space: 8 }) { + Text( + `${this.configuration.id} : ${this.configuration.localFolder.localPath} <=> ${this.configuration.remoteFolder.remotePath}` + ) + + Blank() + .flexGrow(1) + + Button('connect') + .onClick(async () => { + CloudSyncApi.connect(this.configuration.localFolder.localPath); + }) + + Button('disconnect') + .onClick(() => { + CloudSyncApi.disconnect(this.configuration.localFolder.localPath); + }) + + Button('sync all') + .onClick(() => this.doDownloadAll()) + + Button('DELETE') + .onClick(async () => { + CloudSyncApi.disconnect(this.configuration.localFolder.localPath); + CloudSyncApi.remove(this.configuration.localFolder.localPath); + await this.syncFolderManager.removeSyncConfiguration(this.configuration); + }) + } + .width('100%') + .height(50) + .padding({ left: 8, right: 8 }) + .border({ width: 1, color: Color.Green }) + } + + private async doDownloadAll() { + Logger.Info(TAG, `sync all`); + + const downloadQueue: DownloadQueue = new DownloadQueue(); + downloadQueue.addRootDir(this.account); + while (downloadQueue.hasPendingItem()) { + let next = await downloadQueue.getNextDownloadFile(); + if (next === undefined) { + break; + } + Logger.Info(TAG, next.relativePath); + if (next.isDirectory) { + try { + fileIo.mkdirSync(this.configuration.localFolder.localPath + '/' + next.relativePath); + } catch (e) { + } + } else { + const fileName = next.relativePath.split('/').pop() || ''; + let transferJob = JobManager.addDownloadJob({ + fileName: fileName, + localFolder: this.configuration.localFolder, + remoteFolder: this.configuration.remoteFolder, + relativePath: next.relativePath + }); + transferJob.start(); + break; + } + Logger.Info(TAG, next.relativePath); + } + Logger.Info(TAG, `download completed`); + } +} + +@Entry +@ComponentV2 +struct PCAbility { + @Local private readonly activeAccountId: number = 0; // TODO: get from PersistentStorage + @Local private readonly account: Account = Nextcloud.getAccountById(this.activeAccountId); + @Local private opHistory: CloudSyncApi.FileChangeInfo[] = []; + @Local private files: DirectoryEntry[] = []; + @Local private syncFolderManager: SyncFolderManager = Nextcloud.getSyncFolderManager(); + private pendingMoveTo: Map = new Map(); + + @Builder + pathOps() { + Column() { + List({ space: 4 }) { + ListItem() { + Button('+') + .onClick(async () => { + await this.doAddNewSyncConfiguration(); + }) + .width('100%') + } + + ForEach( + Array.from(this.syncFolderManager.allSyncConfigurations), + ( + conf: [number, SyncConfiguration], + _index: number) => { + ListItem() { + SyncConfigurationRow({ + account: this.account, + syncFolderManager: this.syncFolderManager, + configuration: conf[1], + }) + } + .clip(true) + }) + } + .width('100%') + .height('100%') + } + .width('100%') + .height('100%') + .padding(16) + } + + @Builder + pcMainPage() { + Column({ space: 16 }) { + Column({ space: 8 }) { + List() { + ForEach(this.files, (item: DirectoryEntry, index: number) => { + ListItem() { + Text(`${index}: ${item.name} --- ${item.isDirectory ? 'dir' : item.mime}`) + } + .clip(true) + }) + } + .width('100%') + .height(150) + + Button('reload') + .onClick(async () => { + this.files = await ListFiles(this.account, '/'); + }) + + } + .padding(8) + .width('100%') + .backgroundColor($r('sys.color.glass_material_outline_secondary')) + + Column({ space: 8 }) { + Text('download progress') + + TransferProgress() + .width('100%') + .height(250) + } + + Column({ space: 8 }) { + Text('Operation History:') + List() { + ForEach(this.opHistory, (item: CloudSyncApi.FileChangeInfo, index) => { + ListItem() { + Row() { + Text(`${index + 1}. `) + Text( + `: ${item.relativePath}, Op: ${CloudSyncApi.FileChangeOperationType[item.operationType]}` + ) + } + } + }) + } + .width('100%') + .height(250) + } + .padding(8) + .backgroundColor($r('sys.color.glass_material_outline_secondary')) + } + .width('100%') + .height('100%') + .padding(16) + } + + async aboutToAppear(): Promise { + Logger.Info(TAG, `aboutToAppear`); + await PermissionManager.RequestPermission(this.getUIContext(), [ + 'ohos.permission.FILE_ACCESS_PERSIST', + 'ohos.permission.READ_IMAGEVIDEO', + 'ohos.permission.MEDIA_LOCATION', + ]); + + for (let conf of this.syncFolderManager.allSyncConfigurations.values()) { + Logger.Info(TAG, `conf: ${conf.id} - ${conf.localFolder.localPath} <=> ${conf.remoteFolder.remotePath}`); + CloudSyncApi.connect(conf.localFolder.localPath); + + try { + await fileShare.activatePermission([{ + uri: fileUri.getUriFromPath(conf.localFolder.localPath), + operationMode: OPERATION_MODES + }]); + Logger.Info(TAG, `activate permission done for '${conf.localFolder.localPath}'`); + } catch (e) { + Logger.Error(TAG, `failed to activate: ${JSON.stringify(e)}`); + } + } + + CloudSyncApi.registerFileChangeMonitor((info: CloudSyncApi.FileChangeInfo) => { + this.onFileChanged(info) + }); + + emitter.on(Constants.EMITTER_EVENT_NOTIFICATION_ON_IDLE, async () => { + for (let i = 0; i < 10; i++) { + await ListFiles(this.account, '/'); + } + }); + this.files = await ListFiles(this.account, '/'); + } + + aboutToDisappear(): void { + for (let syncConfiguration of this.syncFolderManager.allSyncConfigurations.values()) { + CloudSyncApi.disconnect(syncConfiguration.localFolder.localPath); + } + } + + build() { + Tabs({ barPosition: BarPosition.Start }) { + TabContent() { + this.pathOps() + } + .tabBar('Paths') + + TabContent() { + UploadPage({ account: this.account }) + .width('100%') + .height('100%') + } + .tabBar('Upload') + + TabContent() { + this.pcMainPage() + } + .tabBar($r('app.string.tab_files')) + + TabContent() { + SettingsPage({ currentAccount: this.account }) + .width('100%') + .height('100%') + } + .tabBar($r('app.string.tab_settings')) + } + .vertical(true) + } + + private async doAddNewSyncConfiguration() { + let context = this.getUIContext().getHostContext() as common.UIAbilityContext; + let documentPicker = new picker.DocumentViewPicker(context); + let items = await documentPicker.select({ + defaultFilePathUri: 'file://docs/storage/Users/currentUser/CloudSyncFolder', + maxSelectNumber: 1, + selectMode: picker.DocumentSelectMode.FOLDER, + }); + if (items.length !== 1) { + Logger.Error(TAG, `???`); + return; + } + let path = new fileUri.FileUri(items[0]).path; + + try { + Logger.Info(TAG, items[0]); + await fileShare.persistPermission([{ + uri: items[0], + operationMode: OPERATION_MODES + }]); + Logger.Info(TAG, `persist done`); + } catch (e) { + Logger.Error(TAG, `failed to persist permission: ${JSON.stringify(e)}`); + } + + let localPathAlias = 'LocalPathAlias_' + Date.now(); + let localFolder = await this.syncFolderManager.addLocalFolder(path, localPathAlias); + Logger.Info(TAG, `added local folder: ${JSON.stringify(localFolder)}`); + let remoteFolder = + await this.syncFolderManager.addRemoteFolder( + this.account, + '/', + 'RemotePathAlias_' + + Date.now() + ); + Logger.Info(TAG, `added remote folder: ${JSON.stringify(remoteFolder)}`); + let syncConfig = await this.syncFolderManager.addSyncConfiguration(localFolder, remoteFolder); + Logger.Info(TAG, `added sync configuration: ${JSON.stringify(syncConfig)}`); + let addResult: number = CloudSyncApi.add(localPathAlias, path); + Logger.Info(TAG, `Add: ${addResult}`) + } + + private async onFileChanged(info: CloudSyncApi.FileChangeInfo): Promise { + if (info.operationType === CloudSyncApi.FileChangeOperationType.MOVE_FROM) { + this.pendingMoveTo.set(info.fileId, info.relativePath); + } else if (info.operationType === CloudSyncApi.FileChangeOperationType.MOVE_TO) { + let moveFrom = this.pendingMoveTo.get(info.fileId); + if (moveFrom === undefined) { + Logger.Error(TAG, `got a MoveTo without a MoveFrom`); + } else { + Logger.Info(TAG, `move: ${moveFrom} -> ${info.relativePath}`); + this.pendingMoveTo.delete(info.fileId); + } + } + Logger.Info(TAG, `${CloudSyncApi.FileChangeOperationType[info.operationType]} ::: '${info.relativePath}'`); + this.opHistory = [info, ...this.opHistory]; + } +} diff --git a/entry/src/main/ets/pages/PreviewPage.ets b/entry/src/main/ets/pages/PreviewPage.ets index c965bc7..0f8fe12 100644 --- a/entry/src/main/ets/pages/PreviewPage.ets +++ b/entry/src/main/ets/pages/PreviewPage.ets @@ -1,31 +1,34 @@ -import { DirectoryEntry, Logger, NCommon } from 'nextcloud'; +import { Account, DirectoryEntry, Logger } from 'nextcloud'; import { Path } from '../utils/Path'; const TAG: string = "Page-PreviewPage"; export let PREVIEW_PAGE_NAME: string = "PreviewPage"; -@Component + +@ComponentV2 struct PreviewPage { - @Prop entry: DirectoryEntry; - @Prop fullPath: Path = new Path(); - @State private previewLoaded: boolean = false; - @State private previewLink: string = NCommon.MakePreviewUrl(this.entry.fileId, 100, 100); - @State @Watch('onPreviewLevelChanged') private previewLevel: number = 0; - @State private failedToLoad: boolean = false; - @State private failedReason: string = ""; - @State private title: string = ""; + @Require @Param account: Account; + @Require @Param entry: DirectoryEntry; + @Require @Param fullPath: Path = new Path(); + @Local private previewLoaded: boolean = false; + @Local private previewLink: string = this.account.getPreviewUrl(this.entry.fileId, 100, 100); + @Local private previewLevel: number = 0; + @Local private failedToLoad: boolean = false; + @Local private failedReason: string = ""; + @Local private title: string = ""; // Zoom and Pan states - @State private imageScale: number = 1; - @State private offsetX: number = 0; - @State private offsetY: number = 0; - @State private eventOffsetX: number = 0; - @State private eventOffsetY: number = 0; - @State private previousScale: number = 1; + @Local private imageScale: number = 1; + @Local private offsetX: number = 0; + @Local private offsetY: number = 0; + @Local private eventOffsetX: number = 0; + @Local private eventOffsetY: number = 0; + @Local private previousScale: number = 1; // private startPinchX: number = 0; private startPinchY: number = 0; + @Monitor('previewLevel') onPreviewLevelChanged() { Logger.Info(TAG, "Preview level: " + this.previewLevel); if (this.previewLevel == 4) { @@ -48,9 +51,9 @@ struct PreviewPage { if (this.previewLevel == 2) { Logger.Info(TAG, "Preview image loaded, start loading full image"); if (this.entry.mime.startsWith("image/")) { - this.previewLink = NCommon.MakeDownloadUrl(this.fullPath.path(false)); + this.previewLink = this.account.getDownloadUrl(this.fullPath.path(false)); } else { - this.previewLink = NCommon.MakePreviewUrl(this.entry.fileId, 1000, 1000); + this.previewLink = this.account.getPreviewUrl(this.entry.fileId, 1000, 1000); } } }) @@ -93,7 +96,7 @@ struct PreviewPage { this.eventOffsetY = (event.pinchCenterY - this.startPinchY) * this.previousScale; this.imageScale = this.previousScale * event.scale; }) - .onActionEnd((event: GestureEvent) => { + .onActionEnd((_event: GestureEvent) => { this.offsetX = (this.offsetX * this.imageScale + this.eventOffsetX) / this.imageScale; this.offsetY = (this.offsetY * this.imageScale + this.eventOffsetY) / this.imageScale; this.eventOffsetX = 0; @@ -126,9 +129,10 @@ struct PreviewPage { export interface PreviewPageProps { entry: DirectoryEntry; fullPath: Path; + account: Account; } @Builder export function PreviewPageBuilder(_name: string, props: PreviewPageProps) { - PreviewPage(props); + PreviewPage({ account: props.account, entry: props.entry, fullPath: props.fullPath }); } diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index f029467..24d252e 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -1,31 +1,31 @@ -import { Logger, NCommon } from 'nextcloud'; +import { + Account, Logger, NCommon, Nextcloud, LocalFolder, LocalFolderType, Category, + PhotoAssetsManager +} from 'nextcloud'; import { LengthMetrics } from '@kit.ArkUI'; +import application from '@ohos.app.ability.application'; +import { ShowToast } from '../utils/Utils'; const TAG = 'pages/Settings'; -@Observed -class EditableCategory implements NCommon.CategoryConfig { - name: string; - patterns: string[]; - location: string; - - constructor(other: NCommon.CategoryConfig) { - this.name = other.name; - this.patterns = other.patterns; - this.location = other.location; - } -} - @Component export struct SettingsPage { scroller: Scroller = new Scroller(); + @ObjectLink public currentAccount: Account; @State private username: string = ""; @State private hostname: string = ""; @State private uploadReplaceExisting: boolean = false; @State private uploadBaseDirectory: string = '/'; - @State private uploadCategories: EditableCategory[] = []; + private categories: Category[] = []; aboutToAppear(): void { + this.categories = Array.from(PhotoAssetsManager.categories.values()); + this.categories.sort((a: Category, b: Category): number => { + if (a.instantUploadEnabled !== b.instantUploadEnabled) { + return a.instantUploadEnabled ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) this.doLoadSettings(); } @@ -40,11 +40,12 @@ export struct SettingsPage { Scroll(this.scroller) { Flex({ direction: FlexDirection.Column }) { - Column({ space: 4 }) { + Column() { Text($r('app.string.login_info')) .width('100%') .fontSize($r('sys.float.subheader_title_font_size')) .fontWeight(FontWeight.Bold) + .margin({ bottom: 4 }) Row() { Text($r('app.string.username')) @@ -64,15 +65,19 @@ export struct SettingsPage { .width('100%') .height(24) } + .backgroundColor($r('sys.color.gray_02')) + .borderRadius(8) + .padding(12) Divider() .margin({ top: 16, bottom: 16 }) - Column({ space: 4 }) { + Column() { Text($r('app.string.upload')) .width('100%') .fontWeight(FontWeight.Bold) .fontSize($r('sys.float.subheader_title_font_size')) + .margin({ bottom: 4 }) Row() { Text($r('app.string.upload_base_directory')) @@ -100,45 +105,55 @@ export struct SettingsPage { .width('100%') .height(24) } + .backgroundColor($r('sys.color.gray_02')) + .borderRadius(8) + .padding(12) Divider() .margin({ top: 16, bottom: 16 }) - Column({ space: 4 }) { + Column({ space: 8 }) { Text($r('app.string.categories')) .width('100%') .fontWeight(FontWeight.Bold) .fontSize($r('sys.float.subheader_title_font_size')) + .margin({ bottom: 4 }) - ForEach(this.uploadCategories, (category: EditableCategory, index: number) => { - Column({ space: 4 }) { + Repeat(this.categories) + .each((item: RepeatItem) => { Row() { - Text(category.name) - .fontWeight(FontWeight.Bold) - - Text(this.uploadBaseDirectory + category.location) - .fontSize(12) - .fontColor(Color.Gray) + Column() { + Text(item.item.name) + Text(`Album Id: ${item.item.localFolder.albumId}`) + .fontSize(10) + } + .alignItems(HorizontalAlign.Start) + + Row({ space: 8 }) { + Text('Upload') + Toggle({ type: ToggleType.Switch, isOn: item.item.instantUploadEnabled }) + .onChange((value: boolean) => { + item.item.instantUploadEnabled = value; + Nextcloud.getSyncFolderManager() + .updateLocalFolder(item.item.localFolder); + }) + .height('60px') + .width('107px') + } + .alignItems(VerticalAlign.Center) } .justifyContent(FlexAlign.SpaceBetween) .width('100%') - - ForEach(category.patterns, (pattern: string, patternIndex: number) => { - TextInput({ text: pattern }) - .fontSize(12) - .fontFamily('monospace') - .width('100%') - .onChange((content) => { - category.patterns[patternIndex] = content; - }) - }) - } - .width('100%') - .padding(8) - }) + .backgroundColor($r('sys.color.gray_03')) + .borderRadius(8) + .padding(12) + }) } + .padding(12) + .backgroundColor($r('sys.color.gray_02')) + .borderRadius(8) } - .margin({ left: 24, right: 24 }) + .margin({ left: 16, right: 16 }) } .align(Alignment.TopStart) .edgeEffect(EdgeEffect.Spring) @@ -147,25 +162,34 @@ export struct SettingsPage { Button($r('app.string.save_settings'), { stateEffect: true }) .onClick(() => { this.doSaveSettings(); - this.getUIContext().getPromptAction().showToast({ - message: 'settings saved' - }); + ShowToast(this.getUIContext(), 'settings saved'); }) .margin({ top: 16, bottom: 16 }) + + Button('DROP DATABASE') + .visibility(Visibility.None) + .backgroundColor(Color.Red) + .onClick(async () => { + await Nextcloud.recreateDatabase(); + try { + await application.getApplicationContext() + .killAllProcesses(); + } catch (error) { + Logger.Error(TAG, `failed to kill all processes: ${JSON.stringify(error)}`); + } + }) } + .background($r('sys.color.background_primary')) .height('100%') + .padding({ bottom: 16 }) } private doLoadSettings() { if (NCommon.Preference) { - this.username = NCommon.Preference.username; - this.hostname = NCommon.Preference.hostname; + this.username = this.currentAccount.username; + this.hostname = this.currentAccount.hostname; this.uploadReplaceExisting = NCommon.Preference.replaceExistingFiles; this.uploadBaseDirectory = NCommon.Preference.baseUploadDirectory; - this.uploadCategories = []; - for (let c of NCommon.Preference.categories) { - this.uploadCategories.push(new EditableCategory(c)); - } } else { Logger.Error(TAG, `failed to load settings, preference is undefined`); } @@ -173,11 +197,8 @@ export struct SettingsPage { private doSaveSettings() { if (NCommon.Preference) { - NCommon.Preference.username = this.username; - NCommon.Preference.hostname = this.hostname; NCommon.Preference.replaceExistingFiles = this.uploadReplaceExisting; NCommon.Preference.baseUploadDirectory = this.uploadBaseDirectory; - NCommon.Preference.categories = this.uploadCategories; } else { Logger.Error(TAG, `failed to save settings, preference is undefined`); } diff --git a/entry/src/main/ets/pages/TransferProgress.ets b/entry/src/main/ets/pages/TransferProgress.ets new file mode 100644 index 0000000..5b67ee3 --- /dev/null +++ b/entry/src/main/ets/pages/TransferProgress.ets @@ -0,0 +1,61 @@ +import { JobManager, TransferJob } from 'nextcloud'; +import { ShowToast } from '../utils/Utils'; + +@ComponentV2 +export struct TransferProgress { + @Builder + transferProgressItem(job: TransferJob) { + Column() { + Text(job.filename) + .textOverflow({ overflow: TextOverflow.MARQUEE }) + .textAlign(TextAlign.Start) + .width('100%') + + Row({ space: 16 }) { + Progress({ + value: job.transferredSize, + total: job.totalSize, + }) + .flexShrink(1) + + Text(job.progressPercentText) + .width(60) + .textAlign(TextAlign.End) + } + .width('100%') + } + .width('100%') + .borderRadius(16) + .padding(8) + .backgroundColor($r('sys.color.gray_02')) + } + + build() { + Column({ space: 16 }) { + List({ space: 8 }) { + LazyForEach(JobManager.tasks, (item: TransferJob) => { + ListItem() { + this.transferProgressItem(item) + } + }, (item: TransferJob, _index: number) => { + return `${item.id}` + }) + } + .width('100%') + .height('100%') + .flexShrink(1) + .flexGrow(1) + + Button('Clear') + .onClick(() => { + JobManager.tasks.clear(); + ShowToast(this.getUIContext(), `Cleared!`, 2000); + }) + .backgroundColor(Color.Red) + } + .padding(16) + .background($r('sys.color.background_primary')) + .width('100%') + .height('100%') + } +} diff --git a/entry/src/main/ets/pages/UploadPage.ets b/entry/src/main/ets/pages/UploadPage.ets index c87a65a..9f543ca 100644 --- a/entry/src/main/ets/pages/UploadPage.ets +++ b/entry/src/main/ets/pages/UploadPage.ets @@ -1,184 +1,282 @@ -import { Logger, NCommon } from 'nextcloud'; -import { PermissionManager } from '../utils/PermissionManager'; -import { AddAssetResult, Category } from '../models/Category'; -import { LengthMetrics } from '@kit.ArkUI'; -import { photoAccessHelper as PHelper } from '@kit.MediaLibraryKit'; -import { dataSharePredicates } from '@kit.ArkData'; -import { PhotoItem, UploadStatus } from '../models/PhotoItem'; +import { + Account, + Category, + Logger, + Nextcloud, + PhotoAssetsManager, + RemoteFolder, + PhotoItem, + UploadStatus, +} from 'nextcloud'; const TAG = "Page-UploadPage" +const PHOTO_PREVIEW_SIZE = 75; +async function getUploadRemoteFolder(account: Account): Promise { + let remoteFolderId = account.dbAccount.config.instantUploadFolderId; + let remoteFolder = await Nextcloud.getSyncFolderManager().getRemoteFolderById(remoteFolderId); + if (remoteFolder === null) { + Logger.Info(TAG, `creating instant upload remote folder`) + // not configured, create new + remoteFolder = await Nextcloud.getSyncFolderManager().addRemoteFolder(account, '/InstantUpload/', 'Instant Upload'); + remoteFolderId = remoteFolder.id; + account.dbAccount.config.instantUploadFolderId = remoteFolder.id; + await Nextcloud.updateAccount(account); + Logger.Info(TAG, `created instant upload remote folder`) + } + Logger.Info(TAG, `instant upload remote dir: ${remoteFolderId}`); + return remoteFolder!; +} -const FETCH_OPTIONS: PHelper.FetchOptions = { - fetchColumns: [ - PHelper.PhotoKeys.URI, - PHelper.PhotoKeys.PHOTO_TYPE, - PHelper.PhotoKeys.PHOTO_SUBTYPE, - PHelper.PhotoKeys.DISPLAY_NAME, - PHelper.PhotoKeys.DATE_TAKEN, - PHelper.PhotoKeys.DATE_ADDED_MS, - PHelper.PhotoKeys.SIZE, - PHelper.PhotoKeys.WIDTH, - PHelper.PhotoKeys.HEIGHT, - PHelper.PhotoKeys.TITLE, - PHelper.PhotoKeys.COVER_POSITION, - PHelper.PhotoKeys.DYNAMIC_RANGE_TYPE, - PHelper.PhotoKeys.DETAIL_TIME, - PHelper.PhotoKeys.DATE_TAKEN_MS, - ], - predicates: new dataSharePredicates.DataSharePredicates().orderByDesc(PHelper.PhotoKeys.DATE_TAKEN), -}; - - -@Component +@ComponentV2 struct PhotoView { - @ObjectLink asset: PhotoItem; - @Prop category: Category; - @State private thumbnail: PixelMap | null = null; + @Require @Param account: Account; + @Require @Param asset: PhotoItem; + @Require @Param category: Category; + @Local private thumbnail: PixelMap | null = null; + + getUploadColor(status: UploadStatus): Color { + switch (status) { + case UploadStatus.Completed: + return Color.Green; + case UploadStatus.Pending: + return Color.Pink; + case UploadStatus.Uploading: + return Color.Yellow; + case UploadStatus.Failed: + case UploadStatus.FailedCreatingPath: + return Color.Red; + case UploadStatus.None: + return Color.Gray; + case UploadStatus.Existed: + return Color.Blue; + default: + return Color.Gray; + } + } build() { Stack() { Image(this.thumbnail) - .height(100) - .width(100) + .height(PHOTO_PREVIEW_SIZE) + .width(PHOTO_PREVIEW_SIZE) .objectFit(ImageFit.Cover) Text(this.asset.isVideo ? '▶' : '') - .fontSize(48) + .fontSize(24) .fontFamily('monospace') .fontColor(Color.Red) } .draggable(false) - .height(100) - .width(100) + .height(PHOTO_PREVIEW_SIZE) + .width(PHOTO_PREVIEW_SIZE) .borderWidth(3) - .borderColor( - (this.asset.uploadStatus == UploadStatus.Uploaded) ? Color.Green : - (this.asset.uploadStatus == UploadStatus.Uploading) ? Color.Yellow : - (this.asset.uploadStatus == UploadStatus.Failed) ? Color.Red : - (this.asset.uploadStatus == UploadStatus.None) ? Color.Gray : - (this.asset.uploadStatus == UploadStatus.Existed) ? Color.Blue : Color.Gray) + .borderColor(this.getUploadColor(this.asset.uploadStatus)) .onClick(async () => { - await this.asset.upload(NCommon.Preference.baseUploadDirectory + this.category.location); + const remoteFolder = await getUploadRemoteFolder(this.account); + this.asset.getUploadJob(this.category.name, remoteFolder).start(); }) .onAppear(async () => { if (this.thumbnail === null) { - this.thumbnail = await this.asset.getThumbnail({ height: 150, width: 150 }); + this.thumbnail = await this.asset.getThumbnail({ height: 200, width: 200 }); } }) } } -@Component -struct PhotosGridView { - @ObjectLink category: Category; - private scroller: Scroller = new Scroller(); +@ComponentV2 +struct CategoryView { + @Require @Param account: Account; + @Require @Param category: Category; + @Local private isEditing: boolean = false; + @Local private isExpanded: boolean = false; + @Local private viewHeight: number = 56; - build() { - Stack() { - Grid(this.scroller) { - LazyForEach(this.category.visibleItems, (asset: PhotoItem, index: number) => { - GridItem() { - PhotoView({ asset: asset, category: this.category }) - } - .width('100%') - .onAppear(() => { - if (index == this.category.visibleItems.totalCount() - 1) { - this.category.showNext50(); - } - }) - }) + aboutToAppear(): void { + } + + @Builder + topBar() { + Row({ space: 16 }) { + Row({ space: 8 }) { + if (this.isEditing) { + TextInput({ text: this.category.name }) + .fontWeight(FontWeight.Bold) + .flexShrink(1) + .fontSize(16) + .borderRadius(0) + .onSubmit(async (enterKey: EnterKeyType, event: SubmitEvent) => { + if (event.text.length === 0) { + this.getUIContext() + .getPromptAction() + .showDialog({ + title: `Category Rename`, + message: `Category name cannot be empty!`, + }) + .catch((e: Error) => { + Logger.Error(TAG, `failed to show prompt dialog, ${JSON.stringify(e)}`); + }); + + event.keepEditableState(); + return; + } + Logger.Info(TAG, `category edit onSubmit: ${EnterKeyType[enterKey]}`) + this.category.setAlias(event.text); + Nextcloud.getSyncFolderManager().updateLocalFolder(this.category.localFolder); + }) + .onBlur(() => { + this.isEditing = false; + }) + } else { + Text(this.category.name) + .fontSize(24) + + SymbolGlyph($r('sys.symbol.doc_plaintext_and_pencil')) + .fontSize(12) + .fontColor([Color.Gray]) + .onClick(() => { + this.isEditing = true; + }) + } } - .fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(80) }) - .columnsTemplate('1fr 1fr 1fr 1fr') - .scrollBar(BarState.On) - .edgeEffect(EdgeEffect.Spring) - .columnsGap(8) - .rowsGap(8) - .cachedCount(10) - .width('100%') - .height('auto') + .flexShrink(1) + .height(36) - Column() { - Button($r('app.string.upload_all')) - .onClick(() => { - Logger.Info(TAG, `Upload all in '${this.category.name}'`); - this.category.uploadAllAssets(); - }) - .width('30%') + Row({ space: 16 }) { + Button() { + SymbolGlyph(this.isExpanded ? $r('sys.symbol.chevron_up') : $r('sys.symbol.chevron_down')) + .fontSize(24) + .fontColor([$r('sys.color.font')]) + } + .onClick(() => { + this.isExpanded = !this.isExpanded; + this.viewHeight = this.isExpanded ? 382 : 56; + }) + .padding(8) + .type(ButtonType.Circle) + .buttonStyle(ButtonStyleMode.TEXTUAL) + .backgroundColor($r('sys.color.gray_03')) } - .width('100%') - .position({ bottom: 5 }) } + .onClick(() => { + this.isExpanded = !this.isExpanded; + this.viewHeight = this.isExpanded ? 382 : 56; + }) + .justifyContent(FlexAlign.SpaceBetween) + .width('100%') } -} - - -@Preview -@Component -export struct UploadPage { - @State private categories: Category[] = []; - async aboutToAppear() { - let context = this.getUIContext().getHostContext()!; - let result1 = await PermissionManager.RequestPermission(context, 'ohos.permission.READ_IMAGEVIDEO'); - Logger.Info(TAG, `Request permission result: ${result1}`); - let result2 = await PermissionManager.RequestPermission(context, 'ohos.permission.MEDIA_LOCATION'); - Logger.Info(TAG, `Request permission result: ${result2}`); - - this.initializeCategoriesAndAssets(context); + @Builder + photoGrid() { + Grid() { + LazyForEach(this.category.visibleItems, (asset: PhotoItem, index: number) => { + GridItem() { + PhotoView({ asset: asset, category: this.category, account: this.account }) + .enabled(this.category.instantUploadEnabled) + } + .width('100%') + .onAppear(() => { + if (index == this.category.visibleItems.totalCount() - 1) { + this.category.loadNextBatch(); + } + }) + }) + } + .layoutDirection(GridDirection.Row) + .fadingEdge(true) + .columnsTemplate(`repeat(auto-fit, ${PHOTO_PREVIEW_SIZE})`) + .scrollBar(BarState.On) + .edgeEffect(EdgeEffect.Spring) + .columnsGap(8) + .rowsGap(8) + .cachedCount(10) + .width('100%') + .height('100%') + .margin({ bottom: 12 }) } build() { - Tabs() { - ForEach(this.categories, (category: Category) => { - TabContent() { - Flex({ direction: FlexDirection.Column, space: { main: LengthMetrics.px(20) } }) { - Flex({ direction: FlexDirection.Row, justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Baseline }) { - Text(category.name) - .fontWeight(FontWeight.Bold) - .fontSize(24) - - Text(category.location) - } - .height('32') + Column({ space: 8 }) { + // Top Bar Row + this.topBar() - ForEach(category.patterns, (pattern: string) => { - Text(pattern) - .fontSize(12) - .fontColor(Color.Green) - .fontFamily('serif') - }) + Blank() + .height(6) - PhotosGridView({ category: category }) - } - .width('100%') - .height('100%') + Stack() { + this.photoGrid(); + + Column() { + Button($r('app.string.upload_all')) + .onClick(async () => { + const remoteFolder = await getUploadRemoteFolder(this.account); + this.category.uploadAllAssets(remoteFolder); + }) + .backgroundColor(this.category.instantUploadEnabled ? '#0082C9' : 'gray') + .width('30%') } - .margin(10) - .tabBar(category.name) - }) + .enabled(this.category.instantUploadEnabled) + .width('100%') + .position({ bottom: 10 }) + } + .width('100%') + .height('100%') + .constraintSize({ maxHeight: 300, minHeight: 100 }) } + .borderRadius(16) + .padding({ + left: 12, + right: 12, + top: 8, + bottom: 8 + }) + .backgroundColor($r('sys.color.gray_02')) + .width('100%') + .height(this.viewHeight) + .renderFit(RenderFit.CENTER) + .clip(true) + .animation({ + duration: 250, + curve: Curve.Friction, + playMode: PlayMode.Normal, + }) } +} - private async initializeCategoriesAndAssets(context: Context) { - while (this.categories.length > 0) { - this.categories.pop(); - } - for (let c of NCommon.Preference.categories) { - this.categories.push(new Category(c)); - } +@Preview +@ComponentV2 +export struct UploadPage { + @Require @Param account: Account; + @Local private categories: Category[] = []; - let helper = PHelper.getPhotoAccessHelper(context); - let result = await helper.getAssets(FETCH_OPTIONS); - for (let asset of await result.getAllObjects()) { - let photoItem = new PhotoItem(asset); - for (let category of this.categories) { - if (category.tryAddAsset(photoItem) != AddAssetResult.Unmatched) { - break; - } + aboutToAppear(): void { + Logger.Info(TAG, `aboutToAppear, found ${PhotoAssetsManager.categories.size} categories`); + this.categories = Array.from(PhotoAssetsManager.categories.values()); + this.categories.sort((a: Category, b: Category): number => { + if (a.instantUploadEnabled !== b.instantUploadEnabled) { + return a.instantUploadEnabled ? -1 : 1; } + return a.name.localeCompare(b.name); + }) + } + + build() { + Column() { + List({ space: 10 }) { + Repeat(this.categories) + .each((item: RepeatItem) => { + if (item.item.instantUploadEnabled) { + CategoryView({ category: item.item, account: this.account }) + } + }) + } + .width('100%') + .height('auto') + .padding(16) + .flexShrink(1) } - result.close(); + .width('100%') + .height('100%') + .background($r('sys.color.background_primary')) } } diff --git a/entry/src/main/ets/statusbar/StatusBarAbility.ets b/entry/src/main/ets/statusbar/StatusBarAbility.ets new file mode 100644 index 0000000..777ef1c --- /dev/null +++ b/entry/src/main/ets/statusbar/StatusBarAbility.ets @@ -0,0 +1,32 @@ +import { UIExtensionContentSession, Want } from '@kit.AbilityKit'; +import { StatusBarViewExtensionAbility } from '@kit.DeskTopExtensionKit'; +import { Logger } from 'nextcloud'; + +const TAG = 'StatusBar/Ability'; + +export default class StatusBarView extends StatusBarViewExtensionAbility { + onCreate() { + Logger.Info(TAG, `onCreate`); + } + + onSessionCreate(want: Want, session: UIExtensionContentSession) { + Logger.Info(TAG, `onSessionCreate, want: ${want.abilityName}`); + session.loadContent('statusbar/StatusBarPage'); + } + + onForeground() { + Logger.Info(TAG, `onForeground`); + } + + onBackground() { + Logger.Info(TAG, `onBackground`); + } + + onSessionDestroy(session: UIExtensionContentSession) { + Logger.Info(TAG, `onSessionDestroy`); + } + + onDestroy() { + Logger.Info(TAG, `onDestroy`); + } +} diff --git a/entry/src/main/ets/statusbar/StatusBarPage.ets b/entry/src/main/ets/statusbar/StatusBarPage.ets new file mode 100644 index 0000000..f73bea8 --- /dev/null +++ b/entry/src/main/ets/statusbar/StatusBarPage.ets @@ -0,0 +1,56 @@ +import { Logger } from 'nextcloud'; +import { TransferJobStatus, TransferStatusReceiver } from './TransferStatusReceiver'; + +const TAG = 'StatusBar/Page'; + +@ComponentV2 +struct TransferProgressItem { + @Require @Param task: TransferJobStatus; + + build() { + Column({ space: 4 }) { + Text(this.task.fileName) + .textOverflow({ overflow: TextOverflow.MARQUEE }) + .textAlign(TextAlign.Start) + .width('100%') + + Progress({ value: this.task.transferredSize, total: this.task.totalSize }) + .flexGrow(1) + + Text(this.task.progressPercentText) + } + .backgroundColor($r('sys.color.ohos_id_color_hover')) + .padding(8) + .borderRadius(4) + .width('100%') + } +} + +@Entry +@ComponentV2 +struct StatusBarPage { + private statusReceiver: TransferStatusReceiver = new TransferStatusReceiver(); + + aboutToAppear(): void { + Logger.Info(TAG, `aboutToAppear`); + } + + build() { + Scroll() { + Column({ space: 16 }) { + if (this.statusReceiver) { + ForEach(Array.from(this.statusReceiver.tasks).reverse(), (item: [number, TransferJobStatus]) => { + TransferProgressItem({ + task: item[1] + }) + }, (item: [number, TransferJobStatus], _index: number) => { + return `${item[0]}` + }) + } + } + } + .padding(16) + .width('100%') + .height('100%') + } +} diff --git a/entry/src/main/ets/statusbar/TransferStatusReceiver.ets b/entry/src/main/ets/statusbar/TransferStatusReceiver.ets new file mode 100644 index 0000000..50b12f8 --- /dev/null +++ b/entry/src/main/ets/statusbar/TransferStatusReceiver.ets @@ -0,0 +1,83 @@ +import { commonEventManager } from '@kit.BasicServicesKit'; +import { Logger } from 'nextcloud'; +import { + TransferJobBasicInfo, + TRANSFER_MANAGER_COMMON_EVENT_NAME, + TransferManagerEvent, + TransferManagerEventCode, + TransferType +} from 'nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC'; + +const TAG = 'StatusBar/TransferStatusReceiver'; + +@ObservedV2 +export class TransferJobStatus { + @Trace id: number; + @Trace type: TransferType; + @Trace fileName: string; + @Trace totalSize: number; + @Trace transferredSize: number; + + constructor(task: TransferJobBasicInfo) { + this.id = task.id; + this.type = task.type; + this.fileName = task.fileName; + this.totalSize = task.totalSize; + this.transferredSize = task.transferredSize; + } + + public get progressPercentText() { + let progress = this.transferredSize / this.totalSize; + return `${(progress * 100).toFixed(1)}%`; + } +} + +@ObservedV2 +export class TransferStatusReceiver { + @Trace tasks: Map = new Map(); + private subscriber: commonEventManager.CommonEventSubscriber | undefined = undefined; + private readonly subscribeInfo: commonEventManager.CommonEventSubscribeInfo = { + events: [TRANSFER_MANAGER_COMMON_EVENT_NAME] + }; + private readonly subscribeCB = async (data: commonEventManager.CommonEventData) => { + console.info(`Succeeded in subscribing, data is ` + JSON.stringify(data)); + let event: TransferManagerEvent = JSON.parse(data.data!); + switch (event.code) { + case TransferManagerEventCode.TASK_CREATED: { + this.tasks.set(event.taskId, new TransferJobStatus(event.task)); + break; + } + case TransferManagerEventCode.TASK_PROGRESS: { + let task = this.tasks.get(event.taskId)!; + task.transferredSize = event.transferredSize; + break; + } + case TransferManagerEventCode.TASK_COMPLETED: { + let task = this.tasks.get(event.taskId)!; + task.transferredSize = task.totalSize; + break; + } + case TransferManagerEventCode.TASK_FAILED: { + let task = this.tasks.get(event.taskId)!; + task.transferredSize = 0; + break; + } + } + } + + constructor() { + this.doSubscribe(); + } + + private async doSubscribe() { + try { + this.subscriber = await commonEventManager.createSubscriber(this.subscribeInfo); + Logger.Info(TAG, `succeeded in creating subscriber.`); + await commonEventManager.subscribeToEvent(this.subscriber, this.subscribeCB); + Logger.Info(TAG, `succeeded in subscribing`); + } catch (error) { + let err: BusinessError = error as BusinessError; + Logger.Error(TAG, `Failed to create subscriber. Code is ${err.code}, message is ${err.message}`); + } + } +} diff --git a/entry/src/main/ets/utils/CloudSyncApi.ets b/entry/src/main/ets/utils/CloudSyncApi.ets new file mode 100644 index 0000000..91ece89 --- /dev/null +++ b/entry/src/main/ets/utils/CloudSyncApi.ets @@ -0,0 +1,35 @@ +export { + add, remove, connect, disconnect, registerFileChangeMonitor, updateCustomAlias, setLocalFileState +} from 'libcloudsync.so'; + +export enum FileChangeOperationType { + CREATE = 0, + DELETE = 1, + MOVE_FROM = 2, + MOVE_TO = 3, + CLOSE_WRITE = 4, + SYNC_FOLDER_INVALID = 5, +} + +export interface FileChangeInfo { + fileId: string; + parentFileId: string; + relativePath: string; + operationType: FileChangeOperationType; + size: number; + mtime: number; +} + +export enum SyncState { + IDLE = 0, + SYNCING = 1, + SUCCEEDED = 2, + FAILED = 3, + CANCELED = 4, + CONFLICTED = 5, +} + +export interface SyncFileState { + name: string; + syncState: SyncState; +} diff --git a/entry/src/main/ets/utils/Common.ets b/entry/src/main/ets/utils/Common.ets index a72623c..bab9dfb 100644 --- a/entry/src/main/ets/utils/Common.ets +++ b/entry/src/main/ets/utils/Common.ets @@ -6,4 +6,8 @@ export enum FolderViewMode { export namespace Constants { export const VIEW_MODE_KEY = 'FolderList.FolderViewMode'; + + export const PHONE_ACTIVE_ACCOUNT_ID = 'Phone.ActiveAccountId'; + + export const EMITTER_EVENT_NOTIFICATION_ON_IDLE: string = 'Events.Notification.OnIdle'; } diff --git a/entry/src/main/ets/utils/DownloadQueue.ets b/entry/src/main/ets/utils/DownloadQueue.ets new file mode 100644 index 0000000..2dfee39 --- /dev/null +++ b/entry/src/main/ets/utils/DownloadQueue.ets @@ -0,0 +1,70 @@ +import { Account, DirectoryEntry, ListFiles, Logger } from 'nextcloud'; + +const DOWNLOAD_DEPTH_FIRST: boolean = true; +const TAG = 'Utils/DownloadQueue'; + +export class DownloadItem { + account: Account; + relativePath: string; + isDirectory: boolean; + + constructor(account: Account, relativePath: string, isDirectory: boolean) { + this.account = account; + this.relativePath = relativePath; + this.isDirectory = isDirectory; + } + + public makeChildItem(filename: string, isDirectory: boolean): DownloadItem { + let relativePath = this.relativePath.endsWith('/') ? this.relativePath : this.relativePath + '/'; + return new DownloadItem(this.account, relativePath + filename, isDirectory); + } +} + +export class DownloadQueue { + queue: DownloadItem[] = []; + + public hasPendingItem() { + return this.queue.length > 0; + } + + public async getNextDownloadFile(): Promise { + do { + let next = await this.shiftNext(); + if (next !== undefined) { + return next; + } + } while (this.hasPendingItem()); + Logger.Info(TAG, `download queue empty`); + return undefined; + } + + public addRootDir(account: Account) { + let rootItem = new DownloadItem(account, '/', true); + this.queue.push(rootItem); + } + + private async shiftNext(): Promise { + let next = this.queue.shift(); + if (!next) { + Logger.Info(TAG, `download queue is empty`); + return undefined; + } + + if (next.isDirectory) { + Logger.Debug(TAG, `downloading dir: ${next.relativePath}`); + let items = await ListFiles(next.account, next.relativePath); + let downloadItems = items.map((item: DirectoryEntry): DownloadItem => { + return next!.makeChildItem(item.name, item.isDirectory); + }); + + if (DOWNLOAD_DEPTH_FIRST) { + this.queue.unshift(...downloadItems); + } else { + this.queue.push(...downloadItems); + } + } else { + Logger.Debug(TAG, `downloading file: ${next.relativePath}`); + } + return next; + } +} diff --git a/entry/src/main/ets/utils/GlobalStorage.ets b/entry/src/main/ets/utils/GlobalStorage.ets index 7e1567b..220c387 100644 --- a/entry/src/main/ets/utils/GlobalStorage.ets +++ b/entry/src/main/ets/utils/GlobalStorage.ets @@ -1,5 +1,8 @@ export const INSTANT_UPLOAD_PATH = '/InstantUpload/'; -export class GlobalStorage { - public static SandboxBasePath: string = ""; +@ObservedV2 +class GlobalStorageImpl { + @Trace public SandboxBasePath: string = ""; } + +export const GlobalStorage = new GlobalStorageImpl(); diff --git a/entry/src/main/ets/utils/NotificationManager.ets b/entry/src/main/ets/utils/NotificationManager.ets new file mode 100644 index 0000000..625e8c7 --- /dev/null +++ b/entry/src/main/ets/utils/NotificationManager.ets @@ -0,0 +1,82 @@ +import { Logger } from 'nextcloud'; +import { notificationManager } from '@kit.NotificationKit'; + +const TAG = "Util-NotificationManager"; + +export class Notification { + private static nextId = 1; + private readonly nId: number; + + private constructor(nId: number) { + this.nId = nId; + } + + public static async publishNotification(title: string, content: string, percent: number): Promise { + let n = new Notification(Notification.nextId++); + await n.publishNotification(title, content, percent); + return n; + } + + public async cancel(timeout: number = 0) { + if (this.nId === undefined) { + Logger.Warn(TAG, "cancelNotification: notificationId is undefined, return directly"); + return; + } + + if (timeout) { + setTimeout(async () => { + await this.doCancel(); + }, timeout); + } else { + await this.doCancel(); + } + } + + public async publishNotification(title: string, content: string, percent: number) { + if (this.nId === undefined) { + Logger.Warn(TAG, "publishUploadProgress: notificationId is undefined, return directly"); + return; + } + + await this.doPublish(title, content, percent); + } + + private async doCancel() { + try { + await notificationManager.cancel(this.nId); + Logger.Info(TAG, `doCancelNotification: cancelled notification id ${this.nId}`); + } catch (err) { + Logger.Error(TAG, `doCancelNotification: cancel fail: ${JSON.stringify(err)}`); + } + } + + private async doPublish(title: string, subtitle: string, progress: number) { + Logger.Info(TAG, `doPublishNotification: '${title}', '${subtitle}', ${progress}% -> ${this.nId}`); + let downloadTemplate: notificationManager.NotificationTemplate = { + name: 'downloadTemplate', + data: { + title: title, + fileName: subtitle, + progressValue: progress, + } + }; + + let notificationRequest: notificationManager.NotificationRequest = { + id: this.nId, + content: { + notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, + normal: { + title: "", + text: "", + additionalText: "", + } + }, + template: downloadTemplate + }; + try { + await notificationManager.publish(notificationRequest); + } catch (err) { + Logger.Error(TAG, `doPublishNotification: publish fail: ${JSON.stringify(err)}`); + } + } +} diff --git a/entry/src/main/ets/utils/Path.ets b/entry/src/main/ets/utils/Path.ets index 28768ca..7842ae4 100644 --- a/entry/src/main/ets/utils/Path.ets +++ b/entry/src/main/ets/utils/Path.ets @@ -1,4 +1,4 @@ -import { Logger } from "nextcloud"; +import { Logger } from 'nextcloud'; const TAG = "Util-Path"; diff --git a/entry/src/main/ets/utils/PermissionManager.ets b/entry/src/main/ets/utils/PermissionManager.ets index d79c043..9734dea 100644 --- a/entry/src/main/ets/utils/PermissionManager.ets +++ b/entry/src/main/ets/utils/PermissionManager.ets @@ -1,47 +1,70 @@ import { abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'; -import { BusinessError } from '@kit.BasicServicesKit'; import { Logger } from 'nextcloud'; +import { UIContext } from '@ohos.arkui.UIContext'; const TAG = 'utils/PermissionManager'; export class PermissionManager { - static async RequestPermission(context: Context, permission: Permissions): Promise { + static async RequestPermission(context: UIContext, perms: Permissions[]): Promise { let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); + Logger.Warn(TAG, `requestPermission for permissions: ${perms}`); - if (await PermissionManager.verifyPermission(permission)) { - Logger.Info(TAG, `requestPermission success, permissionName->${permission}`); + let toGrant = await PermissionManager.getPermissionsToGrant(atManager, perms); + if (toGrant.length === 0) { + Logger.Info(TAG, `requestPermission success, all permissions already granted`); return true; } - let result = await atManager.requestPermissionsFromUser(context, [permission]); + let result = await atManager.requestPermissionsFromUser(context.getHostContext()!, toGrant); + let leftoverPermissions: Permissions[] = []; for (let i = 0; i < result.permissions.length; i++) { let rPerm = result.permissions[i]; let rResult = result.authResults[i]; - - if (rPerm == permission && rResult == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { - Logger.Info(TAG, `requestPermission success, permissionName->${permission}`); - return true; + let isGranted = rResult == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; + if (!isGranted) { + leftoverPermissions.push(rPerm as Permissions); } } - Logger.Warn(TAG, `requestPermission fail, ${permission}, requesting to open settings`); - await AlertDialog.show({ message: '请开启权限' }); - await atManager.requestPermissionOnSetting(context, [permission]); + if (leftoverPermissions.length == 0) { + Logger.Info(TAG, `requestPermission success: ${toGrant}`); + return true; + } + + Logger.Warn(TAG, `requestPermission fail, ${leftoverPermissions}, requesting to open settings`); + context.showAlertDialog({ message: '需要开启权限才能使用全部功能' }); + await atManager.requestPermissionOnSetting(context.getHostContext()!, leftoverPermissions); return false; } - private static async verifyPermission(permission: Permissions): Promise { - let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); - let tokenID: number = (await bundleManager.getBundleInfoForSelf(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION)) - .appInfo - .accessTokenId; - let result: boolean = false; - await atManager.verifyAccessToken(tokenID, permission).then((data: abilityAccessCtrl.GrantStatus) => { - result = data == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; - }).catch((err: BusinessError) => { - Logger.Error(TAG, `verifyPermission fail, err->${JSON.stringify(err)}`); - }); - return result; + /** + * Verify if the permission is granted + * @param permission + * @returns Promise permissions that are NOT granted + */ + private static async getPermissionsToGrant(atManager: abilityAccessCtrl.AtManager, permission: Permissions[]): Promise { + let bundleInfo = await bundleManager.getBundleInfoForSelf(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); + + let verifySinglePermission = async (perm: Permissions): Promise => { + try { + let grantStatus = await atManager.verifyAccessToken(bundleInfo.appInfo.accessTokenId, perm); + Logger.Debug(TAG, `verifyPermission for ${perm}, grantStatus: ${grantStatus}`); + return grantStatus == abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; + } catch (error) { + Logger.Error(TAG, `verifyPermission exception for ${perm}, err->${JSON.stringify(error)}`); + return false; + } + }; + + let notGrantedPermissions: Permissions[] = []; + for (let perm of permission) { + if (!await verifySinglePermission(perm)) { + notGrantedPermissions.push(perm); + } + } + + Logger.Info(TAG, `verifyPermission, not granted permissions: ${notGrantedPermissions}`); + return notGrantedPermissions; } } diff --git a/entry/src/main/ets/utils/PhotoMonitor.ets b/entry/src/main/ets/utils/PhotoMonitor.ets new file mode 100644 index 0000000..171fbba --- /dev/null +++ b/entry/src/main/ets/utils/PhotoMonitor.ets @@ -0,0 +1,82 @@ +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { Logger, NCommon, PhotoAssetsManager } from 'nextcloud'; +import { Notification } from './NotificationManager'; + +const TAG = 'Utils-PhotoMonitor'; + +class PhotoMonitorImpl { + private static readonly FULL_UPLOAD_INTERVAL_MS = 60 * 60 * 1000; // perform full upload at least every hour + private static readonly UPLOAD_DELAY_MS = 30 * 1000; // wait for this time before triggering full upload + private doUploadTimer: number | undefined = undefined; + private fullUploadTimer: number | undefined = undefined; + private readonly onPhotoChangedCallback = async (changeData: photoAccessHelper.ChangeData): Promise => { + await this.doOnPhotoChanged(changeData); + }; + + public beginMonitorPhotoChange() { + try { + NCommon.pHelper.registerChange( + photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, + true, + this.onPhotoChangedCallback + ); + } catch (error) { + Logger.Error(TAG, `Failed to register photo change monitor: ${error}`); + } + + this.scheduleFullUpload(); + } + + public stopMonitorPhotoChange() { + try { + NCommon.pHelper.unRegisterChange( + photoAccessHelper.DefaultChangeUri.DEFAULT_PHOTO_URI, + this.onPhotoChangedCallback + ); + } catch (error) { + Logger.Error(TAG, `Failed to unregister photo change monitor: ${error}`); + } + + } + + private async scheduleFullUpload() { + clearInterval(this.fullUploadTimer); + + this.fullUploadTimer = setInterval(async () => { + Logger.Info(TAG, `>>> Scheduled full upload beginning`); + // await PhotoAssetsManager.doFullUpload(); + let nextTime = new Date(Date.now() + PhotoMonitorImpl.FULL_UPLOAD_INTERVAL_MS); + Logger.Info(TAG, `<<< Scheduled full upload completed, next time: ${nextTime}`); + }, PhotoMonitorImpl.FULL_UPLOAD_INTERVAL_MS); + + let nextUploadTime = new Date(Date.now() + PhotoMonitorImpl.FULL_UPLOAD_INTERVAL_MS); + Logger.Info(TAG, `+++ Scheduled full upload at ${nextUploadTime}`); + } + + private async doOnPhotoChanged(changeData: photoAccessHelper.ChangeData) { + if (changeData.type !== photoAccessHelper.NotifyType.NOTIFY_ADD) { + Logger.Debug(TAG, `change type isn't ADD, return directly`); + return; + } + + for (let uri of changeData.uris) { + await PhotoAssetsManager.prependNewAsset(uri); + } + + clearTimeout(this.doUploadTimer); // cancel repeating upload timer + clearInterval(this.fullUploadTimer); // postpone full upload timer after photo change + + this.doUploadTimer = setTimeout(async () => { + Logger.Info(TAG, `>>> Scheduled photo upload beginning`); + // await PhotoAssetsManager.doFullUpload(); + Logger.Info(TAG, `<<< Scheduled photo upload completed`); + this.scheduleFullUpload(); + }, PhotoMonitorImpl.UPLOAD_DELAY_MS); + + let nextUploadTime = new Date(Date.now() + PhotoMonitorImpl.UPLOAD_DELAY_MS); + // Notification.publishNotification('nextUploadTime', '1', 1); + Logger.Info(TAG, `+++ Scheduled upload at ${nextUploadTime}`); + } +} + +export const PhotoMonitor = new PhotoMonitorImpl(); diff --git a/entry/src/main/ets/utils/Utils.ets b/entry/src/main/ets/utils/Utils.ets index 904c581..d5552d3 100644 --- a/entry/src/main/ets/utils/Utils.ets +++ b/entry/src/main/ets/utils/Utils.ets @@ -1,4 +1,7 @@ -const DEFAULT_PATH_FORMAT = 'yyyy/MM/' +import { UIContext } from '@ohos.arkui.UIContext'; +import { Logger } from 'nextcloud'; + +const TAG = "Utils/Utils"; export function FormatBytes(bytes: string) { let bytesNum = parseInt(bytes); @@ -12,18 +15,27 @@ export function FormatBytes(bytes: string) { return bytesNum.toFixed(2) + ' ' + units[i]; } -export function TimeToPath(date: Date, pathFormat: string = DEFAULT_PATH_FORMAT) { - // make sure the path format ends with a slash - if (!pathFormat.endsWith('/')) { - pathFormat += '/'; +export function ShowToast(context: UIContext, message: string | Resource, duration: number = 1500): void { + try { + Logger.Info(TAG, `Showing toast: '${message}' for ${duration}ms`); + context.getPromptAction().showToast({ message, duration, }); + } catch (error) { + Logger.Error(TAG, `Failed to show toast: ${JSON.stringify(error)}`); } +} - let result = pathFormat; - result = result.replace('yyyy', date.getFullYear().toString()); - result = result.replace('MM', (date.getMonth() + 1).toString().padStart(2, '0')); - result = result.replace('dd', date.getDate().toString().padStart(2, '0')); - result = result.replace('HH', date.getHours().toString().padStart(2, '0')); - result = result.replace('mm', date.getMinutes().toString().padStart(2, '0')); - result = result.replace('ss', date.getSeconds().toString().padStart(2, '0')); - return result; +export function ShowAlertDialog( + context: UIContext, + title: string | Resource, + message: string | Resource, +): void { + try { + Logger.Info(TAG, `Showing alert dialog: '${title}' - '${message}'`); + context.showAlertDialog({ + title: title, + message: message, + }); + } catch (error) { + Logger.Error(TAG, `Failed to show alert dialog: ${JSON.stringify(error)}`); + } } diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index cd39c30..55d1a85 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -8,6 +8,15 @@ { "name": "ohos.permission.INTERNET" }, + { + "name": "ohos.permission.KEEP_BACKGROUND_RUNNING_SYSTEM", + "reason": "$string:permission_media_location_reason", + "usedScene": { + "abilities": [ + "EntryAbility" + ] + } + }, { "name": "ohos.permission.READ_IMAGEVIDEO", "reason": "$string:permission_image_video_reason", @@ -25,6 +34,15 @@ "EntryAbility" ] } + }, + { + "name": "ohos.permission.KEEP_BACKGROUND_RUNNING" + }, + { + "name": "ohos.permission.FILE_ACCESS_PERSIST" + }, + { + "name": "ohos.permission.PREPARE_APP_TERMINATE" } ], "deviceTypes": [ @@ -55,6 +73,9 @@ "action.system.home" ] } + ], + "backgroundModes": [ + "taskKeeping" ] } ], @@ -69,8 +90,15 @@ "name": "ohos.extension.backup", "resource": "$profile:backup_config" } - ], + ] + }, + { + "name": "StatusBarAbility", + "srcEntry": "./ets/statusbar/StatusBarAbility.ets", + "icon": "$media:layered_image", + "type": "statusBarView", + "exported": true } ] } -} +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json new file mode 100644 index 0000000..a0a93dd --- /dev/null +++ b/entry/src/main/resources/base/element/float.json @@ -0,0 +1,8 @@ +{ + "float": [ + { + "name": "page_text_font_size", + "value": "50fp" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 3bd0604..9aab7eb 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -85,4 +85,4 @@ "value": "Upload All" } ] -} +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/layered_image.json b/entry/src/main/resources/base/media/layered_image.json index fb49920..d2e870e 100644 --- a/entry/src/main/resources/base/media/layered_image.json +++ b/entry/src/main/resources/base/media/layered_image.json @@ -1,7 +1,6 @@ { - "layered-image": - { - "background" : "$media:background", - "foreground" : "$media:foreground" + "layered-image": { + "background": "$media:background", + "foreground": "$media:foreground" } -} \ No newline at end of file +} diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json index 1898d94..95cb6f8 100644 --- a/entry/src/main/resources/base/profile/main_pages.json +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -1,5 +1,7 @@ { "src": [ - "pages/Index" + "pages/Index", + "pages/PcMainPage", + "statusbar/StatusBarPage" ] -} +} \ No newline at end of file diff --git a/entry/src/main/resources/zh_CN/element/string.json b/entry/src/main/resources/zh_CN/element/string.json index d976b7d..9c8b0a5 100644 --- a/entry/src/main/resources/zh_CN/element/string.json +++ b/entry/src/main/resources/zh_CN/element/string.json @@ -85,4 +85,4 @@ "value": "全部上传" } ] -} +} \ No newline at end of file diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 index dbbc3d0..3fb55ee 100644 --- a/hvigor/hvigor-config.json5 +++ b/hvigor/hvigor-config.json5 @@ -1,24 +1,26 @@ { - "modelVersion": "5.0.1", + "modelVersion": "6.0.0", "dependencies": { }, "execution": { - // "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | false ]. Default: "normal" */ - // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ - // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ - // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ - // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ + "analyze": "advanced", + /* Define the build analyze mode. Value: [ "normal" | "advanced" | false ]. Default: "normal" */ + // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ + // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ + // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ + "typeCheck": true, + /* Enable typeCheck. Value: [ true | false ]. Default: false */ }, "logging": { - "level": "info" - /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ + "level": "info", + /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ }, "debugging": { - "stacktrace": true - /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ + "stacktrace": true, + /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ }, "nodeOptions": { - // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ - // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ + // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ + // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ } -} +} \ No newline at end of file diff --git a/nextcloud/index.ets b/nextcloud/index.ets deleted file mode 100644 index 70bbc93..0000000 --- a/nextcloud/index.ets +++ /dev/null @@ -1,15 +0,0 @@ -export { NCommon, DEFAULT_CATEGORIES } from './src/main/ets/Common'; - -export { ListFiles } from './src/main/ets/webdav/Files'; - -export { DirectoryEntry } from './src/main/ets/webdav/DirEntryParserStateMachine'; - -export { LoginProcess } from './src/main/ets/Login'; - -export { BasicDataSource } from './src/main/ets/BasicDatasource'; - -export { FilesDataSource } from './src/main/ets/FilesDataSource'; - -export { CreatePath, UploadFile } from './src/main/ets/webdav/Uploader'; - -export { Logger } from './src/main/ets/Logger'; diff --git a/nextcloud/oh-package-lock.json5 b/nextcloud/oh-package-lock.json5 index c36f764..31c3e07 100644 --- a/nextcloud/oh-package-lock.json5 +++ b/nextcloud/oh-package-lock.json5 @@ -10,7 +10,7 @@ }, "packages": { "@ohos/axios@2.2.4": { - "name": "", + "name": "@ohos/axios", "version": "2.2.4", "integrity": "sha512-5oH753kGFwfJP1kvCnI92RRaSqRbN0b24vmNNrUV+T8+5ako98rtVQOhA7ezMPox5Z3PFWndLQTF/P/hEK/yeQ==", "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/axios/-/axios-2.2.4.har", diff --git a/nextcloud/oh-package.json5 b/nextcloud/oh-package.json5 index 73ce640..cbdb133 100644 --- a/nextcloud/oh-package.json5 +++ b/nextcloud/oh-package.json5 @@ -2,10 +2,10 @@ "name": "nextcloud", "version": "1.0.0", "description": "Nextcloud API Wrapper", - "main": "./index.ets", + "main": "./src/main/ets/Index.ets", "author": "Moody Liu", "license": "", "dependencies": { - "@ohos/axios": "^2.2.4", - }, + "@ohos/axios": "^2.2.4" + } } diff --git a/nextcloud/src/main/ets/Account/Account.ets b/nextcloud/src/main/ets/Account/Account.ets new file mode 100644 index 0000000..c91d315 --- /dev/null +++ b/nextcloud/src/main/ets/Account/Account.ets @@ -0,0 +1,86 @@ +import { AccountConfig, DBAccount } from '../Database/DbTypes/DBAccount'; +import { RemoteFolder } from '../Database/Types/RemoteFolder'; +import { Nextcloud } from '../Main'; +import { Base64Encode } from '../Utils/CommonUtils'; +import { Logger } from '../Utils/Logger'; + +const TAG = 'Account/Account'; + +export class Account { + public dbAccount: DBAccount; + + constructor(dbAccount: DBAccount) { + Logger.Info(TAG, `constructor: enter`); + this.dbAccount = dbAccount; + } + + public get id(): number { + return this.dbAccount.id; + } + + public get username(): string { + return this.dbAccount.username; + } + + public get hostname(): string { + return this.dbAccount.hostname; + } + + private get baseUrlWithCredentials(): string { + return `https://${this.dbAccount.username}:${this.dbAccount.password}@${this.dbAccount.hostname}`; + } + + public static createNewAccount(hostname: string, username: string, password: string): Account { + Logger.Info(TAG, `createNewAccount: enter, hostname=${hostname}, username=${username}`); + const accountConfig: AccountConfig = { + instantUploadFolderId: 0, + } + let dbAccount = new DBAccount(0, hostname, username, password, accountConfig); + return new Account(dbAccount); + } + + public isLoggedIn(): boolean { + return this.dbAccount.password !== ''; + } + + public makeAuthorizationHeader(): string { + return 'basic ' + Base64Encode(this.dbAccount.username + ":" + this.dbAccount.password); + } + + public remoteBaseDirUrl(): string { + return `https://${this.dbAccount.hostname}/remote.php/dav/files/${this.dbAccount.username}/`; + } + + public chunkedUploadBaseUrl(): string { + return `https://${this.dbAccount.hostname}/remote.php/dav/uploads/${this.dbAccount.username}/`; + } + + public getPreviewUrl(fileId: string, x: number = 64, y: number = 64): string { + return this.baseUrlWithCredentials + `/core/preview?fileId=${fileId}&x=${x}&y=${y}&mimeFallback=true`; + } + + public getDownloadUrl(path: string): string { + let encodedPath = encodeURI(path); + return this.baseUrlWithCredentials + `/remote.php/dav/files/${this.dbAccount.username}/${encodedPath}`; + } + + public async getRemoteUploadFolder(): Promise { + if (this.dbAccount.config.instantUploadFolderId === 0) { + Logger.Info(TAG, `account did not have instant upload configured`); + return null; + } + let remoteFolder = await Nextcloud.getSyncFolderManager().getRemoteFolderById(this.dbAccount.config.instantUploadFolderId); + if (remoteFolder === null) { + Logger.Error(TAG, `failed to get remote folder`); + return null; + } + + if (remoteFolder.accountId !== this.id) { + Logger.Error(TAG, `remote folder accountId mismatches, ${remoteFolder.accountId} vs ${this.id}`) + this.dbAccount.config.instantUploadFolderId = 0; + return null; + } + + return remoteFolder; + } +} diff --git a/nextcloud/src/main/ets/Login.ets b/nextcloud/src/main/ets/Account/LoginFlow.ets similarity index 51% rename from nextcloud/src/main/ets/Login.ets rename to nextcloud/src/main/ets/Account/LoginFlow.ets index 6694fee..b7e3d27 100644 --- a/nextcloud/src/main/ets/Login.ets +++ b/nextcloud/src/main/ets/Account/LoginFlow.ets @@ -1,33 +1,34 @@ -import { Logger } from './Logger'; -import { Configuration, CoreClientFlowLoginV2Api, CoreLoginFlowV2Credentials } from './nextcloud-api-sdk'; -import { NCommon } from './Common'; +import { Logger } from '../Utils/Logger'; +import { Configuration, CoreClientFlowLoginV2Api, CoreLoginFlowV2Credentials } from '../nextcloud-api-sdk'; import { AxiosError } from '@ohos/axios'; +import { AsyncSleep } from '../Utils/CommonUtils'; +import { Err, Ok, Result } from '../Utils/Result'; +import { Account } from './Account'; +import { NCommon } from '../Common'; -const TAG = "Nextcloud-Login"; +const TAG = 'Account/Login'; -interface InitLoginResult { - success: boolean; - loginUrl?: string; - errorMsg?: string; -}; +interface PollLoginResultOk { + kind: "ok"; + account: Account; +} -interface PollLoginResult { - cancelled: boolean; - loginName: string; +interface PollLoginResultCancelled { + kind: "cancelled"; } -class LoginProcess { +type PollLoginResult = PollLoginResultOk | PollLoginResultCancelled; + +export class LoginFlow { private loginApi: CoreClientFlowLoginV2Api | null = null; - private token: string = ""; + private token: string = ''; private cancelled: boolean = false; + private hostname: string = ''; - hasAppPassword(): boolean { - return NCommon.Preference.password !== ""; - } - - async initLogin(): Promise { + async initLogin(hostname: string): Promise> { + this.hostname = hostname; let apiConfig = new Configuration({ - basePath: "https://" + NCommon.Preference.hostname, + basePath: "https://" + hostname, baseOptions: { headers: { "User-Agent": NCommon.UserAgent() }, } @@ -37,14 +38,14 @@ class LoginProcess { try { let flow = (await this.loginApi.coreClientFlowLoginV2Init()).data; this.token = flow.poll.token; - return { success: true, loginUrl: flow.login }; + return Ok(flow.login); } catch (e) { + Logger.Error(TAG, "LoginV2Init failed: " + JSON.stringify(e)); let axiosError: AxiosError = e as AxiosError; - Logger.Error(TAG, "LoginV2Init failed: " + e); if (Number(axiosError.code) === 2300006) { - return { success: false, errorMsg: (JSON.parse(axiosError.message) as Error).message }; + return Err((JSON.parse(axiosError.message) as Error).message); } else { - return { success: false, errorMsg: axiosError.message }; + return Err("Network error"); } } } @@ -56,23 +57,19 @@ class LoginProcess { async loginPollRepeatedly(): Promise { Logger.Info(TAG, "Polling for login result..."); this.cancelled = false; - let result = await this.loginPollOnce(); - while (!result) { + + let result: CoreLoginFlowV2Credentials | null; + do { if (this.cancelled) { - return { - loginName: '', - cancelled: true - }; + return { kind: "cancelled" }; } - await new Promise(resolve => setTimeout(resolve, 1500)); + await AsyncSleep(1500); result = await this.loginPollOnce(); - } + } while (result === null); - NCommon.Preference.password = result.appPassword; - NCommon.Preference.username = result.loginName; return { - cancelled: false, - loginName: result.loginName, + kind: 'ok', + account: Account.createNewAccount(this.hostname, result.loginName, result.appPassword) }; } @@ -85,5 +82,3 @@ class LoginProcess { } } } - -export { LoginProcess }; diff --git a/nextcloud/src/main/ets/Common.ets b/nextcloud/src/main/ets/Common.ets index a704a13..643fa0c 100644 --- a/nextcloud/src/main/ets/Common.ets +++ b/nextcloud/src/main/ets/Common.ets @@ -1,106 +1,44 @@ import { deviceInfo } from '@kit.BasicServicesKit'; import { preferences } from '@kit.ArkData'; -import { common } from '@kit.AbilityKit'; -import { Logger } from './Logger'; -import util from '@ohos.util'; +import { Logger } from './Utils/Logger'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; const TAG = "Nextcloud-Common"; const USER_AGENT_PART = "Nextcloud Client for HarmonyOS on "; -export const DEFAULT_CATEGORIES: NCommon.CategoryConfig[] = [ - { - name: 'Camera', - location: 'Camera/', - patterns: [ - String.raw`^(IMG)_\d{8}_\d{6}(_\d{3})?(.*)\.(\w+)$`, - String.raw`^(S?VID)_\d{8}_\d{6}\.(\w+)$`, - ], - }, - { - name: 'Screenshot', - location: 'Screenshots/', - patterns: [ - String.raw`^((Watch)?[Ss]creenshot)_\d{8}_\d{6}(.*)\.(\w+)$`, - String.raw`^(S?VID)_\d{8}_\d{6}_\d+\.(\w+)$` - ], - }, - { - name: 'Misc', - location: 'Others/', - patterns: [ - String.raw`^(IMG)_(\d+)\.(\w+)$`, - String.raw`^(image)_(\d+)\.(\w+)$`, - String.raw`^(S?VID)_(\d+)\.(\w+)$`, - String.raw`^(alipay)_(\d+)\.(\w+)$`, - ], - }, - { - name: "Unmatched", - location: 'Others-unmatched/', - patterns: [".*"], - }, -]; - - -const LOGIN_APP_PASSWORD = "login.appPassword"; -const LOGIN_USERNAME = "login.username"; -const LOGIN_SERVER = "login.server"; -const UPLOAD_BASE_DIRECTORY = "upload.baseDir"; -const UPLOAD_REPLACE_EXISTING = "upload.replaceExisting"; -const UPLOAD_CATEGORIES = "upload.categories"; +type LOGIN_APP_PASSWORD = "login.appPassword"; +type LOGIN_USERNAME = "login.username"; +type LOGIN_SERVER = "login.server"; +type UPLOAD_BASE_DIRECTORY = "upload.baseDir"; +type UPLOAD_REPLACE_EXISTING = "upload.replaceExisting"; + +type SETTINGS_KEYS = + LOGIN_APP_PASSWORD | + LOGIN_USERNAME | + LOGIN_SERVER | + UPLOAD_BASE_DIRECTORY | + UPLOAD_REPLACE_EXISTING; class NPreference { - static SETTINGS_KEYS: string[] = [ - LOGIN_APP_PASSWORD, - LOGIN_USERNAME, - LOGIN_SERVER, - UPLOAD_BASE_DIRECTORY, - UPLOAD_REPLACE_EXISTING, - UPLOAD_CATEGORIES, - ]; private pref: preferences.Preferences; - constructor(context: common.UIAbilityContext) { + constructor(context: Context) { this.pref = preferences.getPreferencesSync(context, { name: "nextcloud" }); } - get password(): string { - return this.getPref(LOGIN_APP_PASSWORD, ""); - } - - set password(value: string) { - this.setPref(LOGIN_APP_PASSWORD, value); - } - - get username(): string { - return this.getPref(LOGIN_USERNAME, "username"); - } - - set username(value: string) { - this.setPref(LOGIN_USERNAME, value); - } - - get hostname(): string { - return this.getPref(LOGIN_SERVER, "nextcloud.com"); - } - - set hostname(value: string) { - this.setPref(LOGIN_SERVER, value); - } - get replaceExistingFiles(): boolean { - return this.getPref(UPLOAD_REPLACE_EXISTING, false); + return this.getPref('upload.replaceExisting', false); } set replaceExistingFiles(doReplace: boolean) { - this.setPref(UPLOAD_REPLACE_EXISTING, doReplace); + this.setPref('upload.replaceExisting', doReplace); } get baseUploadDirectory(): string { const DEFAULT_VALUE = 'InstantUpload/'; - let dir = this.getPref(UPLOAD_BASE_DIRECTORY, DEFAULT_VALUE); + let dir = this.getPref('upload.baseDir', DEFAULT_VALUE); if (dir === '') { dir = DEFAULT_VALUE; } @@ -108,32 +46,10 @@ class NPreference { } set baseUploadDirectory(dir: string) { - this.setPref(UPLOAD_BASE_DIRECTORY, dir); - } - - get categories(): NCommon.CategoryConfig[] { - let stringConfig = this.getPref(UPLOAD_CATEGORIES, ""); - if (stringConfig === "") { - return DEFAULT_CATEGORIES; - } - return JSON.parse(stringConfig) as NCommon.CategoryConfig[]; - } - - set categories(categories: NCommon.CategoryConfig[]) { - let stringConfig = JSON.stringify(categories); - this.setPref(UPLOAD_CATEGORIES, stringConfig); + this.setPref('upload.baseDir', dir); } - public hasLogon() { - return this.password !== '' && this.username !== ''; - } - - private getPref(key: string, defaultValue: T): T { - if (!NPreference.SETTINGS_KEYS.includes(key)) { - Logger.Error(TAG, "Invalid preference key: " + key); - return defaultValue; - } - + public getPref(key: SETTINGS_KEYS, defaultValue: T): T { try { let value = this.pref.getSync(key, "") as T; if (value === null) { @@ -148,12 +64,7 @@ class NPreference { } - private setPref(key: string, value: preferences.ValueType) { - if (!NPreference.SETTINGS_KEYS.includes(key)) { - Logger.Error(TAG, "Invalid preference key: " + key); - return; - } - + private setPref(key: SETTINGS_KEYS, value: preferences.ValueType) { Logger.Info(TAG, "Storing value for " + key); try { this.pref.putSync(key, value); @@ -166,17 +77,13 @@ class NPreference { export namespace NCommon { - export interface CategoryConfig { - name: string; - patterns: string[]; - location: string; - } - /** * Preferences for Nextcloud */ export let Preference: NPreference; + export let pHelper: photoAccessHelper.PhotoAccessHelper; + export function UserAgent() { return USER_AGENT_PART + deviceInfo.marketName + " (" + deviceInfo.productModel + ")"; } @@ -185,30 +92,18 @@ export namespace NCommon { * Initialise the common module * @param context The context to use for preferences */ - export function Initialise(context: common.UIAbilityContext) { + export function Initialise(context: Context) { Logger.Info(TAG, "Common initialised with User-Agent: " + UserAgent()); Preference = new NPreference(context); } - export function MakePreviewUrl(fileId: string, x: number = 64, y: number = 64): string { - return `https://${NCommon.Preference.username}:${NCommon.Preference.password}@${NCommon.Preference.hostname}` - + `/core/preview?fileId=${fileId}&x=${x}&y=${y}&mimeFallback=true`; - } - - export function MakeDownloadUrl(filePath: string): string { - let encodedPath = encodeURI(filePath); - return `https://${NCommon.Preference.username}:${NCommon.Preference.password}@${NCommon.Preference.hostname}` - + `/remote.php/dav/files/${NCommon.Preference.username}/${encodedPath}`; - } - - let Base64Util = new util.Base64Helper(); - let Utf8Encoder = util.TextEncoder.create('utf-8'); - - function Base64Encode(msg: string): string { - return Base64Util.encodeToStringSync(Utf8Encoder.encodeInto(msg)); - } - - export function MakeAuthorization(): string { - return 'basic ' + Base64Encode(NCommon.Preference.username + ":" + NCommon.Preference.password); + export function RandomString(length: number): string { + const CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + 'abcdefghijklmnopqrstuvwxyz' + '0123456789'; + let result = ''; + const charactersLength = CHARACTERS.length; + for (let i = 0; i < length; i++) { + result += CHARACTERS.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; } } diff --git a/nextcloud/src/main/ets/Database/DBManager.ets b/nextcloud/src/main/ets/Database/DBManager.ets new file mode 100644 index 0000000..28c3833 --- /dev/null +++ b/nextcloud/src/main/ets/Database/DBManager.ets @@ -0,0 +1,329 @@ +import { relationalStore } from '@kit.ArkData'; +import { Logger } from '../Utils/Logger'; +import { DBAccountTableInfo } from './DbTypes/DBAccount'; +import { DBLocalFolderTable } from './DbTypes/DBLocalFolder'; +import { DBRemoteFolderTable } from './DbTypes/DBRemoteFolder'; +import { DEBUG } from '../../../../BuildProfile'; +import { DBSyncConfigurationTable } from './DbTypes/DBSyncConfiguration'; + +const TAG = 'Database/DBManager'; + +const CAN_RECREATE_DATABASE = false; + +interface ForeignKeyDescriptor { + target: DBTableDescriptor; + targetColumn: ColumnDescriptor; +} + +export interface ColumnDescriptor { + type: string; ///< Data type (e.g., INTEGER, TEXT) + attrs?: string; ///< Additional attributes (e.g., PRIMARY KEY, NOT NULL) + foreignKey?: ForeignKeyDescriptor; +} + +export interface DBTableDescriptor { + readonly tableName: string, + readonly columns: Record; + readonly createItemFromBucket: (bucket: relationalStore.ValuesBucket) => TItem; + readonly primaryKey: TColumnName; +} + +/** + * Interface for database items that can be converted to a ValuesBucket. + */ +export interface DBItem { + getTableName(): string; + + getTableDescription(): DBTableDescriptor; + + toBucket(isCreate: boolean): relationalStore.ValuesBucket; +} + +const TABLES: DBTableDescriptor[] = [ + DBAccountTableInfo, + DBRemoteFolderTable, + DBLocalFolderTable, + DBSyncConfigurationTable, +]; + +export class DBManager { + private store: relationalStore.RdbStore | undefined = undefined; + + static generateTableCreationScript(tableName: string, columnDesc: Record): string { + let foreignKeyDefs: string[] = []; + let columnsDef: string[] = []; + for (let colName of Object.keys(columnDesc)) { + let colDesc = columnDesc[colName]; + let colDef = `${colName} ${colDesc.type}`; + if (colDesc.attrs) { + colDef += ` ${colDesc.attrs}`; + } + columnsDef.push(colDef); + if (colDesc.foreignKey) { + let fk = colDesc.foreignKey; + let fkTargetColumn: string = + Object.keys(fk.target.columns) + .find( + key => fk.target.columns[key] === + fk.targetColumn + )!; + foreignKeyDefs.push(`FOREIGN KEY(${colName}) REFERENCES ${fk.target.tableName}(${fkTargetColumn})`); + } + } + columnsDef.push(...foreignKeyDefs); // Add foreign key definitions to columns at the end + return `CREATE TABLE ${tableName} (\n\t${columnsDef.join(',\n\t')});`; + } + + async initDataStore(context: Context, storageName: string) { + Logger.Info(TAG, `initDataStore: enter, storageName: ${storageName}`); + let storeConfig: relationalStore.StoreConfig = { + name: storageName, + securityLevel: relationalStore.SecurityLevel.S3, + }; + + if (this.store) { + Logger.Error(TAG, `initDataStore: duplicated init`); + return; + } + + try { + this.store = await relationalStore.getRdbStore(context, storeConfig); + } catch (e) { + Logger.Error(TAG, `initDataStore: Failed to get RdbStore: ${JSON.stringify(e)}`); + return; + } + + Logger.Info(TAG, 'initDataStore: completed'); + } + + async upgradeDatabase(): Promise { + Logger.Info(TAG, 'upgradeDatabase: enter'); + if (!this.store) { + Logger.Error(TAG, `upgradeDatabase: store is undefined`); + return; + } + + let transaction: relationalStore.Transaction; + try { + transaction = await this.store.createTransaction(); + } catch (error) { + Logger.Error(TAG, `upgradeDatabase: failed to create transaction: ${JSON.stringify(error)}`); + return; + } + try { + let storeVersion = await transaction.execute('PRAGMA user_version'); + if (storeVersion === 0) { + for (let tableDesc of TABLES) { + let createSql = DBManager.generateTableCreationScript(tableDesc.tableName, tableDesc.columns); + Logger.Info(TAG, `upgradeDatabase: creating table with sql: ${createSql}`); + let result = await transaction.execute(createSql); + Logger.Info( + TAG, + `upgradeDatabase: created table ${tableDesc.tableName}, result: ${JSON.stringify(result)}` + ); + } + await transaction.execute('PRAGMA user_version = 1'); + storeVersion = 1; + } + } catch (e) { + Logger.Error(TAG, `upgradeDatabase: failed to execute sql: ${JSON.stringify(e)}`); + await transaction.rollback(); + return; + } + + try { + await transaction.commit(); + } catch (error) { + Logger.Error(TAG, `upgradeDatabase: failed to commit transaction: ${JSON.stringify(error)}`); + } + Logger.Info(TAG, 'upgradeDatabase: completed'); + } + + async doTransaction(operations: (transaction: relationalStore.Transaction) => Promise) { + Logger.Debug(TAG, 'doTransaction: enter'); + if (!this.store) { + Logger.Error(TAG, `doTransaction: store is undefined`); + return; + } + + let transaction: relationalStore.Transaction; + try { + transaction = await this.store.createTransaction(); + } catch (error) { + Logger.Error(TAG, `doTransaction: failed to create transaction: ${JSON.stringify(error)}`); + return; + } + + try { + await operations(transaction); + } catch (e) { + Logger.Error(TAG, `doTransaction: failed to execute operations: ${JSON.stringify(e)}`); + await transaction.rollback(); + return; + } + + try { + await transaction.commit(); + } catch (error) { + Logger.Error(TAG, `doTransaction: failed to commit transaction: ${JSON.stringify(error)}`); + } + Logger.Debug(TAG, 'doTransaction: completed'); + } + + async getItems(factory: DBTableDescriptor): Promise { + Logger.Info(TAG, 'getObjects: enter'); + if (!this.store) { + Logger.Error(TAG, `getObjects: store is undefined`); + return []; + } + + let result: T[] = []; + try { + let resultSet = await this.store.query(new relationalStore.RdbPredicates(factory.tableName)); + while (resultSet.goToNextRow()) { + let bucket = resultSet.getRow(); + result.push(factory.createItemFromBucket(bucket)); + } + } catch (error) { + Logger.Error(TAG, `getObjects: failed to read result set: ${JSON.stringify(error)}`); + } + + Logger.Info(TAG, `getObjects: completed, found ${result.length} items`); + return result; + } + + async getItemById(id: number, table: DBTableDescriptor): Promise { + Logger.Info(TAG, 'getItemById: enter'); + if (!this.store) { + Logger.Error(TAG, `getItemById: store is undefined`); + return null; + } + + let predicate = new relationalStore.RdbPredicates(table.tableName); + predicate.equalTo(table.primaryKey, id); + + try { + let resultSet = await this.store.query(predicate); + if (resultSet.rowCount === 0) { + Logger.Warn(TAG, `getItemById: no item found with id=${id}`); + return null; + } + if (resultSet.goToNextRow()) { + let bucket = resultSet.getRow(); + let item = table.createItemFromBucket(bucket); + Logger.Info(TAG, `getItemById: completed, found item with id=${id}`); + return item; + } + } catch (error) { + Logger.Error(TAG, `getItemById: failed to read result set: ${JSON.stringify(error)}`); + return null; + } + + Logger.Info(TAG, `getItemById: completed, no item found with id=${id}`); + return null; + } + + async deleteItemById(id: number, object: DBItem): Promise { + Logger.Info(TAG, 'deleteItemById: enter'); + if (!this.store) { + Logger.Error(TAG, `deleteItemById: store is undefined`); + return; + } + + let predicate = new relationalStore.RdbPredicates(object.getTableName()); + let bucket = object.toBucket(false); + let idColumnName = Object.keys(bucket).find(key => bucket[key] === id); + predicate.equalTo(idColumnName!, id); + + try { + let deletedCount = await this.store.delete(predicate); + Logger.Info( + TAG, + `deleteItemById: completed, deleted ${deletedCount} rows from table ${object.getTableName()}` + ); + } catch (error) { + Logger.Error( + TAG, + `deleteItemById: failed to delete from table ${object.getTableName()}: ${JSON.stringify(error)}` + ); + } + + } + + async createItem(object: DBItem): Promise { + return this.doStoreItem(object, true); + } + + async updateItem(object: DBItem): Promise { + return this.doStoreItem(object, false); + } + + async dropDatabase() { + if (this.store === undefined) { + Logger.Error(TAG, `dropAllTables: store is undefined`); + return; + } + + Logger.Warn(TAG, `dropAllTables: enter, CAN_RECREATE_DATABASE=${CAN_RECREATE_DATABASE}, DEBUG=${DEBUG}`); + if (CAN_RECREATE_DATABASE && DEBUG) { + Logger.Warn(TAG, 'upgradeDatabase: recreating database as per ALWAYS_RECREATE_DATABASE flag'); + try { + for (let tableDesc of TABLES) { + let dropSql = `DROP TABLE IF EXISTS ${tableDesc.tableName};`; + await this.store.execute(dropSql); + Logger.Warn(TAG, `dropAllTables: dropped table ${tableDesc.tableName}`); + } + await this.store.execute('PRAGMA user_version = 0'); + Logger.Warn(TAG, 'dropAllTables: reset user_version to 0'); + // drop all other tables + let tablesResult = await this.store.querySql('SELECT name FROM sqlite_master WHERE type="table";'); + Logger.Warn(TAG, `dropAllTables: checking for leftover tables: ${tablesResult.rowCount}`); + while (tablesResult.goToNextRow()) { + let row = tablesResult.getRow(); + let tableName = row['name'] as string; + Logger.Warn(TAG, `dropAllTables: leftover table ${tableName}`); + if (tableName === 'sqlite_sequence') { + Logger.Warn(TAG, `dropAllTables: skipping sqlite_sequence table`); + continue; + } + await this.store.execute(`DROP TABLE IF EXISTS ${tableName};`); + } + Logger.Warn(TAG, 'dropAllTables: database dropped, closing store'); + await this.store.close(); + } catch (e) { + Logger.Error(TAG, `dropAllTables: failed to execute sql: ${JSON.stringify(e)}`); + } + Logger.Warn(TAG, 'dropAllTables: completed'); + } else { + Logger.Warn(TAG, 'dropAllTables: operation is disabled'); + } + } + + private async doStoreItem(object: DBItem, isCreate: boolean): Promise { + Logger.Info(TAG, `doStoreItem: enter, isCreate=${isCreate}`); + if (!this.store) { + Logger.Error(TAG, `doStoreItem: store is undefined`); + return; + } + + let bucket = object.toBucket(isCreate); + try { + let rowId: number; + if (isCreate) { + rowId = await this.store.insert(object.getTableName(), bucket); + } else { + let pred = new relationalStore.RdbPredicates(object.getTableName()); + const primaryKey = object.getTableDescription().primaryKey; + pred.equalTo(primaryKey, bucket[primaryKey]); + rowId = await this.store.update(bucket, pred); + } + Logger.Info(TAG, `doStoreItem: completed, rowId: ${rowId}`); + this.store.commit(); + return rowId; + } catch (error) { + Logger.Error(TAG, `doStoreItem: failed to insert to ${object.getTableName()}: ${JSON.stringify(error)}`); + return; + } + } +} diff --git a/nextcloud/src/main/ets/Database/DbTypes/DBAccount.ets b/nextcloud/src/main/ets/Database/DbTypes/DBAccount.ets new file mode 100644 index 0000000..59cb00d --- /dev/null +++ b/nextcloud/src/main/ets/Database/DbTypes/DBAccount.ets @@ -0,0 +1,72 @@ +import { Logger } from '../../Utils/Logger'; +import { DBItem, DBTableDescriptor } from '../DBManager'; +import { relationalStore } from '@kit.ArkData'; + +const TAG = 'Database/DBAccount'; + +/** + * Backwards compatible, do not change field names + */ +export interface AccountConfig { + instantUploadFolderId: number +} + +export class DBAccount implements DBItem { + static readonly DB_TABLE_NAME: string = 'Accounts'; + id: number; + username: string; + password: string; + hostname: string; + config: AccountConfig; + + constructor(id: number, hostname: string, username: string, password: string, config: AccountConfig) { + Logger.Debug(TAG, `constructor: enter, id=${id}, hostname=${hostname}, username=${username}`); + this.id = id; + this.hostname = hostname; + this.username = username; + this.password = password; + this.config = config; + } + + getTableDescription(): DBTableDescriptor { + return DBAccountTableInfo; + } + + public getTableName(): string { + return DBAccount.DB_TABLE_NAME; + } + + public toBucket(isCreate: boolean): relationalStore.ValuesBucket { + let bucket: relationalStore.ValuesBucket = {}; + if (isCreate === false) { + bucket['account_id'] = this.id; + } + bucket['username'] = this.username; + bucket['password'] = this.password; + bucket['hostname'] = this.hostname; + bucket['config'] = JSON.stringify(this.config); + return bucket; + } +} + +type DBAccountColumnNames = 'account_id' | 'hostname' | 'username' | 'password' | 'config'; + +export const DBAccountTableInfo: DBTableDescriptor = { + tableName: DBAccount.DB_TABLE_NAME, + columns: { + 'account_id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + 'hostname': { type: 'TEXT', attrs: 'NOT NULL' }, + 'username': { type: 'TEXT', attrs: 'NOT NULL' }, + 'password': { type: 'TEXT', attrs: 'NOT NULL' }, + 'config': { type: 'TEXT', } + }, + primaryKey: 'account_id', + createItemFromBucket(bucket: relationalStore.ValuesBucket): DBAccount { + let id = bucket['account_id'] as number; + let username = bucket['username'] as string; + let password = bucket['password'] as string; + let hostname = bucket['hostname'] as string; + let config: AccountConfig = JSON.parse(bucket['config'] as string); + return new DBAccount(id, hostname, username, password, config); + } +}; diff --git a/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets b/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets new file mode 100644 index 0000000..06d3f9a --- /dev/null +++ b/nextcloud/src/main/ets/Database/DbTypes/DBLocalFolder.ets @@ -0,0 +1,96 @@ +import { relationalStore } from '@kit.ArkData'; +import { Logger } from '../../Utils/Logger'; +import { DBItem, DBTableDescriptor } from '../DBManager'; + +const TAG = 'Database/DBLocalFolder'; + +export enum LocalFolderType { + Regular, + PhotoAlbum, +} + +@ObservedV2 +export class DBLocalFolderConfig { + @Trace instantUploadEnabled: boolean = false; + + constructor(record: Record) { + this.instantUploadEnabled = record['instantUploadEnabled'] as boolean ?? false; + } + + public toJson() { + let object: Record = { + 'instantUploadEnabled': this.instantUploadEnabled, + }; + + return JSON.stringify(object); + } +} + +@ObservedV2 +export class DBLocalFolder implements DBItem { + static readonly DB_TABLE_NAME: string = 'LocalFolders'; + @Trace public id: number; + @Trace public localPath: string; + @Trace public alias: string; + @Trace public folderType: LocalFolderType; + public config: DBLocalFolderConfig; + + constructor(id: number, localPath: string, alias: string, folderType: LocalFolderType, config: DBLocalFolderConfig) { + Logger.Debug(TAG, `constructor: enter`, + `id: ${id}`, + `localPath: '${localPath}'`, + `alias: '${alias}'`, + `folderType: ${folderType}`); + this.id = id; + this.localPath = localPath; + this.alias = alias; + this.folderType = folderType; + this.config = config; + } + + getTableDescription(): DBTableDescriptor { + return DBLocalFolderTable; + } + + public getTableName(): string { + return DBLocalFolder.DB_TABLE_NAME; + } + + public toBucket(isCreate: boolean): relationalStore.ValuesBucket { + let bucket: relationalStore.ValuesBucket = {}; + if (isCreate === false) { + bucket['folder_id'] = this.id; + } + bucket['local_path'] = this.localPath; + bucket['alias'] = this.alias; + bucket['folder_type'] = this.folderType; + bucket['config'] = this.config.toJson(); + return bucket; + } + + public toString(): string { + return `DBLocalSyncFolder{id=${this.id}, localPath=${this.localPath}, alias=${this.alias}}`; + } +} + +type DBLocalSyncFolderColumnNames = 'folder_id' | 'local_path' | 'alias' | 'folder_type' | 'config'; + +export const DBLocalFolderTable: DBTableDescriptor = { + tableName: DBLocalFolder.DB_TABLE_NAME, + columns: { + 'folder_id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + 'local_path': { type: 'TEXT', attrs: 'NOT NULL' }, + 'alias': { type: 'TEXT', attrs: 'NOT NULL' }, + 'folder_type': { type: 'INTEGER', attrs: 'NOT NULL' }, + 'config': { type: 'TEXT', attrs: 'NOT NULL' }, + }, + primaryKey: 'folder_id', + createItemFromBucket(bucket: relationalStore.ValuesBucket): DBLocalFolder { + let id = bucket['folder_id'] as number; + let localPath = bucket['local_path'] as string; + let alias = bucket['alias'] as string; + let folderType = bucket['folder_type'] as number; + let config = new DBLocalFolderConfig(JSON.parse(bucket['config'] as string | undefined ?? '{}')); + return new DBLocalFolder(id, localPath, alias, folderType, config); + } +}; diff --git a/nextcloud/src/main/ets/Database/DbTypes/DBRemoteFolder.ets b/nextcloud/src/main/ets/Database/DbTypes/DBRemoteFolder.ets new file mode 100644 index 0000000..9834c2e --- /dev/null +++ b/nextcloud/src/main/ets/Database/DbTypes/DBRemoteFolder.ets @@ -0,0 +1,68 @@ +import { DBItem, DBTableDescriptor } from '../DBManager'; +import { relationalStore } from '@kit.ArkData'; +import { Logger } from '../../Utils/Logger'; +import { DBAccountTableInfo } from './DBAccount'; + +const TAG = 'Database/DBRemoteFolder'; + +export class DBRemoteFolder implements DBItem { + static readonly DB_TABLE_NAME: string = 'RemoteFolders'; + id: number; + accountId: number; + remotePath: string; + alias: string; + + constructor(id: number, accountId: number, remotePath: string, alias: string) { + Logger.Info(TAG, `constructor: enter, id=${id}, accountId=${accountId}, remotePath=${remotePath}, alias=${alias}`); + this.id = id; + this.accountId = accountId; + this.remotePath = remotePath; + this.alias = alias; + } + + public getTableName(): string { + return DBRemoteFolder.DB_TABLE_NAME; + } + + getTableDescription(): DBTableDescriptor { + return DBRemoteFolderTable; + } + + public toBucket(isCreate: boolean): relationalStore.ValuesBucket { + let bucket: relationalStore.ValuesBucket = {}; + if (isCreate === false) { + bucket['folder_id'] = this.id; + } + bucket['account_id'] = this.accountId; + bucket['remote_path'] = this.remotePath; + bucket['alias'] = this.alias; + return bucket; + } +} + +type DBRemoteFolderColumnNames = 'folder_id' | 'remote_path' | 'account_id' | 'alias'; + +export const DBRemoteFolderTable: DBTableDescriptor = { + tableName: DBRemoteFolder.DB_TABLE_NAME, + columns: { + 'folder_id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + 'remote_path': { type: 'TEXT', attrs: 'NOT NULL' }, + 'account_id': { + type: 'INTEGER', + attrs: 'NOT NULL', + foreignKey: { + target: DBAccountTableInfo, + targetColumn: DBAccountTableInfo.columns.account_id, + }, + }, + 'alias': { type: 'TEXT', attrs: 'NOT NULL' }, + }, + primaryKey: 'folder_id', + createItemFromBucket(bucket: relationalStore.ValuesBucket): DBRemoteFolder { + let id = bucket['folder_id'] as number; + let remotePath = bucket['remote_path'] as string; + let account_id = bucket['account_id'] as number; + let alias = bucket['alias'] as string; + return new DBRemoteFolder(id, account_id, remotePath, alias); + } +}; diff --git a/nextcloud/src/main/ets/Database/DbTypes/DBSyncConfiguration.ets b/nextcloud/src/main/ets/Database/DbTypes/DBSyncConfiguration.ets new file mode 100644 index 0000000..de81145 --- /dev/null +++ b/nextcloud/src/main/ets/Database/DbTypes/DBSyncConfiguration.ets @@ -0,0 +1,68 @@ +import { DBItem, DBTableDescriptor } from '../DBManager'; +import { relationalStore } from '@kit.ArkData'; +import { DBRemoteFolderTable } from './DBRemoteFolder'; +import { DBLocalFolderTable } from './DBLocalFolder'; + +export class DBSyncConfiguration implements DBItem { + static readonly DB_TABLE_NAME: string = 'SyncConfigurations'; + id: number; + localFolderId: number; + remoteFolderId: number; + + constructor(id: number, localFolderId: number, remoteFolderId: number) { + this.id = id; + this.localFolderId = localFolderId; + this.remoteFolderId = remoteFolderId; + } + + public getTableName(): string { + return DBSyncConfiguration.DB_TABLE_NAME; + } + + getTableDescription(): DBTableDescriptor { + return DBSyncConfigurationTable; + } + + public toBucket(isCreate: boolean): relationalStore.ValuesBucket { + let bucket: relationalStore.ValuesBucket = {}; + if (isCreate === false) { + bucket['id'] = this.id; + } + bucket['local_folder_id'] = this.localFolderId; + bucket['remote_folder_id'] = this.remoteFolderId; + return bucket; + } + + public toString(): string { + return `DBSyncConfiguration{id=${this.id}, localFolderId=${this.localFolderId}, remoteFolderId=${this.remoteFolderId}}`; + } +} + +type DBSyncConfigurationColumnNames = 'id' | 'local_folder_id' | 'remote_folder_id'; + +export const DBSyncConfigurationTable: DBTableDescriptor = { + tableName: DBSyncConfiguration.DB_TABLE_NAME, + columns: { + 'id': { type: 'INTEGER', attrs: 'PRIMARY KEY AUTOINCREMENT', }, + 'local_folder_id': { + type: 'INTEGER', attrs: 'NOT NULL', + foreignKey: { + target: DBLocalFolderTable, + targetColumn: DBLocalFolderTable.columns.folder_id, + } + }, + 'remote_folder_id': { + type: 'INTEGER', attrs: 'NOT NULL', foreignKey: { + target: DBRemoteFolderTable, + targetColumn: DBRemoteFolderTable.columns.folder_id, + } + }, + }, + primaryKey: 'id', + createItemFromBucket(bucket: relationalStore.ValuesBucket): DBSyncConfiguration { + let id = bucket['id'] as number; + let localFolderId = bucket['local_folder_id'] as number; + let remoteFolderId = bucket['remote_folder_id'] as number; + return new DBSyncConfiguration(id, localFolderId, remoteFolderId); + }, +}; diff --git a/nextcloud/src/main/ets/Database/Types/LocalAlbumFolder.ets b/nextcloud/src/main/ets/Database/Types/LocalAlbumFolder.ets new file mode 100644 index 0000000..7fb0e29 --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/LocalAlbumFolder.ets @@ -0,0 +1,11 @@ +import { DBLocalFolder, LocalFolderType } from "../DbTypes/DBLocalFolder"; +import { LocalFolder } from "./LocalFolder"; + +export class LocalAlbumFolder extends LocalFolder { + constructor(dbLocalFolder: DBLocalFolder) { + super(dbLocalFolder); + if (dbLocalFolder.folderType !== LocalFolderType.PhotoAlbum) { + throw Error("LocalAlbumFolder must be of PhotoAlbum type"); + } + } +} diff --git a/nextcloud/src/main/ets/Database/Types/LocalFolder.ets b/nextcloud/src/main/ets/Database/Types/LocalFolder.ets new file mode 100644 index 0000000..ec6bd53 --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/LocalFolder.ets @@ -0,0 +1,59 @@ +import { DBLocalFolder, LocalFolderType } from '../DbTypes/DBLocalFolder'; + +export class LocalFolder { + private dbLocalFolder_: DBLocalFolder; + + constructor(dbLocalFolder: DBLocalFolder) { + this.dbLocalFolder_ = dbLocalFolder; + } + + public get id(): number { + return this.dbLocalFolder_.id; + } + + public get dbLocalFolder() { + return this.dbLocalFolder_; + } + + public get folderType(): LocalFolderType { + return this.dbLocalFolder_.folderType; + } + + // only valid for regular folder type + public get localPath(): string { + if (this.dbLocalFolder_.folderType === LocalFolderType.Regular) { + return this.dbLocalFolder_.localPath; + } else { + return ""; + } + } + + // only valid for albums + public get albumId(): number { + if (this.dbLocalFolder_.folderType === LocalFolderType.PhotoAlbum) { + return Number(this.dbLocalFolder_.localPath); + } else { + return 0; + } + } + + public get alias(): string { + return this.dbLocalFolder_.alias; + } + + public set alias(newAlias: string) { + this.dbLocalFolder_.alias = newAlias; + } + + public getFullPath(relativePath: string): string { + if (this.dbLocalFolder_.folderType === LocalFolderType.Regular) { + if (this.localPath.endsWith('/')) { + return this.localPath + relativePath; + } else { + return this.localPath + '/' + relativePath; + } + } else { + return ""; + } + } +} diff --git a/nextcloud/src/main/ets/Database/Types/RemoteFolder.ets b/nextcloud/src/main/ets/Database/Types/RemoteFolder.ets new file mode 100644 index 0000000..cc53499 --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/RemoteFolder.ets @@ -0,0 +1,40 @@ +import { Account } from '../../Account/Account'; +import { DBRemoteFolder } from '../DbTypes/DBRemoteFolder'; + +export class RemoteFolder { + private dbRemoteFolder_: DBRemoteFolder; + private account_: Account; + + constructor(account: Account, dbRemoteFolder: DBRemoteFolder) { + this.dbRemoteFolder_ = dbRemoteFolder; + this.account_ = account; + } + + public get name() { + return this.dbRemoteFolder_.alias; + } + + public get id(): number { + return this.dbRemoteFolder_.id; + } + + public get accountId(): number { + return this.dbRemoteFolder_.accountId; + } + + public get account(): Account { + return this.account_; + } + + public get remotePath(): string { + return this.dbRemoteFolder_.remotePath; + } + + public getFullPath(relativePath: string): string { + let basePath = this.remotePath; + if (!basePath.endsWith('/')) { + basePath += '/'; + } + return basePath + relativePath; + } +} diff --git a/nextcloud/src/main/ets/Database/Types/SyncConfiguration.ets b/nextcloud/src/main/ets/Database/Types/SyncConfiguration.ets new file mode 100644 index 0000000..6798298 --- /dev/null +++ b/nextcloud/src/main/ets/Database/Types/SyncConfiguration.ets @@ -0,0 +1,19 @@ +import { DBSyncConfiguration } from '../DbTypes/DBSyncConfiguration'; +import { LocalFolder } from './LocalFolder'; +import { RemoteFolder } from './RemoteFolder'; + +export class SyncConfiguration { + public localFolder: LocalFolder; + public remoteFolder: RemoteFolder; + public dbSyncConfiguration: DBSyncConfiguration; + + constructor(localFolder: LocalFolder, remoteFolder: RemoteFolder, dbSyncConfiguration: DBSyncConfiguration) { + this.localFolder = localFolder; + this.remoteFolder = remoteFolder; + this.dbSyncConfiguration = dbSyncConfiguration; + } + + public get id(): number { + return this.dbSyncConfiguration.id; + } +} diff --git a/nextcloud/src/main/ets/FilesDataSource.ets b/nextcloud/src/main/ets/FilesDataSource.ets deleted file mode 100644 index 4c5c42c..0000000 --- a/nextcloud/src/main/ets/FilesDataSource.ets +++ /dev/null @@ -1,24 +0,0 @@ -import { BasicDataSource } from './BasicDatasource'; -import { DirectoryEntry } from './webdav/DirEntryParserStateMachine'; -import { ListFiles } from './webdav/Files'; - -export class FilesDataSource extends BasicDataSource { - constructor(path: string) { - super(); - ListFiles(path).then(entries => { - entries = entries.sort((a: DirectoryEntry, b: DirectoryEntry) => { - // Directories first, then sort by name - if (a.isDirectory && !b.isDirectory) { - return -1; - } else if (!a.isDirectory && b.isDirectory) { - return 1; - } else { - return a.name.localeCompare(b.name); - } - }) - - this.entries = entries; - this.notifyDataReload(); - }); - } -} diff --git a/nextcloud/src/main/ets/Index.ets b/nextcloud/src/main/ets/Index.ets new file mode 100644 index 0000000..d54a874 --- /dev/null +++ b/nextcloud/src/main/ets/Index.ets @@ -0,0 +1,46 @@ +/** + * Nextcloud module entry point + */ +export { LocalFolder } from './Database/Types/LocalFolder'; + +export { LocalFolderType } from './Database/DbTypes/DBLocalFolder'; + +export { RemoteFolder } from './Database/Types/RemoteFolder'; + +export { SyncConfiguration } from './Database/Types/SyncConfiguration'; + +export { TransferJob, TransferJobData } from './SyncEngine/Jobs/TransferJob'; + +export { UploadStatus } from './SyncEngine/PhotoItem'; + +export { NCommon } from './Common'; + +export { ListFiles } from './webdav/Files'; + +export { DirectoryEntry } from './webdav/DirEntryParserStateMachine'; + +export { LoginFlow } from './Account/LoginFlow'; + +export { Account } from './Account/Account'; + +export { BasicDataSource } from './Utils/BasicDatasource'; + +export { FilesDataSource } from './Utils/FilesDataSource'; + +export { JobManager, JobManagerImpl } from './SyncEngine/TransferManager'; + +export { SyncFolderManager } from './SyncEngine/SyncFolderManager'; + +export { PhotoAssetsManager } from './SyncEngine/PhotoAssetsManager'; + +export { Category } from './SyncEngine/Category'; + +export { PhotoItem } from './SyncEngine/PhotoItem'; + +export { CreatePath } from './webdav/TransferUtils'; + +export { Logger } from './Utils/Logger'; + +export { AsyncSleep } from './Utils/CommonUtils'; + +export { Nextcloud, InitialiseNextcloud } from './Main'; diff --git a/nextcloud/src/main/ets/Logger.ts b/nextcloud/src/main/ets/Logger.ts deleted file mode 100644 index 9a02dc1..0000000 --- a/nextcloud/src/main/ets/Logger.ts +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -import hilog from '@ohos.hilog' - -const DOMAIN = 0xABAB; - -export class Logger { - static Debug(component: string, message: string) { - hilog.debug(DOMAIN, component, message) - } - - static Info(component: string, message: string) { - hilog.info(DOMAIN, component, message) - } - - static Warn(component: string, message: string) { - hilog.warn(DOMAIN, component, message) - } - - static Error(component: string, message: string) { - hilog.error(DOMAIN, component, message) - } -} diff --git a/nextcloud/src/main/ets/Main.ets b/nextcloud/src/main/ets/Main.ets new file mode 100644 index 0000000..e169726 --- /dev/null +++ b/nextcloud/src/main/ets/Main.ets @@ -0,0 +1,119 @@ +/** + * nextcloud initialization + */ +import { DEBUG } from '../../../BuildProfile'; +import { SyncFolderManager } from './SyncEngine/SyncFolderManager'; +import { Logger } from './Utils/Logger'; +import { DBManager } from './Database/DBManager'; +import { NCommon } from './Common'; +import { Account } from './Account/Account'; +import { DBAccountTableInfo } from './Database/DbTypes/DBAccount'; + +const TAG = 'Nextcloud/Init'; + +const DEFAULT_DATABASE_NAME = 'nextcloud_data.db'; +const DEBUG_DATABASE_NAME = 'nextcloud_data-debug.db'; + +@Observed +export class NextcloudImpl { + private dbManager_: DBManager; + private syncFolderManager_: SyncFolderManager; + private accounts_: Map = new Map(); + + constructor() { + Logger.Info(TAG, `constructor`); + this.dbManager_ = new DBManager(); + this.syncFolderManager_ = new SyncFolderManager(this.dbManager_); + } + + public async initialize(context: Context): Promise { + Logger.Info(TAG, `initialize: enter`); + await this.dbManager_.initDataStore(context, DEBUG ? DEBUG_DATABASE_NAME : DEFAULT_DATABASE_NAME); + await this.dbManager_.upgradeDatabase(); + await this.loadAccounts(); + await this.syncFolderManager_.loadSyncFolders(); + } + + public getSyncFolderManager(): SyncFolderManager { + return this.syncFolderManager_; + } + + public getAccountById(accountId: number): Account { + let account = this.accounts_.get(accountId); + if (account) { + return account; + } + + Logger.Error(TAG, `getAccountById: account id ${accountId} not found, available accounts: ${Array.from(this.accounts_.keys()).join(',')}`); + if (this.accounts_.size > 0) { + account = this.accounts_.values().next().value; + Logger.Info(TAG, `getAccountById: returning first available account id ${account!.dbAccount.id}`); + return account!; + } else { + throw new Error(`No accounts available`); + } + } + + public getAllAccounts(): Account[] { + let values = this.accounts_.values(); + return Array.from(values); + } + + public hasAccounts(): boolean { + return this.accounts_.size > 0; + } + + public async addAccount(account: Account, addToDb: boolean = true): Promise { + this.accounts_.set(account.dbAccount.id, account); + if (addToDb) { + await this.dbManager_.createItem(account.dbAccount); + } + } + + public async updateAccount(account: Account) { + await this.dbManager_.updateItem(account.dbAccount); + } + + public async recreateDatabase() { + await this.dbManager_.dropDatabase(); + } + + private async loadAccounts(): Promise { + let dbAccounts = await this.dbManager_.getItems(DBAccountTableInfo); + for (let dbAccount of dbAccounts) { + await this.addAccount(new Account(dbAccount), false); + } + + if (dbAccounts.length === 0) { + await this.migrateAccount(); + } else { + Logger.Info(TAG, `loadAccounts: ${dbAccounts.length} accounts loaded`); + } + } + + /** + * No accounts found in database, migrate from preferences if possible + */ + private async migrateAccount() { + let prefServer = NCommon.Preference.getPref('login.server', ''); + let prefUsername = NCommon.Preference.getPref('login.username', ''); + let prefPassword = NCommon.Preference.getPref('login.appPassword', ''); + if (prefServer.length > 0 && prefUsername.length > 0 && prefPassword.length > 0) { + Logger.Info(TAG, `migrateAccount: no accounts found, migrate default account '${prefUsername}@${prefServer}' from preferences`); + let account = Account.createNewAccount(prefServer, prefUsername, prefPassword); + await this.addAccount(account); + } else { + Logger.Info(TAG, `migrateAccount: no accounts found, possibly first run`); + } + } +} + +export async function InitialiseNextcloud(context: Context): Promise { + Logger.Info(TAG, `init`); + NCommon.Initialise(context); + Nextcloud = new NextcloudImpl(); + await Nextcloud.initialize(context); +} + + +export let Nextcloud: NextcloudImpl; diff --git a/nextcloud/src/main/ets/SyncEngine/Category.ets b/nextcloud/src/main/ets/SyncEngine/Category.ets new file mode 100644 index 0000000..68fedf4 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Category.ets @@ -0,0 +1,142 @@ +import { ImageListDataSource } from './ImageListDataSource'; +import { PhotoItem, UploadStatus } from './PhotoItem'; +import { LocalFolder } from '../Database/Types/LocalFolder'; +import { LocalFolderType } from '../Database/DbTypes/DBLocalFolder'; +import { PhotoAssetsManager } from './PhotoAssetsManager'; +import { Logger } from '../Utils/Logger'; +import { RemoteFolder } from '../Database/Types/RemoteFolder'; +import { CreateSession, IsRemotePathExists } from '../webdav/TransferUtils'; + +const TAG = 'models/Category'; + + +export enum AddAssetResult { + Added, + Unmatched, + Throttled +} + +export enum AddPosition { + Append, + Prepend +} + +const BATCH_SIZE = 75; + +export class Category { + visibleItems: ImageListDataSource = new ImageListDataSource(); + localFolder: LocalFolder; + + constructor(localFolder: LocalFolder) { + if (localFolder.folderType !== LocalFolderType.PhotoAlbum) { + throw Error('local folder type incorrect'); + } + this.localFolder = localFolder; + this.loadNextBatch(); + } + + public get name() { + return this.localFolder.alias; + } + + public get instantUploadEnabled() { + return this.localFolder.dbLocalFolder.config.instantUploadEnabled; + } + + public set instantUploadEnabled(value: boolean) { + this.localFolder.dbLocalFolder.config.instantUploadEnabled = value; + } + + public setAlias(newAlias: string) { + this.localFolder.alias = newAlias; + } + + tryAddAsset(asset: PhotoItem, position: AddPosition): AddAssetResult { + if (!this.matchesAsset(asset)) { + Logger.Error(TAG, `tryAddAsset: asset albumId ${asset.ownerAlbumId} doesn't match category albumId ${this.localFolder.albumId}`); + return AddAssetResult.Unmatched; + } + + switch (position) { + case AddPosition.Append: { + this.visibleItems.addAsset(asset); + return AddAssetResult.Added; + } + case AddPosition.Prepend: { + this.visibleItems.prependAsset(asset); + return AddAssetResult.Added; + } + default: { + Logger.Error(TAG, `tryAddAsset: unknown position ${position}`); + return AddAssetResult.Unmatched; + } + } + } + + async loadNextBatch(): Promise { + const offset = this.visibleItems.totalCount(); + try { + let items = await PhotoAssetsManager.loadNextBatchFromAlbumId(this.localFolder.albumId, offset, BATCH_SIZE); + for (let item of items) { + this.visibleItems.addAsset(item); + } + return items.length; + } catch (error) { + Logger.Error(TAG, `loadNextBatch: failed: ${JSON.stringify(error)}`); + return 0; + } + } + + public async uploadAllAssets(remoteFolder: RemoteFolder): Promise { + const BATCH_SIZE = 16; + Logger.Info(TAG, `uploadAllAssets: enter`); + + let jobs: Promise[] = []; + let totalCount = this.visibleItems.totalCount(); + let i = 0; + let continuesExistance = 0; + while (true) { + // to the current end + if (i === totalCount) { + const nextBatchSize = await this.loadNextBatch(); + if (nextBatchSize === 0) { + Logger.Info(TAG, `uploadAllAssets: reached to the end`); + break; + } + Logger.Info(TAG, `uploadAllAssets: continue from ${i} to ${totalCount}+${nextBatchSize}`); + totalCount = this.visibleItems.totalCount(); + } + Logger.Info(TAG, `uploadAllAssets: checking for ${i}/${totalCount} photo item`); + const photoItem = this.visibleItems.getData(i++); + photoItem.uploadStatus = UploadStatus.Pending; + const uploadData = photoItem.getUploadJobData(this.name, remoteFolder); + const fullRemotePath = '/' + remoteFolder.remotePath + '/' + uploadData.relativePath + '/' + uploadData.fileName; + + let exists: boolean; + { + const rcpSession = CreateSession(remoteFolder.account); + exists = await IsRemotePathExists(rcpSession, remoteFolder.account, fullRemotePath); + rcpSession.close(); + } + Logger.Info(TAG, `uploadAllAssets: relative path '${fullRemotePath}' exists: ${exists}`); + + if (exists) { + continuesExistance++; + photoItem.uploadStatus = UploadStatus.Existed; + if (continuesExistance === BATCH_SIZE) { + Logger.Info(TAG, `uploadAllAssets: last ${BATCH_SIZE} exists, complete`); + break; + } + } else { + continuesExistance = 0; + photoItem.uploadStatus = UploadStatus.Pending; + let job = photoItem.getUploadJob(this.name, remoteFolder); + await job.start(); + } + } + } + + private matchesAsset(asset: PhotoItem): boolean { + return asset.ownerAlbumId === this.localFolder.albumId; + } +} diff --git a/entry/src/main/ets/models/ImageListDataSource.ets b/nextcloud/src/main/ets/SyncEngine/ImageListDataSource.ets similarity index 62% rename from entry/src/main/ets/models/ImageListDataSource.ets rename to nextcloud/src/main/ets/SyncEngine/ImageListDataSource.ets index e6b9a23..669416d 100644 --- a/entry/src/main/ets/models/ImageListDataSource.ets +++ b/nextcloud/src/main/ets/SyncEngine/ImageListDataSource.ets @@ -1,4 +1,4 @@ -import { BasicDataSource } from 'nextcloud'; +import { BasicDataSource } from '../Utils/BasicDatasource'; import { PhotoItem } from './PhotoItem'; @@ -7,9 +7,12 @@ export class ImageListDataSource extends BasicDataSource { super(); } + public prependAsset(asset: PhotoItem) { + this.prependItem(asset); + } + public addAsset(asset: PhotoItem) { - this.entries.push(asset); - this.notifyDataAdd(this.entries.length - 1); + this.appendItem(asset); } public addWithoutNotify(asset: PhotoItem) { diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets new file mode 100644 index 0000000..918aab2 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/BaseJob.ets @@ -0,0 +1,76 @@ +import { Logger } from '../../Index'; + +export enum JobState { + PENDING = 'pending', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + FAILED = 'failed' +} + +export interface JobData {} + +type JobStateListener = (job: BaseJob, state: JobState) => void; + +export class BaseJob { + public readonly jobName: string; + protected readonly TAG: string; + protected state: JobState = JobState.PENDING; + private stateListeners: Array = []; + private stateHistory: JobState[] = []; + + constructor(name: string) { + this.jobName = name; + this.TAG = `Jobs/${this.jobName}`; + this.stateHistory.push(JobState.PENDING); + } + + public get jobState(): string { + return this.state; + } + + public addStateListener(listener: JobStateListener): void { + this.stateListeners.push(listener as JobStateListener); + for (const state of this.stateHistory) { + listener(this, state); + } + } + + public removeStateListener(listener?: JobStateListener): void { + if (listener === undefined) { + this.stateListeners = []; + } else { + this.stateListeners = this.stateListeners.filter(l => l !== listener); + } + } + + public start(): Promise { + this.setState(JobState.IN_PROGRESS); + let promise = new Promise(async (resolve, reject) => { + try { + Logger.Info(this.TAG, `Job ${this.jobName} started.`); + await this.onWork(); + setTimeout(() => { + this.setState(JobState.COMPLETED); + Logger.Info(this.TAG, `Job ${this.jobName} completed.`); + resolve(); + }, 1000); + } catch (error) { + this.setState(JobState.FAILED); + Logger.Error(this.TAG, `Job ${this.jobName} failed with error: ${error}`); + reject(error); + } + }); + + return promise; + } + + protected async onWork(): Promise { + throw new Error(`onWork method not implemented for job ${this.jobName}`); + } + + private setState(newState: JobState) { + this.state = newState; + this.stateHistory.push(newState); + this.stateListeners.forEach(listener => listener(this, newState)); + } +} diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets new file mode 100644 index 0000000..cc68336 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/DownloadTransferJob.ets @@ -0,0 +1,46 @@ +import { TransferJob, TransferJobData } from './TransferJob'; +import { Logger } from '../../Utils/Logger'; +import { rcp } from '@kit.RemoteCommunicationKit'; +import { TransferType } from '../TransferManagerEventIPC'; +import { LocalFolder } from '../../Database/Types/LocalFolder'; + +export interface DownloadJobData extends TransferJobData { + /** + * The local folder involved in the transfer + */ + localFolder: LocalFolder; + + /** + * The relative path of the file being transferred within the folder structure, which can be used to derive the full paths: + * localPath = localFolder.path + relativePath + * remotePath = remoteFolder.path + relativePath + */ + relativePath: string; +} + +@ObservedV2 +export class DownloadTransferJob extends TransferJob { + @Trace public localFolder: LocalFolder; + @Trace public relativePath: string; + + constructor(id: number, data: DownloadJobData) { + super(id, TransferType.Download, data); + this.localFolder = data.localFolder; + this.relativePath = data.relativePath; + } + + protected async onTransferWork(session: rcp.Session) { + Logger.Info(this.TAG, `onTransferWork enter`); + const localFullPath = this.localFolder.getFullPath(this.relativePath); + const remoteFullPath = this.remoteFolder.getFullPath(this.relativePath); + + let downloadToFileOptions: rcp.DownloadToFile = { + kind: "file", + file: localFullPath, + }; + + + await session.downloadToFile(this.remoteFolder.account.remoteBaseDirUrl() + remoteFullPath, downloadToFileOptions); + + } +} diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/Index.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/Index.ets new file mode 100644 index 0000000..fd07bd7 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/Index.ets @@ -0,0 +1 @@ +export { MkColJob } from './MkColJob'; diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/MkColJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/MkColJob.ets new file mode 100644 index 0000000..61d6f6d --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/MkColJob.ets @@ -0,0 +1,7 @@ +import { BaseJob } from './BaseJob'; + +export class MkColJob extends BaseJob { + async doWork(): Promise { + return; + } +} \ No newline at end of file diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets new file mode 100644 index 0000000..6b48b31 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/TransferJob.ets @@ -0,0 +1,128 @@ +import { RemoteFolder } from '../../Database/Types/RemoteFolder'; +import { Logger } from '../../Utils/Logger'; +import { CreateSession } from '../../webdav/TransferUtils'; +import { + publishTransferEvent, + TransferManagerEventCode, + TransferType, +} from '../TransferManagerEventIPC'; +import { BaseJob, JobData } from './BaseJob'; +import { rcp } from '@kit.RemoteCommunicationKit'; +// +export interface TransferJobData extends JobData { + fileName: string; + + /** + * The remote folder involved in the transfer + */ + remoteFolder: RemoteFolder; +} + +@ObservedV2 +export class TransferJob extends BaseJob { + readonly id: number; + public readonly type: TransferType; + public readonly filename: string; + @Trace public totalSize: number = 1; + @Trace public transferredSize: number = 0; + @Trace public remoteFolder: RemoteFolder; + @Trace protected historicalBatchesTransferredSize: number = 0; + private readonly sessionExtraHeaders: Map = new Map(); + + constructor(id: number, type: TransferType, data: TransferJobData, extraHeaders?: Map) { + super(`TransferJob/${TransferType[type]}/${data.fileName}`); + this.id = id; + this.type = type; + this.filename = data.fileName; + this.remoteFolder = data.remoteFolder; + if (extraHeaders) { + this.sessionExtraHeaders = extraHeaders; + } + } + + public get progressPercentText() { + let progress = this.transferredSize / this.totalSize; + return `${(progress * 100).toFixed(1)}%`; + } + + protected async onWork() { + Logger.Info(this.TAG, `doWork enter`); + + const eventsHandler: rcp.HttpEventsHandler = { + onDownloadProgress: (totalBytes: number, transferredBytes: number) => { + this.onDownloadProgress(transferredBytes, totalBytes); + }, + onUploadProgress: (_totalBytes: number, transferredBytes: number) => { + this.onUploadProgress(transferredBytes); + } + }; + + let session = CreateSession(this.remoteFolder.account, this.sessionExtraHeaders, eventsHandler); + if (this.type === TransferType.Upload) { + // Upload job - total size is known beforehand, notify task created immediately + this.notifyTaskCreated(); + } + + const timerId = setTimeout(() => { + publishTransferEvent({ + code: TransferManagerEventCode.TASK_PROGRESS, + taskId: this.id, + transferredSize: this.transferredSize + }) + }, 500); + + try { + await this.onTransferWork(session); + session.close(); + } catch (error) { + Logger.Error(this.TAG, `onTransferWork failed, error: ${JSON.stringify(error)}`); + session.close(); + throw error as Error; + } finally { + clearTimeout(timerId); + } + + this.transferredSize = this.totalSize; + publishTransferEvent({ + code: TransferManagerEventCode.TASK_COMPLETED, + taskId: this.id, + }) + } + + protected async onTransferWork(_session: rcp.Session) { + throw Error("onTransferWork not implemented"); + } + + protected onDownloadProgress(transferredBytes: number, totalBytes: number) { + this.transferredSize = transferredBytes; + if (this.totalSize === 0 && totalBytes > 0) { + this.totalSize = totalBytes; + // First time we get total size, notify task created + this.notifyTaskCreated(); + } else { + publishTransferEvent({ + code: TransferManagerEventCode.TASK_PROGRESS, + taskId: this.id, + transferredSize: this.transferredSize + }) + } + } + + protected onUploadProgress(transferredBytes: number) { + this.transferredSize = this.historicalBatchesTransferredSize + transferredBytes; + } + + private notifyTaskCreated() { + publishTransferEvent({ + code: TransferManagerEventCode.TASK_CREATED, + taskId: this.id, + task: { + id: this.id, + type: this.type, + fileName: this.filename, + totalSize: this.totalSize, + transferredSize: this.transferredSize + }, + }); + } +} diff --git a/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets b/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets new file mode 100644 index 0000000..2d58cb9 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/Jobs/UploadTransferJob.ets @@ -0,0 +1,170 @@ +import { CreatePath, IsRemotePathExists } from '../../webdav/TransferUtils'; +import { TransferJob, TransferJobData } from './TransferJob'; +import { fileIo } from '@kit.CoreFileKit'; +import { Logger } from '../../Utils/Logger'; +import { rcp } from '@kit.RemoteCommunicationKit'; +import { TransferType } from '../TransferManagerEventIPC'; +import { NCommon } from '../../Common'; +export interface UploadCallback { + jobCreated, + aboutToUpload, +} + +export interface UploadJobData extends TransferJobData { + /** + * Local item modified time in milliseconds since epoch + */ + mTime: number; + + /** + * Uri of the local item + */ + uri: string; + + /** + * The relative path of the file being transferred within the folder structure, which can be used to derive the full paths: + * localPath = localFolder.path + relativePath + * remotePath = remoteFolder.path + relativePath + */ + relativePath: string; +} + +@ObservedV2 +export class UploadTransferJob extends TransferJob { + private uploadStatusCode: number = 0; + private readonly uri: string; + private readonly file: fileIo.File; + private readonly fileStat: fileIo.Stat; + private readonly relativePath: string; + + constructor(id: number, data: UploadJobData) { + let extraHeaders = new Map(); + extraHeaders.set('X-OC-MTime', `${Math.round(data.mTime / 1000)}`); + super(id, TransferType.Upload, data, extraHeaders); + this.relativePath = data.relativePath; + this.uri = data.uri; + try { + this.fileStat = fileIo.statSync(this.uri); + this.file = fileIo.openSync(this.uri); + this.totalSize = this.fileStat.size; + } catch (error) { + Logger.Error(this.TAG, `failed to create readonly file: ${JSON.stringify(error)}`); + throw error as Error; + } + } + + public get statusCode(): number { + return this.uploadStatusCode; + } + + protected async onTransferWork(session: rcp.Session) { + Logger.Info(this.TAG, `doWork enter`); + const account = this.remoteFolder.account; + const destPath = this.remoteFolder.getFullPath(this.relativePath); + const destFile = destPath + '/' + this.filename; + + let shouldReplace = NCommon.Preference.replaceExistingFiles; + if (!shouldReplace) { + if (await IsRemotePathExists(session, account, destFile)) { + Logger.Info(this.TAG, `file already exists: ${destFile}, not replacing as per settings`); + session.close(); + this.uploadStatusCode = 204; // No Content, file already exists + return; + } + } + { + let mkdirResult = await CreatePath(this.remoteFolder.account, destPath); + if (mkdirResult !== 201 && mkdirResult !== 409 && mkdirResult !== 405) { + Logger.Error(this.TAG, `failed to create path: ${destPath}`) + this.uploadStatusCode = mkdirResult; + return; + } + } + + if (this.fileStat.size <= DEFAULT_CHUNK_SIZE) { + Logger.Info(this.TAG, `begin regular upload`); + + try { + let arrayBuffer = new ArrayBuffer(this.fileStat.size); + let readLen = await fileIo.read(this.file.fd, arrayBuffer, { length: this.fileStat.size }); + + let remotePath = account.remoteBaseDirUrl() + destFile; + Logger.Info(this.TAG, `Read ${readLen} bytes into ArrayBuffer for upload to ${remotePath}`); + + let resp = await session.put(remotePath, arrayBuffer); + Logger.Info(this.TAG, `upload response for ${remotePath}: ${resp.statusCode}`); + + if (resp.statusCode !== 201 && resp.statusCode !== 204) { + throw new Error(`upload failed with status code: ${resp.statusCode}`); + } + this.uploadStatusCode = resp.statusCode; + } + catch (e) { + Logger.Error(this.TAG, `upload failed: ${JSON.stringify(e)}`); + throw e as Error; + } + } else { + Logger.Info(this.TAG, `begin chunked upload`); + const tempDirectory = NCommon.RandomString(16); + const chunkedUploadRemoteDir = account.chunkedUploadBaseUrl() + tempDirectory + `/`; + session.configuration!.headers!['Destination'] = account.remoteBaseDirUrl() + destFile; + + try { + let resp = await session.fetch(new rcp.Request(chunkedUploadRemoteDir, "MKCOL", {})); + Logger.Info(this.TAG, `Created chunked upload temp directory '${tempDirectory}' with response: ${resp.statusCode}`); + if (resp.statusCode !== 201) { + throw new Error(`Failed to create chunked upload temp directory, status code: ${resp.statusCode}`); + } + } catch (e) { + Logger.Error(this.TAG, `failed to create chunked upload temp directory '${tempDirectory}': ${e}`); + await session.delete(chunkedUploadRemoteDir); + await fileIo.close(this.file.fd); + throw e as Error; + } + + let remainingSize = this.fileStat.size; + session.configuration!.headers!['OC-Total-Length'] = `${this.fileStat.size}`; + try { + let fileCount = 0; + for (let uploadedSize = 0; uploadedSize < this.fileStat.size;) { + let chunkSize = Math.min(remainingSize, DEFAULT_CHUNK_SIZE); + let arrayBuffer = new ArrayBuffer(chunkSize); + let readLen = await fileIo.read(this.file.fd, arrayBuffer, { + length: chunkSize, + offset: uploadedSize + }); + + let chunkPath = chunkedUploadRemoteDir + fileCount.toString().padStart(5, '0'); + Logger.Info(this.TAG, `Read ${readLen} bytes into ArrayBuffer for chunked upload to ${chunkPath}`); + + let resp = await session.put(chunkPath, arrayBuffer); + Logger.Info(this.TAG, `Chunk upload response for ${chunkPath}: ${resp.statusCode}`); + + if (resp.statusCode !== 201 && resp.statusCode !== 204) { + throw new Error(`Chunk upload failed with status code: ${resp.statusCode}`); + } + + uploadedSize += readLen; + remainingSize -= readLen; + fileCount++; + this.historicalBatchesTransferredSize = uploadedSize; + } + // Finalize chunked upload + let moveRequest = new rcp.Request(chunkedUploadRemoteDir + '.file', "MOVE", {}); + let resp = await session.fetch(moveRequest); + Logger.Info(this.TAG, `Finalized chunked upload with response: ${resp.statusCode}`); + if (resp.statusCode !== 201 && resp.statusCode !== 204) { + throw new Error(`Finalizing chunked upload failed with status code: ${resp.statusCode}`); + } + this.uploadStatusCode = resp.statusCode; + } catch (e) { + await session.delete(chunkedUploadRemoteDir); + Logger.Error(this.TAG, `chunked upload failed: ${e}`); + } finally { + await fileIo.close(this.file.fd); + } + } + } +} + +const DEFAULT_CHUNK_SIZE = 50 * 1024 * 1024; // 50 MB diff --git a/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets new file mode 100644 index 0000000..8e97513 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/PhotoAssetsManager.ets @@ -0,0 +1,186 @@ +import { AddPosition, Category } from './Category'; +import { PhotoItem } from './PhotoItem'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { dataSharePredicates } from '@kit.ArkData'; +import { LocalFolder } from '../Database/Types/LocalFolder'; +import { LocalFolderType } from '../Database/DbTypes/DBLocalFolder'; +import { Nextcloud } from '../Main'; +import { NCommon } from '../Common'; +import { Logger } from '../Utils/Logger'; + +const TAG = 'Utils/PhotoAssetsManager'; + +const OWNER_ALBUM_ID = 'owner_album_id'; // API22 + +@ObservedV2 +class PhotoAssetsManagerImpl { + private static readonly FETCH_COLUMNS = [ + photoAccessHelper.PhotoKeys.URI, + photoAccessHelper.PhotoKeys.PHOTO_TYPE, + photoAccessHelper.PhotoKeys.PHOTO_SUBTYPE, + photoAccessHelper.PhotoKeys.DISPLAY_NAME, + photoAccessHelper.PhotoKeys.DATE_TAKEN, + photoAccessHelper.PhotoKeys.DATE_ADDED_MS, + photoAccessHelper.PhotoKeys.SIZE, + photoAccessHelper.PhotoKeys.WIDTH, + photoAccessHelper.PhotoKeys.HEIGHT, + photoAccessHelper.PhotoKeys.TITLE, + photoAccessHelper.PhotoKeys.COVER_POSITION, + photoAccessHelper.PhotoKeys.DYNAMIC_RANGE_TYPE, + photoAccessHelper.PhotoKeys.DETAIL_TIME, + photoAccessHelper.PhotoKeys.DATE_TAKEN_MS, + OWNER_ALBUM_ID, + ]; + @Trace public categories: Map = new Map(); + + /** + * @throws + * @returns + */ + public async loadNextBatchFromAlbumId(albumId: number, offset: number, limit: number): Promise { + const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { + fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, + predicates: new dataSharePredicates.DataSharePredicates() + .equalTo(OWNER_ALBUM_ID, albumId.toString()) + .orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN) + .limit(limit, offset) + }; + + let result = await NCommon.pHelper.getAssets(FETCH_OPTIONS); + let assets = await result.getAllObjects(); + Logger.Debug(TAG, `loadNextBatchFromAlbumId:` + + `albumId: ${albumId}`, + `offset: ${offset}`, + `limit: ${limit} -> ${assets.length} assets` + ); + let items: PhotoItem[] = []; + for (let asset of assets) { + items.push(new PhotoItem(asset)); + } + return items; + } + + public async initialize(categories: Map) { + for (let item of categories) { + let localFolder = item[1]; + if (localFolder.folderType === LocalFolderType.PhotoAlbum) { + this.categories.set(localFolder.albumId, new Category(localFolder)); + } + } + + const sfm = Nextcloud.getSyncFolderManager(); + const albums = await this.getAllCategories(); + for (let albumId of albums) { + if (this.categories.has(albumId)) { + const category = this.categories.get(albumId)!; + if (category.name === `Album ${albumId}`) { + const albumName = await this.getAlbumNameById(albumId); + Logger.Warn(TAG, `updating ${albumId} name --> ${albumName}`); + category.localFolder.alias = albumName; + sfm.updateLocalFolder(category.localFolder); + } + } else { + Logger.Warn(TAG, `initialize: found new AlbumId: ${albumId}`); + let localFolder = await sfm.addLocalAlbumFolder(albumId, `Album ${albumId}`); + this.categories.set(localFolder.albumId, new Category(localFolder)); + } + } + } + + public async prependNewAsset(assetUri: string) { + Logger.Info(TAG, `prependNewAsset enter, uri: ${assetUri}`); + const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { + fetchColumns: PhotoAssetsManagerImpl.FETCH_COLUMNS, + predicates: new dataSharePredicates.DataSharePredicates() + .equalTo(photoAccessHelper.PhotoKeys.URI, assetUri), + }; + + try { + let assetResult = await NCommon.pHelper.getAssets(FETCH_OPTIONS); + if (assetResult.getCount() !== 1) { + Logger.Error(TAG, `found more than one asset with uri: ${assetUri}`); + return; + } + + let asset = await assetResult.getFirstObject(); + let photoItem = new PhotoItem(asset); + + Logger.Info(TAG, `prepending new asset: ${photoItem.displayName}, uri: ${photoItem.uri}`); + this.doAddPhotoItem(photoItem, AddPosition.Prepend); + assetResult.close(); + } catch (error) { + Logger.Error(TAG, `failed to add new asset: ${error}`); + } + } + + private async getAllCategories(): Promise { + const FETCH_OPTIONS: photoAccessHelper.FetchOptions = { + fetchColumns: [ + photoAccessHelper.PhotoKeys.URI, + photoAccessHelper.PhotoKeys.DATE_TAKEN, + photoAccessHelper.PhotoKeys.DISPLAY_NAME, + OWNER_ALBUM_ID, + ], + predicates: new dataSharePredicates.DataSharePredicates() + .orderByDesc(photoAccessHelper.PhotoKeys.DATE_TAKEN) + }; + + let albums: number[] = []; + try { + let result = await NCommon.pHelper.getAssets(FETCH_OPTIONS); + while (!result.isAfterLast()) { + const photoItem = await result.getNextObject(); + const albumId = photoItem.get(OWNER_ALBUM_ID) as number; + if (!albums.includes(albumId)) { + albums.push(albumId); + const displayName = photoItem.get(photoAccessHelper.PhotoKeys.DISPLAY_NAME); + Logger.Debug(TAG, `found albumId: ${albumId} via '${displayName}'`); + } + } + } catch (e) { + Logger.Info(TAG, `failed to get all photos: ${JSON.stringify(e)}`); + } + albums.sort((a, b) => a - b); + return albums; + } + + private async getAlbumNameById(albumId: number): Promise { + const ALBUM_URI_PREFIX = 'file://media/PhotoAlbum/'; + let result = await NCommon.pHelper.getAlbums(2048, 2049); + + const allAlbums = await result.getAllObjects(); + + for (const album of allAlbums) { + if (album.albumUri === (ALBUM_URI_PREFIX + albumId)) { + return album.albumName; + } + } + Logger.Warn(TAG, `getAlbumNameById: album id: ${albumId} not found!`); + return ''; + } + + private async doAddPhotoItem(photoItem: PhotoItem, addPosition: AddPosition) { + let ownerAlbumId = photoItem.ownerAlbumId; + let category = this.categories.get(ownerAlbumId); + if (category) { + category.tryAddAsset(photoItem, addPosition); + + if (category.localFolder.dbLocalFolder.config.instantUploadEnabled) { + let account = Nextcloud.getAllAccounts()[0]; + let remoteFolderId = account.dbAccount.config.instantUploadFolderId; + let remoteFolder = await Nextcloud.getSyncFolderManager() + .getRemoteFolderById(remoteFolderId); + Logger.Info(TAG, `instant upload remote dir: ${remoteFolderId}`); + photoItem.getUploadJob(category.name, remoteFolder!) + .start(); + } else { + Logger.Warn(TAG, + `instant upload not enabled for category ${category.name}, skipping ${photoItem.displayName}`); + } + } else { + Logger.Error(TAG, `add new category for ${ownerAlbumId} failed`); + } + } +} + +export const PhotoAssetsManager = new PhotoAssetsManagerImpl(); diff --git a/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets b/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets new file mode 100644 index 0000000..097897e --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/PhotoItem.ets @@ -0,0 +1,114 @@ +import { photoAccessHelper as PHelper } from '@kit.MediaLibraryKit'; +import { JobManager } from '../Index'; +import { image } from '@kit.ImageKit'; +import { FormatTime } from '../Utils/CommonUtils'; +import { RemoteFolder } from '../Database/Types/RemoteFolder'; +import { BaseJob, JobState } from './Jobs/BaseJob'; +import { UploadJobData, UploadTransferJob } from './Jobs/UploadTransferJob'; +import { Logger } from '../Utils/Logger'; + +const TAG = 'PhotoItem'; +const DEFAULT_FORMAT = 'yyyy/MM/'; + +export enum UploadStatus { + None, // not yet started + Pending, // scheduled, not yet transmitted + Uploading, // transmitting + Existed, // existed + Completed, // upload completed + Failed, // upload failed + FailedCreatingPath, // failed to create one or more parent path +} + +@ObservedV2 +export class PhotoItem { + @Trace uploadStatus: UploadStatus = UploadStatus.None; + private innerAsset: PHelper.PhotoAsset; + private uploadJob: Optional = undefined; + + constructor(asset: PHelper.PhotoAsset) { + this.innerAsset = asset; + } + + get displayName(): string { + return this.innerAsset.displayName ?? ''; + } + + get uri(): string { + return this.innerAsset.uri ?? ''; + } + + get dateTaken(): Date { + return new Date(this.innerAsset.get(PHelper.PhotoKeys.DATE_TAKEN_MS) as number); + } + + get dateTakenMs(): number { + return this.innerAsset.get(PHelper.PhotoKeys.DATE_TAKEN_MS) as number; + } + + get size(): number { + return this.innerAsset.get(PHelper.PhotoKeys.SIZE) as number ?? 0; + } + + get photoType(): PHelper.PhotoType { + return this.innerAsset.get(PHelper.PhotoKeys.PHOTO_TYPE) as PHelper.PhotoType; + } + + get coverPosition(): number { + return this.innerAsset.get(PHelper.PhotoKeys.COVER_POSITION) as number; + } + + get isVideo(): boolean { + return this.photoType === PHelper.PhotoType.VIDEO; + } + + get ownerAlbumId(): number { + return Number(this.innerAsset.get('owner_album_id')); + } + + async getThumbnail(size?: image.Size): Promise { + return await this.innerAsset.getThumbnail(size); + } + + public getUploadJobData(categoryName: string, remoteFolder: RemoteFolder): UploadJobData { + return { + fileName: this.displayName, + mTime: this.dateTakenMs, + uri: this.uri, + remoteFolder: remoteFolder, + relativePath: categoryName + '/' + FormatTime(this.dateTaken, DEFAULT_FORMAT) + '/', + }; + } + + public getUploadJob(categoryName: string, remoteFolder: RemoteFolder): UploadTransferJob { + Logger.Info(TAG, `getUploadJob: enter`); + if (this.uploadJob) { + return this.uploadJob; + } + + this.uploadJob = JobManager.createUploadJob(this.getUploadJobData(categoryName, remoteFolder)); + + this.uploadJob.addStateListener((baseJob: BaseJob, state: JobState) => { + switch (state) { + case JobState.PENDING: + this.uploadStatus = UploadStatus.Pending; + break; + case JobState.IN_PROGRESS: + this.uploadStatus = UploadStatus.Uploading; + break; + case JobState.COMPLETED: + case JobState.FAILED: { + let uploadJob = baseJob as UploadTransferJob; + if (uploadJob.statusCode === 201) { + this.uploadStatus = UploadStatus.Completed; + } else if (uploadJob.statusCode === 204) { + this.uploadStatus = UploadStatus.Existed; + } else { + this.uploadStatus = UploadStatus.Failed; + } + } + } + }); + return this.uploadJob; + } +} diff --git a/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets new file mode 100644 index 0000000..1a64804 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/SyncFolderManager.ets @@ -0,0 +1,180 @@ +// Manage mappings between Account -=> SyncFolder +import { Logger } from '../Utils/Logger'; +import { DBManager } from '../Database/DBManager'; +import { DBSyncConfiguration, DBSyncConfigurationTable } from '../Database/DbTypes/DBSyncConfiguration'; +import { LocalFolder } from '../Database/Types/LocalFolder'; +import { RemoteFolder } from '../Database/Types/RemoteFolder'; +import { DBLocalFolder, DBLocalFolderConfig, DBLocalFolderTable, LocalFolderType } from '../Database/DbTypes/DBLocalFolder'; +import { DBRemoteFolder, DBRemoteFolderTable } from '../Database/DbTypes/DBRemoteFolder'; +import { Account } from '../Account/Account'; +import { SyncConfiguration } from '../Database/Types/SyncConfiguration'; +import { Nextcloud } from '../Main'; + +const TAG = 'SyncEngine/SyncFolderManager'; + +@ObservedV2 +export class SyncFolderManager { + @Trace private configurations_: Map = new Map(); + @Trace private localFolders_: Map = new Map(); + @Trace private remoteFolders_: Map = new Map(); + private dbManager_: DBManager; + + constructor(dbManager: DBManager) { + Logger.Info(TAG, `constructor`); + this.dbManager_ = dbManager; + } + + public get allLocalFolders(): Map { + return this.localFolders_; + } + + public get allRemoteFolders(): Map { + return this.remoteFolders_; + } + + public get allSyncConfigurations(): Map { + return this.configurations_; + } + + public async startWatchForChanges(): Promise { + Logger.Info(TAG, `startWatchForChanges: enter`); + } + + public async addLocalAlbumFolder(albumId: number, alias: string): Promise { + Logger.Info(TAG, `addLocalAlbumFolder: enter`); + let config = new DBLocalFolderConfig({}); + let dbLocalFolder = new DBLocalFolder(0, albumId.toString(), alias, LocalFolderType.PhotoAlbum, config); + let localFolder = new LocalFolder(dbLocalFolder); + let insertedId = await this.dbManager_.createItem(dbLocalFolder); + dbLocalFolder.id = insertedId!; + this.localFolders_.set(dbLocalFolder.id, localFolder); + Logger.Info(TAG, `addLocalAlbumFolder: added local folder id ${dbLocalFolder.id} with albumId ${albumId}`); + return localFolder; + } + + public async addLocalFolder(localPath: string, alias: string): Promise { + Logger.Info(TAG, `addLocalFolder: enter`); + let config = new DBLocalFolderConfig({}) + let dbLocalFolder = new DBLocalFolder(0, localPath, alias, LocalFolderType.Regular, config); + let localFolder = new LocalFolder(dbLocalFolder); + let insertedId = await this.dbManager_.createItem(dbLocalFolder); + dbLocalFolder.id = insertedId!; + this.localFolders_.set(dbLocalFolder.id, localFolder); + Logger.Info(TAG, `addLocalFolder: added local folder id ${dbLocalFolder.id} with path ${localPath}`); + return localFolder; + }; + + public async addRemoteFolder(account: Account, remotePath: string, alias: string): Promise { + Logger.Info(TAG, `addRemoteFolder: enter`); + let dbRemoteFolder = new DBRemoteFolder(0, account.id, remotePath, alias); + let remoteFolder = new RemoteFolder(account, dbRemoteFolder); + let insertedId = await this.dbManager_.createItem(dbRemoteFolder); + dbRemoteFolder.id = insertedId!; + this.remoteFolders_.set(dbRemoteFolder.id, remoteFolder); + Logger.Info(TAG, `addRemoteFolder: added remote folder id ${dbRemoteFolder.id} with path ${remotePath}`); + return remoteFolder; + } + + public async addSyncConfiguration( + localFolder: LocalFolder, + remoteFolder: RemoteFolder + ): Promise { + Logger.Info(TAG, `addSyncConfiguration: enter`); + let dbSyncConfiguration = new DBSyncConfiguration(0, localFolder.id, remoteFolder.id); + let insertedId = await this.dbManager_.createItem(dbSyncConfiguration); + dbSyncConfiguration.id = insertedId!; + const syncConfig = new SyncConfiguration(localFolder, remoteFolder, dbSyncConfiguration); + this.configurations_.set(dbSyncConfiguration.id, syncConfig); + Logger.Info(TAG, `${JSON.stringify(this.configurations_.get(dbSyncConfiguration.id))}`); + Logger.Info( + TAG, + `addSyncConfiguration: added configuration id ${insertedId} with local folder id ${localFolder.id} and remote folder id ${remoteFolder.id}` + ); + return syncConfig; + } + + public async removeSyncConfiguration(syncConfiguration: SyncConfiguration): Promise { + Logger.Info(TAG, `removeSyncConfiguration: enter for configuration id ${syncConfiguration.id}`); + await this.dbManager_.deleteItemById(syncConfiguration.id, syncConfiguration.dbSyncConfiguration); + this.configurations_.delete(syncConfiguration.id); + Logger.Info(TAG, `removeSyncConfiguration: removed configuration id ${syncConfiguration.id}`); + } + + public async loadSyncFolders(): Promise { + Logger.Info(TAG, `loadSyncFolders: enter`); + let localFolders = await this.dbManager_.getItems(DBLocalFolderTable); + for (let localFolder of localFolders) { + this.localFolders_.set(localFolder.id, new LocalFolder(localFolder)); + } + + let remoteFolders = await this.dbManager_.getItems(DBRemoteFolderTable); + for (let remoteFolder of remoteFolders) { + let account = Nextcloud.getAccountById(remoteFolder.accountId); + this.remoteFolders_.set(remoteFolder.id, new RemoteFolder(account, remoteFolder)); + } + + let dbSyncConfiguration = await this.dbManager_.getItems(DBSyncConfigurationTable); + for (let conf of dbSyncConfiguration) { + const localFolder = await this.getLocalFolderById(conf.localFolderId); + const remoteFolder = await this.getRemoteFolderById(conf.remoteFolderId); + if (!localFolder || !remoteFolder) { + Logger.Error( + TAG, + `loadSyncFolders: failed to load sync configuration id ${conf.id} due to missing local or remote folder` + ); + continue; + } + + const syncConfig = new SyncConfiguration(localFolder, remoteFolder, conf); + this.configurations_.set(conf.id, syncConfig); + Logger.Info( + TAG, + `loadSyncFolders: loaded sync configuration id ${conf.id} with local folder id ${localFolder.id} and remote folder id ${remoteFolder.id}` + ); + } + Logger.Info(TAG, `loadSyncFolders: loaded total ${this.configurations_.size} sync configurations`); + } + + public async getRemoteFolderById(id: number): Promise { + let existing = this.remoteFolders_.get(id); + if (existing) { + return existing; + } + + let dbRemoteFolder = await this.dbManager_.getItemById(id, DBRemoteFolderTable); + if (!dbRemoteFolder) { + Logger.Error(TAG, `getRemoteFolderById: remote folder id ${id} not found`); + return null; + } + + let account = Nextcloud.getAccountById(dbRemoteFolder.accountId); + existing = new RemoteFolder(account, dbRemoteFolder); + this.remoteFolders_.set(id, existing); + return existing; + } + + public async updateLocalFolder(localFolder: LocalFolder) { + Logger.Info(TAG, `updateLocalFolder enter: id = ${localFolder.id}`); + let result = await this.dbManager_.updateItem(localFolder.dbLocalFolder); + Logger.Info(TAG, `updateLocalFolder result: ${result}`); + } + + private async getLocalFolderById(id: number): Promise { + let existing = this.localFolders_.get(id); + if (existing) { + return existing; + } + + let dbLocalFolder = + await this.dbManager_.getItemById(id, DBLocalFolderTable); + if (!dbLocalFolder) { + Logger.Error(TAG, `getLocalFolderById: local folder id ${id} not found`); + return null; + } + + Logger.Info(TAG, `getLocalFolderById: ${dbLocalFolder.id}`); + existing = new LocalFolder(dbLocalFolder); + this.localFolders_.set(id, existing); + return existing; + } +} diff --git a/nextcloud/src/main/ets/SyncEngine/TransferManager.ets b/nextcloud/src/main/ets/SyncEngine/TransferManager.ets new file mode 100644 index 0000000..c4f03b0 --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/TransferManager.ets @@ -0,0 +1,42 @@ +import { BasicDataSource } from '../Utils/BasicDatasource'; +import { Logger } from '../Utils/Logger'; +import { BaseJob } from './Jobs/BaseJob'; +import { DownloadJobData, DownloadTransferJob } from './Jobs/DownloadTransferJob'; +import { TransferJob } from './Jobs/TransferJob'; +import { UploadJobData, UploadTransferJob } from './Jobs/UploadTransferJob'; + +const TAG = 'SyncEngine/TransferManager'; + +export class JobManagerImpl { + private tasks_: BasicDataSource; + private idSeq_ = 0; + + constructor() { + Logger.Info(TAG, `created TransferManager`); + this.tasks_ = new BasicDataSource(); + } + + public get tasks() { + return this.tasks_; + } + + public addDownloadJob(jobData: DownloadJobData): DownloadTransferJob { + let id = this.getNextId(); + let job = new DownloadTransferJob(id, jobData); + this.tasks_.prependItem(job); + return job; + } + + public createUploadJob(jobData: UploadJobData): UploadTransferJob { + const id = this.getNextId(); + const job = new UploadTransferJob(id, jobData); + this.tasks_.prependItem(job); + return job; + } + + private getNextId(): number { + return this.idSeq_++; + } +} + +export let JobManager = new JobManagerImpl(); diff --git a/nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets b/nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets new file mode 100644 index 0000000..ec0e80e --- /dev/null +++ b/nextcloud/src/main/ets/SyncEngine/TransferManagerEventIPC.ets @@ -0,0 +1,65 @@ +import { commonEventManager } from '@kit.BasicServicesKit'; +import { Logger } from '../Index'; + +const TAG = 'SyncEngine/TransferManager/IPC'; + +export enum TransferType { + Download, + Upload, +} + +export interface TransferJobBasicInfo { + id: number; + type: TransferType; + fileName: string; + totalSize: number; + transferredSize: number; +} + +export enum TransferManagerEventCode { + TASK_CREATED = 1, + TASK_PROGRESS = 2, + TASK_COMPLETED = 3, + TASK_FAILED = 4, +} + +interface TransferManagerEvent_TaskCreate { + code: TransferManagerEventCode.TASK_CREATED; + taskId: number; + task: TransferJobBasicInfo; +}; + +interface TransferManagerEvent_TaskProgress { + code: TransferManagerEventCode.TASK_PROGRESS; + taskId: number; + transferredSize: number; +}; + +interface TransferManagerEvent_TaskCompleted { + code: TransferManagerEventCode.TASK_COMPLETED; + taskId: number; +}; + +interface TransferManagerEvent_TaskFailed { + code: TransferManagerEventCode.TASK_FAILED; + taskId: number; +}; + +export type TransferManagerEvent = + TransferManagerEvent_TaskCreate + | TransferManagerEvent_TaskProgress + | TransferManagerEvent_TaskCompleted + | TransferManagerEvent_TaskFailed; + +export const TRANSFER_MANAGER_COMMON_EVENT_NAME: string = 'Nextcloud.TransferManager.Events'; + +function noop() { +} + +export function publishTransferEvent(event: TransferManagerEvent) { + let options: commonEventManager.CommonEventPublishData = { + data: JSON.stringify(event), + }; + Logger.Debug(TAG, `published transfer event: ${options.data}`); + commonEventManager.publish(TRANSFER_MANAGER_COMMON_EVENT_NAME, options, noop); +} diff --git a/nextcloud/src/main/ets/BasicDatasource.ets b/nextcloud/src/main/ets/Utils/BasicDatasource.ets similarity index 82% rename from nextcloud/src/main/ets/BasicDatasource.ets rename to nextcloud/src/main/ets/Utils/BasicDatasource.ets index e7da625..ad291b7 100644 --- a/nextcloud/src/main/ets/BasicDatasource.ets +++ b/nextcloud/src/main/ets/Utils/BasicDatasource.ets @@ -15,6 +15,22 @@ export class BasicDataSource implements IDataSource { return this.entries.slice(start, start + count); } + public prependItem(item: T): T { + this.entries.unshift(item); + this.notifyDataAdd(0); + return this.entries[0]; + } + + public appendItem(item: T) { + this.entries.push(item); + this.notifyDataAdd(this.entries.length - 1); + } + + public clear() { + this.entries.length = 0; + this.notifyDataReload(); + } + registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); diff --git a/nextcloud/src/main/ets/Utils/CommonUtils.ets b/nextcloud/src/main/ets/Utils/CommonUtils.ets new file mode 100644 index 0000000..dccba62 --- /dev/null +++ b/nextcloud/src/main/ets/Utils/CommonUtils.ets @@ -0,0 +1,32 @@ +import util from "@ohos.util"; + +let Base64Util = new util.Base64Helper(); +let Utf8Encoder = util.TextEncoder.create('utf-8'); + +export async function AsyncSleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +} + +export function Base64Encode(msg: string): string { + return Base64Util.encodeToStringSync(Utf8Encoder.encodeInto(msg)); +} + +export function FormatTime(date: Date, format: string) { + // make sure the path format ends with a slash + if (!format.endsWith('/')) { + format += '/'; + } + + let result = format; + result = result.replace('yyyy', date.getFullYear().toString()); + result = result.replace('MM', (date.getMonth() + 1).toString().padStart(2, '0')); + result = result.replace('dd', date.getDate().toString().padStart(2, '0')); + result = result.replace('HH', date.getHours().toString().padStart(2, '0')); + result = result.replace('mm', date.getMinutes().toString().padStart(2, '0')); + result = result.replace('ss', date.getSeconds().toString().padStart(2, '0')); + return result; +} diff --git a/nextcloud/src/main/ets/Utils/FilesDataSource.ets b/nextcloud/src/main/ets/Utils/FilesDataSource.ets new file mode 100644 index 0000000..831884c --- /dev/null +++ b/nextcloud/src/main/ets/Utils/FilesDataSource.ets @@ -0,0 +1,27 @@ +import { BasicDataSource } from './BasicDatasource'; +import { DirectoryEntry } from '../webdav/DirEntryParserStateMachine'; +import { ListFiles } from '../webdav/Files'; +import { Account } from '../Account/Account'; + +const SHOW_DIRECTORIES_FIRST = true; + +export class FilesDataSource extends BasicDataSource { + constructor(account: Account, path: string) { + super(); + ListFiles(account, path).then(entries => { + entries = entries.sort((a: DirectoryEntry, b: DirectoryEntry) => { + if (SHOW_DIRECTORIES_FIRST) { + if (a.isDirectory && !b.isDirectory) { + return -1; + } else if (!a.isDirectory && b.isDirectory) { + return 1; + } + } + return a.name.localeCompare(b.name); + }) + + this.entries = entries; + this.notifyDataReload(); + }); + } +} diff --git a/nextcloud/src/main/ets/Utils/Logger.ts b/nextcloud/src/main/ets/Utils/Logger.ts new file mode 100644 index 0000000..bdbff8a --- /dev/null +++ b/nextcloud/src/main/ets/Utils/Logger.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import hilog from '@ohos.hilog'; + +const DOMAIN = 0xABAB; +const NEXTCLOUD_TAG = 'NextcloudApp'; + +export class Logger { + static Debug(component: string, ...message: string[]) { + hilog.debug(DOMAIN, NEXTCLOUD_TAG, `[${component}] ` + message.join(', ')) + } + + static Info(component: string, ...message: string[]) { + hilog.info(DOMAIN, NEXTCLOUD_TAG, `[${component}] ` + message.join(', ')) + } + + static Warn(component: string, ...message: string[]) { + hilog.warn(DOMAIN, NEXTCLOUD_TAG, `[${component}] ` + message.join(', ')) + } + + static Error(component: string, ...message: string[]) { + hilog.error(DOMAIN, NEXTCLOUD_TAG, `[${component}] ` + message.join(', ')) + } +} diff --git a/nextcloud/src/main/ets/Utils/Result.ets b/nextcloud/src/main/ets/Utils/Result.ets new file mode 100644 index 0000000..11fe677 --- /dev/null +++ b/nextcloud/src/main/ets/Utils/Result.ets @@ -0,0 +1,25 @@ +interface ResultOk { + kind: 'ok'; + data: T; +} + +export function Ok(data: T): ResultOk { + return { + kind: 'ok', + data: data, + }; +} + +interface ResultError { + kind: 'error'; + error: E; +} + +export function Err(error: E): ResultError { + return { + kind: 'error', + error: error, + }; +} + +export type Result = ResultOk | ResultError; diff --git a/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts b/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts index c4cbc7c..85c921f 100644 --- a/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts +++ b/nextcloud/src/main/ets/webdav/DirEntryParserStateMachine.ts @@ -1,9 +1,9 @@ import { DAV_PROPERTY_TYPES, DavPropsType, DavResponse, DavResponseRoot } from './DavTypes'; -import { Logger } from '../Logger'; +import { Logger } from '../Utils/Logger'; -const TAG = "Nextcloud-DirEntryParser"; +const TAG = 'webdav/DirEntryParserStateMachine'; -interface DirectoryEntry { +export interface DirectoryEntry { name: string; isDirectory: boolean; mime: string; @@ -23,7 +23,7 @@ enum ParserStatus { Final, }; -class DirEntryParserStateMachine { +export class DirEntryParserStateMachine { private state: ParserStatus = ParserStatus.MultiStatus; private response_root: DavResponseRoot; @@ -193,5 +193,3 @@ class DirEntryParserStateMachine { return entries; } } - -export { DirectoryEntry, DirEntryParserStateMachine }; diff --git a/nextcloud/src/main/ets/webdav/Files.ets b/nextcloud/src/main/ets/webdav/Files.ets index cbdb9e7..4bd56cc 100644 --- a/nextcloud/src/main/ets/webdav/Files.ets +++ b/nextcloud/src/main/ets/webdav/Files.ets @@ -1,20 +1,21 @@ import { rcp } from '@kit.RemoteCommunicationKit'; -import { Logger } from '../Logger'; +import { Logger } from '../Utils/Logger'; import { NCommon } from '../Common'; import { xml } from '@kit.ArkTS'; import { DirectoryEntry, DirEntryParserStateMachine } from './DirEntryParserStateMachine'; +import { Account } from '../Account/Account'; -const TAG = "Nextcloud-Files"; +const TAG = 'webdav/Files'; let session: rcp.Session | null = null; -export async function ListFiles(path: string): Promise { - path = "/remote.php/dav/files/" + NCommon.Preference.username + path; +export async function ListFiles(account: Account, path: string): Promise { + let url = account.remoteBaseDirUrl() + path; Logger.Info(TAG, "Listing files at " + path); let sessionConfiguration: rcp.SessionConfiguration = { headers: { - 'authorization': NCommon.MakeAuthorization(), + 'authorization': account.makeAuthorizationHeader(), 'user-agent': NCommon.UserAgent(), 'accept': 'text/plains' } @@ -25,7 +26,7 @@ export async function ListFiles(path: string): Promise { } let req = - new rcp.Request("https://" + NCommon.Preference.hostname + path, "PROPFIND", {}, + new rcp.Request(url, "PROPFIND", {}, '\n' + ' \n' + ' \n' + @@ -71,5 +72,6 @@ export async function ListFiles(path: string): Promise { parser.parseXml(options) - return machine.getDirEntryList(path); + const prefix = '/remote.php/dav/files/' + account.username + path; + return machine.getDirEntryList(prefix); } diff --git a/nextcloud/src/main/ets/webdav/TransferUtils.ets b/nextcloud/src/main/ets/webdav/TransferUtils.ets new file mode 100644 index 0000000..be1e8af --- /dev/null +++ b/nextcloud/src/main/ets/webdav/TransferUtils.ets @@ -0,0 +1,110 @@ +import { rcp } from '@kit.RemoteCommunicationKit'; +import { NCommon } from '../Common'; +import { Logger } from '../Utils/Logger'; +import { Account } from '../Account/Account'; + +const TAG = "webdav/Upload"; + +export function CreateSession( + account: Account, + extraHeaders?: Map, + eventsHandler?: rcp.HttpEventsHandler): rcp.Session { + let headers: rcp.RequestHeaders = {}; + + if (extraHeaders !== undefined) { + for (let item of extraHeaders) { + headers[item[0]] = item[1]; + } + } + + headers['authorization'] = account.makeAuthorizationHeader(); + headers['user-agent'] = NCommon.UserAgent(); + headers['accept'] = 'text/plains'; + + let sessionConfiguration: rcp.SessionConfiguration = { + headers: headers, + requestConfiguration: { + tracing: { + verbose: true, + infoToCollect: { + textual: true, + incomingHeader: true, + outgoingHeader: true, + incomingData: true, + outgoingData: true, + incomingSslData: true, + outgoingSslData: true + }, + collectTimeInfo: true, + httpEventsHandler: eventsHandler, + } + } + } + + try { + return rcp.createSession(sessionConfiguration); + } catch (error) { + Logger.Error(TAG, `failed to create session: ${JSON.stringify(error)}`); + throw error as Error; + } +} + +export async function IsRemotePathExists( + session: rcp.Session, + account: Account, + path: string): Promise { + let fullPath = account.remoteBaseDirUrl() + path; + Logger.Info(TAG, `IsRemotePathExists: checking if path exists: '${fullPath}'`); + + try { + let resp = await session.fetch(new rcp.Request(fullPath, "HEAD", {})); + if (resp.statusCode === 200 || resp.statusCode === 207) { + Logger.Info(TAG, "IsRemotePathExists: Path exists: " + fullPath); + return true; + } else if (resp.statusCode === 404) { + Logger.Info(TAG, "IsRemotePathExists: Path does not exist: " + fullPath); + return false; + } else { + Logger.Error(TAG, "IsRemotePathExists: Failed to check path existence: " + resp.statusCode); + return false; + } + } catch (e) { + Logger.Error(TAG, "IsRemotePathExists: failed: " + e); + return false; + } +} + +export async function CreatePath(account: Account, path: string): Promise { + let session = CreateSession(account); + if (await IsRemotePathExists(session, account, path)) { + Logger.Info(TAG, "Path already exists: " + path); + session.close(); + return 201; + } + + let segment = path.split("/"); + let respCode = -1; + for (let i = 0; i < segment.length; i++) { + let thisLevel = segment.slice(0, i + 1).join("/"); + if (!thisLevel) { + continue; + } + let fullPath = account.remoteBaseDirUrl() + thisLevel + "/"; + try { + let resp = await session.fetch(new rcp.Request(fullPath, "MKCOL", {})); + Logger.Info(TAG, `Create path '${thisLevel}', response: ${resp.statusCode}`); + respCode = resp.statusCode; + if (resp.statusCode !== 201 && resp.statusCode !== 405) { + Logger.Error(TAG, "Failed to create path: " + resp.statusCode); + session.close(); + return resp.statusCode; + } + } catch (e) { + Logger.Error(TAG, "Failed to create path: " + e); + session.close(); + return respCode; + } + } + session.close(); + return respCode; +} diff --git a/nextcloud/src/main/ets/webdav/Uploader.ets b/nextcloud/src/main/ets/webdav/Uploader.ets deleted file mode 100644 index 4f40742..0000000 --- a/nextcloud/src/main/ets/webdav/Uploader.ets +++ /dev/null @@ -1,143 +0,0 @@ -import { rcp } from '@kit.RemoteCommunicationKit'; -import { NCommon } from '../Common'; -import fs from '@ohos.file.fs'; -import { Logger } from '../Logger'; - - -const TAG = "Nextcloud-Upload"; - - -function CreateSession(extraHeaders?: Map): rcp.Session { - let headers: rcp.RequestHeaders = {}; - - if (extraHeaders !== undefined) { - for (let item of extraHeaders) { - headers[item[0]] = item[1]; - } - } - - headers['authorization'] = NCommon.MakeAuthorization(); - headers['user-agent'] = NCommon.UserAgent(); - headers['accept'] = 'text/plains'; - - let sessionConfiguration: rcp.SessionConfiguration = { - headers: headers, - requestConfiguration: { - tracing: { - httpEventsHandler: { - onUploadProgress: (progress: number) => { - Logger.Info(TAG, "Upload progress: " + progress); - }, - } - } - } - } - - try { - return rcp.createSession(sessionConfiguration); - } catch (error) { - Logger.Error(TAG, `failed to create session: ${JSON.stringify(error)}`); - throw error as Error; - } -} - -export async function Exists(session: rcp.Session, path: string, filename: string = ""): Promise { - let fullPath = "https://" + NCommon.Preference.hostname + "/remote.php/dav/files/" + NCommon.Preference.username + "/" + path + "/" + filename; - Logger.Info(TAG, "Checking if path exists: " + fullPath); - - try { - let resp = await session.fetch(new rcp.Request(fullPath, "HEAD", {})); - if (resp.statusCode === 200 || resp.statusCode === 207) { - Logger.Info(TAG, "Path exists: " + fullPath); - return true; - } else if (resp.statusCode === 404) { - Logger.Info(TAG, "Path does not exist: " + fullPath); - return false; - } else { - Logger.Error(TAG, "Failed to check path existence: " + resp.statusCode); - return false; - } - } catch (e) { - Logger.Error(TAG, "Error checking path existence: " + e); - return false; - } -} - -export async function CreatePath(path: string): Promise { - let session = CreateSession(); - if (await Exists(session, path)) { - Logger.Info(TAG, "Path already exists: " + path); - session.close(); - return 201; - } - - let segment = path.split("/"); - let respCode = -1; - for (let i = 0; i < segment.length; i++) { - let thisLevel = segment.slice(0, i + 1).join("/"); - if (!thisLevel) { - continue; - } - let fullPath = "https://" + NCommon.Preference.hostname + "/remote.php/dav/files/" + NCommon.Preference.username + "/" + thisLevel + "/"; - try { - let resp = await session.fetch(new rcp.Request(fullPath, "MKCOL", {})); - Logger.Info(TAG, `Create path '${thisLevel}', response: ${resp.statusCode}`); - respCode = resp.statusCode; - if (resp.statusCode !== 201 && resp.statusCode !== 405) { - Logger.Error(TAG, "Failed to create path: " + resp.statusCode); - session.close(); - return resp.statusCode; - } - } catch (e) { - Logger.Error(TAG, "Failed to create path: " + e); - session.close(); - return respCode; - } - } - session.close(); - return respCode; -} - -export async function UploadFile(filename: string, mTime: number, path: string, uri: string): Promise { - let extraHeaders = new Map(); - extraHeaders.set('X-OC-MTime', `${Math.round(mTime / 1000)}`); - let session = CreateSession(extraHeaders); - - if (!NCommon.Preference.replaceExistingFiles) { - if (await Exists(session, path, filename)) { - Logger.Info(TAG, `file already exists: ${filename}, not replacing as per settings`); - session.close(); - return 204; // No Content, file already exists - } - } - - - let file: fs.File; - try { - file = await fs.open(uri); - Logger.Debug(TAG, "File opened"); - } catch (error) { - Logger.Error(TAG, `failed to create readonly file: ${JSON.stringify(error)}`); - throw error as Error; - } - - let fullpath = "https://" + NCommon.Preference.hostname + "/remote.php/dav/files/" + NCommon.Preference.username + "/" + path + "/" + filename; - Logger.Info(TAG, "Uploading file to " + fullpath); - - let ret = -1; - try { - let resp = await session.put(fullpath, new rcp.UploadFromFile(file)); - Logger.Info(TAG, "Upload response: " + resp.statusCode); - ret = resp.statusCode; - } catch (e) { - Logger.Error(TAG, "Failed to upload file: " + e); - } - - try { - await fs.close(file); - } catch (error) { - Logger.Warn(TAG, `failed to close the file: ${JSON.stringify(error)}`); - } - session.close(); - return ret; -} diff --git a/nextcloud/src/main/module.json5 b/nextcloud/src/main/module.json5 index 37f8f13..aac657b 100644 --- a/nextcloud/src/main/module.json5 +++ b/nextcloud/src/main/module.json5 @@ -10,6 +10,10 @@ "requestPermissions": [ { "name": "ohos.permission.INTERNET" + }, + { + "name": "ohos.permission.READ_IMAGEVIDEO", + "reason": "$string:page_show", } ] } diff --git a/oh-package-lock.json5 b/oh-package-lock.json5 index a974b0f..5228468 100644 --- a/oh-package-lock.json5 +++ b/oh-package-lock.json5 @@ -11,14 +11,14 @@ }, "packages": { "@ohos/hamock@1.0.0": { - "name": "", + "name": "@ohos/hamock", "version": "1.0.0", "integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==", "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har", "registryType": "ohpm" }, "@ohos/hypium@1.0.19": { - "name": "", + "name": "@ohos/hypium", "version": "1.0.19", "integrity": "sha512-cEjDgLFCm3cWZDeRXk7agBUkPqjWxUo6AQeiu0gEkb3J8ESqlduQLSIXeo3cCsm8U/asL7iKjF85ZyOuufAGSQ==", "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.19.har", diff --git a/oh-package.json5 b/oh-package.json5 index f10aa8e..815f06d 100644 --- a/oh-package.json5 +++ b/oh-package.json5 @@ -1,10 +1,10 @@ { - "modelVersion": "5.0.1", + "modelVersion": "6.0.0", "description": "Nextcloud Client for HarmonyOS", "dependencies": { }, "devDependencies": { "@ohos/hypium": "1.0.19", - "@ohos/hamock": "1.0.0", + "@ohos/hamock": "1.0.0" } -} +} \ No newline at end of file diff --git a/watch_logs.cache.json b/watch_logs.cache.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/watch_logs.cache.json @@ -0,0 +1 @@ +[] \ No newline at end of file