diff --git a/clientapp/android/.idea/misc.xml b/clientapp/android/.idea/misc.xml index 824785de..adb8ae0f 100644 --- a/clientapp/android/.idea/misc.xml +++ b/clientapp/android/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/clientapp/android/.idea/vcs.xml b/clientapp/android/.idea/vcs.xml index 6dac12af..58676450 100644 --- a/clientapp/android/.idea/vcs.xml +++ b/clientapp/android/.idea/vcs.xml @@ -2,19 +2,6 @@ - - - - - - - - - - - - - diff --git a/clientapp/android/app/build.gradle.kts b/clientapp/android/app/build.gradle.kts index dfb9c354..b2e90c6d 100644 --- a/clientapp/android/app/build.gradle.kts +++ b/clientapp/android/app/build.gradle.kts @@ -38,12 +38,6 @@ android { kotlinOptions { jvmTarget = "1.8" } - externalNativeBuild { - cmake { - path = file("src/main/cpp/CMakeLists.txt") - version = "3.22.1" - } - } buildFeatures { viewBinding = true buildConfig = true @@ -85,6 +79,8 @@ dependencies { implementation(libs.androidx.room.ktx) implementation(libs.hilt) implementation(libs.androidx.junit.ktx) + implementation(libs.ktor.network) + implementation(libs.google.gson) ksp(libs.androidx.room.compiler) ksp(libs.hilt.compiler) diff --git a/clientapp/android/app/src/main/cpp/.clang-tidy b/clientapp/android/app/src/main/cpp/.clang-tidy deleted file mode 100644 index b09e4d85..00000000 --- a/clientapp/android/app/src/main/cpp/.clang-tidy +++ /dev/null @@ -1,6 +0,0 @@ ---- -Checks: " - -cppcoreguidelines-pro-type-vararg - -cppcoreguidelines-avoid-do-while - -misc-no-recursion -" diff --git a/clientapp/android/app/src/main/cpp/CMakeLists.txt b/clientapp/android/app/src/main/cpp/CMakeLists.txt deleted file mode 100644 index 2c0cece0..00000000 --- a/clientapp/android/app/src/main/cpp/CMakeLists.txt +++ /dev/null @@ -1,48 +0,0 @@ - -# For more information about using CMake with Android Studio, read the -# documentation: https://d.android.com/studio/projects/add-native-code.html. -# For more examples on how to use CMake, see https://github.com/android/ndk-samples. - -# Sets the minimum CMake version required for this project. -cmake_minimum_required(VERSION 3.22.1) - -# Declares the project name. The project name can be accessed via ${ PROJECT_NAME}, -# Since this is the top level CMakeLists.txt, the project name is also accessible -# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level -# build script scope). -project("tgbotclient") - -# Creates and names a library, sets it as either STATIC -# or SHARED, and provides the relative paths to its source code. -# You can define multiple libraries, and CMake builds them for you. -# Gradle automatically packages shared libraries with your APK. -# -# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define -# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME} -# is preferred for the same purpose. -# -# In order to load a library into your app from Java/Kotlin, you must call -# System.loadLibrary() and pass the name of the library defined here; -# for GameActivity/NativeActivity derived applications, the same library name must be -# used in the AndroidManifest.xml file. -set(SOURCE_DIR ../../../../../../src) -add_library(${CMAKE_PROJECT_NAME} SHARED - # List C/C++ source files with relative paths to this CMakeLists.txt. - tgbotclient.cpp - ${SOURCE_DIR}/socket/bot/FileHelperNew.cpp - ${SOURCE_DIR}/hash/crc32.cpp ${SOURCE_DIR}/hash/sha256.cpp ${SOURCE_DIR}/hash/sha-2/sha-256.c) - -set(ABSL_PROPAGATE_CXX_STD ON) -add_subdirectory(${SOURCE_DIR}/third-party/abseil-cpp abseil-cpp) - -# Specifies libraries CMake should link to your target library. You -# can link libraries from various origins, such as libraries defined in this -# build script, prebuilt third-party libraries, or Android system libraries. -target_link_libraries(${CMAKE_PROJECT_NAME} - # List libraries link to the target library - android - log - absl::log_initialize absl::log absl::check) - -target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${SOURCE_DIR}/socket/include ${SOURCE_DIR}/include) -target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE -fmacro-prefix-map=${CMAKE_SOURCE_DIR}/=) diff --git a/clientapp/android/app/src/main/cpp/JNIOnLoad.h b/clientapp/android/app/src/main/cpp/JNIOnLoad.h deleted file mode 100644 index 7bd950cc..00000000 --- a/clientapp/android/app/src/main/cpp/JNIOnLoad.h +++ /dev/null @@ -1,36 +0,0 @@ -// -// Created by royna on 6/7/2024. -// - -#ifndef TGBOT_CLIENT_JNIONLOAD_H -#define TGBOT_CLIENT_JNIONLOAD_H - -#include -#include -#include - -#define NATIVE_METHOD(fn, sig) \ - { \ - .name = #fn, \ - .signature = sig, \ - .fnPtr = reinterpret_cast(fn),\ - } - -#define NATIVE_METHOD_SZ(methods) sizeof(methods) / sizeof(JNINativeMethod) - -static inline jint JNI_onLoadDef(JavaVM *vm, std::string cls, const JNINativeMethod methods[], - size_t size) { - JNIEnv *env; - int rc = JNI_ERR; - if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) { - return rc; - } - jclass c = env->FindClass(cls.c_str()); - if (c == nullptr) return rc; - - rc = env->RegisterNatives(c, methods, size); - assert(rc == JNI_OK); - return JNI_VERSION_1_6; -} - -#endif //TGBOT_CLIENT_JNIONLOAD_H diff --git a/clientapp/android/app/src/main/cpp/JavaCppConverter.hpp b/clientapp/android/app/src/main/cpp/JavaCppConverter.hpp deleted file mode 100644 index a0e9607a..00000000 --- a/clientapp/android/app/src/main/cpp/JavaCppConverter.hpp +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#include - -#include -#include - - -template -To Convert(JNIEnv *env, const From str) = delete; - -template <> -std::string Convert(JNIEnv *env, jstring str) { - jboolean isCopy; - - if (env->ExceptionOccurred()) { - return {}; - } - const char *convertedValue = env->GetStringUTFChars(str, &isCopy); - std::string string = std::string(convertedValue); - env->ReleaseStringUTFChars(str, convertedValue); - return string; -} - -template <> -jstring Convert(JNIEnv *env, std::string_view str) { - if (env->ExceptionOccurred()) { - return nullptr; - } - return env->NewStringUTF(str.data()); -} - -template <> -jstring Convert(JNIEnv* env, const char * __restrict str) { - return Convert(env, str); -} - -// Use pointer to avoid unnecessary copy -template <> -jstring Convert(JNIEnv* env, std::string* str) { - return Convert(env, *str); -} \ No newline at end of file diff --git a/clientapp/android/app/src/main/cpp/tgbotclient.cpp b/clientapp/android/app/src/main/cpp/tgbotclient.cpp deleted file mode 100644 index 320c8a00..00000000 --- a/clientapp/android/app/src/main/cpp/tgbotclient.cpp +++ /dev/null @@ -1,542 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include "JNIOnLoad.h" -#include "JavaCppConverter.hpp" -// Include the tgbot's exported header -#include "../../../../../../src/socket/bot/FileHelperNew.hpp" - -using namespace TgBotSocket; -using namespace TgBotSocket::data; -using namespace TgBotSocket::callback; - -struct SocketConfig { - std::string address; - enum : std::uint8_t { USE_IPV4, USE_IPV6 } mode {}; - int port {}; - - int timeout_s = 5; -}; - -// Java class definitions -#define SOCKNATIVE_JAVACLS "com/royna/tgbotclient/SocketCommandNative" -#define SOCKNATIVE_CMDCB_JAVACLS SOCKNATIVE_JAVACLS "$ICommandStatusCallback" -#define SOCKNATIVE_DSTINFO_JAVACLS SOCKNATIVE_JAVACLS "$DestinationInfo" -#define SOCKNATIVE_DSTTYPE_JAVACLS SOCKNATIVE_JAVACLS "$DestinationType" -#define STRING_JAVACLS "java/lang/String" -#define OBJECT_JAVACLS "java/lang/Object" - -class Callbacks { - public: - enum class Status { - INVALID, - CONNECTION_PREPARED, - CONNECTED, - HEADER_PACKET_SENT, - DATA_PACKET_SENT, - HEADER_PACKET_RECEIVED, - DATA_PACKET_RECEIVED, - PROCESSED_DATA, - DONE, - }; - - Callbacks(JNIEnv *_env, jobject _callbackObj) : env(_env), callbackInstance(_callbackObj) { - const auto callbackClass = env->FindClass(SOCKNATIVE_CMDCB_JAVACLS); - methods.success = env->GetMethodID(callbackClass, "onSuccess", - "(L" OBJECT_JAVACLS ";)V"); - methods.failure = env->GetMethodID(callbackClass, "onError", "(L" STRING_JAVACLS ";)V"); - methods.status = env->GetMethodID(callbackClass, "onStatusUpdate", "(I)V"); - } - - void success(jobject result) const { - DLOG(INFO) << "Calling callback: " << __func__; - env->CallVoidMethod(callbackInstance, methods.success, result); - } - - void success(const std::string_view result) const { - success(Convert(env, result)); - } - - void failure(const std::string_view message) const { - LOG(ERROR) << message; - _failure(message); - } - - void plog_failure(const std::string_view message) const { - PLOG(ERROR) << message; - _failure(message); - } - - void status(const Status status) const { -#ifndef NDEBUG -#define STATUSLOG "[status_log] " - switch (status) { - case Status::CONNECTION_PREPARED: - LOG(INFO) << STATUSLOG "Preparing connection"; - break; - case Status::CONNECTED: - LOG(INFO) << STATUSLOG "Connected"; - break; - case Status::HEADER_PACKET_SENT: - LOG(INFO) << STATUSLOG "Header packet sent"; - break; - case Status::DATA_PACKET_SENT: - LOG(INFO) << STATUSLOG "Data packet sent"; - break; - case Status::HEADER_PACKET_RECEIVED: - LOG(INFO) << STATUSLOG "Header packet received"; - break; - case Status::DATA_PACKET_RECEIVED: - LOG(INFO) << STATUSLOG "Data packet received"; - break; - case Status::PROCESSED_DATA: - LOG(INFO) << STATUSLOG "Processed result packet"; - break; - case Status::DONE: - LOG(INFO) << STATUSLOG "Finished"; - break; - default: - break; - } -#endif - env->CallVoidMethod(callbackInstance, methods.status, static_cast(status)); - } - - private: - inline void _failure(const std::string_view message) const { - DLOG(INFO) << "Calling callback: " << __func__; - env->CallVoidMethod(callbackInstance, methods.failure, - Convert(env, message)); - } - JNIEnv *env; - jobject callbackInstance; - struct { - jmethodID success; - jmethodID failure; - jmethodID status; - } methods{}; -}; - -RealFS realfs; -SocketFile2DataHelper helper(&realfs); - -class TgBotSocketNative { - public: - - void sendContext(Packet &packet, JNIEnv *env, jobject callbackObj) const { - sendContext(packet, std::make_unique(env, callbackObj)); - } - - void sendContext(Packet &packet, std::unique_ptr callbacks) const { - switch (config.mode) { - case SocketConfig::USE_IPV4: - sendContextCommon(packet, std::move(callbacks)); - break; - case SocketConfig::USE_IPV6: - sendContextCommon(packet, std::move(callbacks)); - break; - default: - LOG(ERROR) << "Unknown mode: " << static_cast(config.mode); - break; - } - } - - static TgBotSocketNative* getInstance() { - static auto instance = TgBotSocketNative(); - return &instance; - } - - SocketConfig config{}; - UploadFile::Options uploadOptions{}; - private: - constexpr static int kRecvFlags = MSG_WAITALL | MSG_NOSIGNAL; - constexpr static int kSendFlags = MSG_NOSIGNAL; - - TgBotSocketNative() = default; - - // Partial template specialization for sockaddr_in/sockaddr_in6 - template - void setupSockAddress(SockAddr *addr) const = delete; - - template <> - [[maybe_unused]] void setupSockAddress(sockaddr_in *addr) const { - addr->sin_family = AF_INET; - inet_pton(AF_INET, config.address.c_str(), &addr->sin_addr); - addr->sin_port = htons(config.port); - } - template <> - [[maybe_unused]] void setupSockAddress(sockaddr_in6 *addr) const { - addr->sin6_family = AF_INET6; - inet_pton(AF_INET6, config.address.c_str(), &addr->sin6_addr); - addr->sin6_port = htons(config.port); - } - - template - void sendContextCommon(Packet &context, std::unique_ptr callbacks) const { - SockAddr addr{}; - socklen_t len = sizeof(SockAddr); - int sockfd{}; - int ret{}; - - LOG(INFO) << "Prepare to send CommandContext"; - sockfd = socket(af, SOCK_STREAM | SOCK_NONBLOCK, 0); - if (sockfd < 0) { - callbacks->plog_failure("Failed to create socket"); - return; - } - - auto sockFdCloser = std::unique_ptr( - &sockfd, [](const int *fd) { close(*fd); }); - LOG(INFO) << "Using IP: " << std::quoted(config.address, '\'') - << ", Port: " << config.port; - setupSockAddress(&addr); - - struct timeval timeout {.tv_sec = 5 }; - setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); - setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); - - // Calculate CRC32 - context.header.data_checksum = Packet::crc32_function(context.data); - - // Update status 1 - callbacks->status(Callbacks::Status::CONNECTION_PREPARED); - - if (connect(sockfd, reinterpret_cast(&addr), len) != 0) { - if (errno != EINPROGRESS) { - callbacks->plog_failure("Failed to initiate connect()"); - return; - } - } - - // Wait for the nonblocking socket's event... - struct pollfd fds{}; - fds.events = POLLOUT; - fds.fd = sockfd; - ret = poll(&fds, 1, config.timeout_s * 1000); - if (ret < 0) { - callbacks->plog_failure("Failed to poll()"); - return; - } else if (!(fds.revents & POLLOUT)) { - callbacks->plog_failure("The server didn't respond"); - return; - } - - // Get if it failed... - int error = 0; - socklen_t error_len = sizeof(error); - ret = getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &error_len); - if (ret < 0) { - callbacks->plog_failure("Failed to getsockopt()"); - return; - } - - // If failed to connect, abort - if (error != 0) { - callbacks->plog_failure("Failed to connect to server"); - return; - } - - callbacks->status(Callbacks::Status::CONNECTED); - - ret = fcntl(sockfd, F_GETFL); - if (ret < 0) { - callbacks->plog_failure("Failed to get socket fd flags"); - return; - } - if (!(ret & O_NONBLOCK)) { - ret = fcntl(sockfd, F_SETFL, ret & ~O_NONBLOCK); - if (ret < 0) { - callbacks->plog_failure("Failed to set socket fd blocking"); - return; - } - } - - ret = send(sockfd, &context.header, sizeof(Packet::Header), kSendFlags); - if (ret < 0) { - callbacks->plog_failure("Failed to send packet header"); - return; - } else { - LOG(INFO) << "Sent header packet with cmd " - << static_cast(context.header.cmd) << ", " - << ret << " bytes"; - } - callbacks->status(Callbacks::Status::HEADER_PACKET_SENT); - - ret = send(sockfd, context.data.get(), context.header.data_size, - kSendFlags); - if (ret < 0) { - callbacks->plog_failure("Failed to send packet data"); - return; - } else { - LOG(INFO) << "Sent data packet, " << ret << " bytes"; - } - callbacks->status(Callbacks::Status::DATA_PACKET_SENT); - - LOG(INFO) << "Now reading callback"; - -#define RETRY(exp) ({ \ - __typeof__(exp) _rc; \ - do { \ - _rc = (exp); \ - } while (_rc == -1 && (errno == EINTR || errno == EAGAIN)); \ - _rc; }) - - Packet::Header header; - ret = RETRY(recv(sockfd, &header, sizeof(header), kRecvFlags)); - if (ret < 0) { - callbacks->plog_failure("Failed to read callback header"); - return; - } - callbacks->status(Callbacks::Status::HEADER_PACKET_RECEIVED); - - if (header.magic != Packet::Header ::MAGIC_VALUE) { - LOG(WARNING) << "Magic value offset: " << header.magic - Packet::Header::MAGIC_VALUE_BASE; - callbacks->failure("Bad magic value of callback header"); - return; - } - DLOG(INFO) << "Allocating " << header.data_size << " bytes..."; - SharedMalloc data(header.data_size); - if (data.get() == nullptr) { - if (header.data_size == 0) { - callbacks->failure("Server returned 0 bytes of data"); - } else { - errno = ENOMEM; - callbacks->plog_failure("Failed to alloc data for callback header"); - } - return; - } - ret = RETRY(recv(sockfd, data.get(), header.data_size, kRecvFlags)); - if (ret < 0) { - callbacks->plog_failure("Failed to read callback data"); - return; - } - callbacks->status(Callbacks::Status::DATA_PACKET_RECEIVED); - -#define CHECK_RESULTDATA_SIZE(type, size) do {\ - if ((size) != sizeof(type)) {\ - LOG(ERROR) << "Received packet with invalid size: " << (size);\ - callbacks->failure("Invalid size for " #type);\ - return;\ - }} while(false) - - LOG(INFO) << "Command received: " << static_cast(header.cmd); - switch (header.cmd) { - case Command::CMD_GET_UPTIME_CALLBACK: - handleSpecificData( - std::move(callbacks), data.get(), header.data_size); - break; - case Command::CMD_DOWNLOAD_FILE_CALLBACK: - handleSpecificData( - std::move(callbacks), data.get(), header.data_size); - break; - case Command::CMD_UPLOAD_FILE_DRY_CALLBACK: - handleSpecificData( - std::move(callbacks), data.get(), header.data_size); - break; - default: { - GenericAck AckData{}; - bool success{}; - CHECK_RESULTDATA_SIZE(GenericAck, header.data_size); - memcpy(&AckData, data.get(), sizeof(AckData)); - success = AckData.result == AckType::SUCCESS; - LOG(INFO) << "Command ACK: " << std::boolalpha << success; - if (!success) { - LOG(ERROR) << "Reason: " << AckData.error_msg.data(); - callbacks->plog_failure(AckData.error_msg.data()); - } else { - callbacks->success(nullptr); - } - break; - } - } - } - - - template - void handleSpecificData(std::unique_ptr callbacks, const void *data, - Packet::Header::length_type len) const = delete; - template <> - void handleSpecificData( - std::unique_ptr callbacks, const void *data, Packet::Header::length_type len) const { - bool rc = - helper.DataToFile( - data, len); - if (rc) { - callbacks->success(nullptr); - } else { - callbacks->failure("Failed to handle download file"); - } - } - template <> - void handleSpecificData( - std::unique_ptr callbacks, const void *data, - Packet::Header::length_type len) const { - const auto *uptime = static_cast(data); - CHECK_RESULTDATA_SIZE(GetUptimeCallback, len); - callbacks->success(uptime); - } - - template <> - void handleSpecificData( - std::unique_ptr callbacks, const void *data, - Packet::Header::length_type len) const { - CHECK_RESULTDATA_SIZE(UploadFileDryCallback, len); - const auto *callback = static_cast(data); - if (callback->result == AckType::SUCCESS) { - SocketFile2DataHelper::DataFromFileParam param{}; - param.filepath = callback->requestdata.srcfilepath.data(); - param.destfilepath = callback->requestdata.destfilepath.data(); - auto p = helper.DataFromFile(param); - if (p) { - sendContext(p.value(), std::move(callbacks)); - } else { - callbacks->failure("Failed to prepare file"); - } - } else { - callbacks->failure(callback->error_msg.data()); - } - } -}; - -namespace { - -void initLogging(JNIEnv __unused *env, jobject __unused thiz) { - absl::InitializeLog(); - LOG(INFO) << __func__; -} - -jboolean changeDestinationInfo(JNIEnv *env, jobject __unused thiz, jstring ipaddr, - jint type, jint port) { - auto sockIntf = TgBotSocketNative::getInstance(); - std::string newAddress = Convert(env, ipaddr); - SocketConfig config{}; - - switch (type) { - case AF_INET: - config.mode = SocketConfig::USE_IPV4; - break; - case AF_INET6: - config.mode = SocketConfig::USE_IPV6; - break; - default: - LOG(ERROR) << "Unknown af type:" << type; - return JNI_FALSE; - } - config.address = newAddress; - config.port = port; - LOG(INFO) << "Switched to IP " << std::quoted(newAddress, '\'') - << ", af: " << type << ", port: " << port; - sockIntf->config = config; - return JNI_TRUE; -} - -jobject getCurrentDestinationInfo(JNIEnv *env, __unused jobject thiz) { - auto cf = TgBotSocketNative::getInstance()->config; - jclass info = env->FindClass(SOCKNATIVE_DSTINFO_JAVACLS); - jmethodID ctor = env->GetMethodID(info, "", - "(L" STRING_JAVACLS - ";L" SOCKNATIVE_DSTTYPE_JAVACLS ";I)V"); - jclass destType = env->FindClass(SOCKNATIVE_DSTTYPE_JAVACLS); - jmethodID valueOf = - env->GetStaticMethodID(destType, "valueOf", - "(L" STRING_JAVACLS ";)" - "L" SOCKNATIVE_DSTTYPE_JAVACLS ";"); - jobject destTypeVal = nullptr; - switch (cf.mode) { - case SocketConfig::USE_IPV4: - destTypeVal = env->CallStaticObjectMethod( - destType, valueOf, Convert(env, "IPv4")); - break; - case SocketConfig::USE_IPV6: - destTypeVal = env->CallStaticObjectMethod( - destType, valueOf, Convert(env, "IPv6")); - break; - } - return env->NewObject(info, ctor, Convert(env, &cf.address), - destTypeVal, cf.port); -} -void sendWriteMessageToChatId(JNIEnv *env, jobject __unused thiz, jlong chat_id, - jstring text, jobject callback) { - WriteMsgToChatId data{}; - data.chat = chat_id; - copyTo(data.message, Convert(env, text).c_str()); - auto pkt = Packet(Command::CMD_WRITE_MSG_TO_CHAT_ID, data); - TgBotSocketNative::getInstance()->sendContext(pkt, env, callback); -} - -void getUptime(JNIEnv *env, jobject __unused thiz, jobject callback) { - auto pkt = Packet(Command::CMD_GET_UPTIME, true); - TgBotSocketNative::getInstance()->sendContext(pkt, env, callback); -} - -void uploadFile(JNIEnv *env, jobject __unused thiz, jstring path, jstring dest_file_path, - jobject callback) { - const auto* native = TgBotSocketNative::getInstance(); - SocketFile2DataHelper::DataFromFileParam params{}; - params.filepath = Convert(env, path); - params.destfilepath = Convert(env, dest_file_path); - params.options = native->uploadOptions; - - LOG(INFO) << "===============" << __func__ << "==============="; - LOG(INFO) << std::boolalpha << "overwrite opt: " << params.options.overwrite; - LOG(INFO) << std::boolalpha << "hash_ignore opt: " << params.options.hash_ignore; - auto rc = - helper.DataFromFile(params); - if (rc) { - native->sendContext(rc.value(), env, callback); - } else { - Callbacks(env, callback).failure("Failed to prepare file"); - } -} - -void downloadFile(JNIEnv *env, jobject __unused thiz, jstring remote_file_path, - jstring local_file_path, jobject callback) { - DownloadFile req{}; - copyTo(req.filepath, Convert(env, remote_file_path).c_str()); - copyTo(req.destfilename, - Convert(env, local_file_path).c_str()); - auto pkt = Packet(Command::CMD_DOWNLOAD_FILE, req); - TgBotSocketNative::getInstance()->sendContext(pkt, env, callback); -} - -void setUploadFileOptions(JNIEnv * __unused env, jobject __unused thiz, jboolean failIfExist, jboolean failIfChecksumMatch) { - TgBotSocketNative::getInstance()->uploadOptions = { - .overwrite = failIfExist == JNI_FALSE, - .hash_ignore = failIfChecksumMatch == JNI_FALSE, - }; - LOG(INFO) << "===============" << __func__ << "==============="; - LOG(INFO) << std::boolalpha << "overwrite opt: " << (failIfExist == JNI_TRUE); - LOG(INFO) << std::boolalpha << "hash_ignore opt: " << (failIfChecksumMatch == JNI_TRUE); -} -} // namespace - -JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *__unused reserved) { - static const JNINativeMethod methods[] = { - NATIVE_METHOD(initLogging, "()V"), - NATIVE_METHOD(changeDestinationInfo, "(L" STRING_JAVACLS ";II)Z"), - NATIVE_METHOD(sendWriteMessageToChatId, - "(JL" STRING_JAVACLS ";L" SOCKNATIVE_CMDCB_JAVACLS ";)V"), - NATIVE_METHOD(getUptime, "(L" SOCKNATIVE_CMDCB_JAVACLS ";)V"), - NATIVE_METHOD(uploadFile, "(L" STRING_JAVACLS ";L" STRING_JAVACLS - ";L" SOCKNATIVE_CMDCB_JAVACLS ";)V"), - NATIVE_METHOD(downloadFile, "(L" STRING_JAVACLS ";L" STRING_JAVACLS - ";L" SOCKNATIVE_CMDCB_JAVACLS ";)V"), - NATIVE_METHOD(getCurrentDestinationInfo, - "()L" SOCKNATIVE_DSTINFO_JAVACLS ";"), - NATIVE_METHOD(setUploadFileOptions, "(ZZ)V") - }; - - return JNI_onLoadDef(vm, SOCKNATIVE_JAVACLS, methods, - NATIVE_METHOD_SZ(methods)); -} diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/SocketCommandNative.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/SocketCommandNative.kt deleted file mode 100644 index 9e7af79d..00000000 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/SocketCommandNative.kt +++ /dev/null @@ -1,110 +0,0 @@ -@file:Suppress("KotlinJniMissingFunction") - -package com.royna.tgbotclient - -object SocketCommandNative { - // No effect but sync with - enum class DestinationType (val af: Int) { - IPv4(2), - IPv6(10) - } - - /** - * Status of the command - * Used in ICommandStatusCallback.onStatusUpdate - * Sync with native TgBotSocketNative::Callbacks::Status - */ - enum class Status(val value: Int) { - INVALID(0), - CONNECTION_PREPARED(1), - CONNECTED(2), - HEADER_PACKET_SENT(3), - DATA_PACKET_SENT(4), - HEADER_PACKET_RECEIVED(5), - DATA_PACKET_RECEIVED(6), - PROCESSED_DATA(7), - DONE(8), - } - - enum class UploadOption(val value: Int) { - MUST_NOT_EXIST(1), - MUST_NOT_MATCH_CHECKSUM(2), - ALWAYS(3) - } - - // Generic interface for command callbacks - interface ICommandCallback { - /*** - * Called when the command is executed successfully - * - * @param result The result of the command. Different values per-command - */ - fun onSuccess(result: Any?) - - /*** - * Called when the command fails - * - * @param error The error message - */ - fun onError(error: String) - } - - // Extension of ICommandCallback for status updates - interface ICommandStatusCallback : ICommandCallback { - /*** - * Called when the status of the command changes - * - * @param status The new status - */ - fun onStatusUpdate(status: Status) - - // Alias function, native code calls this instead of onStatusUpdate above. - fun onStatusUpdate(status: Int) { - onStatusUpdate(Status.entries[status]) - } - } - - data class DestinationInfo(var ipaddr: String, var type: DestinationType, var port: Int) - - // Calls the native code to change the destination - fun changeDestinationInfo(ipaddr: String, type: DestinationType, port: Int) { - changeDestinationInfo(ipaddr, type.af, port) - } - - // Set the upload file options via a call to native code - fun setUploadFileOptions(options: UploadOption) { - when (options) { - UploadOption.MUST_NOT_EXIST -> setUploadFileOptions( - failIfExist = true, - failIfChecksumMatch = false - ) - UploadOption.MUST_NOT_MATCH_CHECKSUM -> setUploadFileOptions( - failIfExist = false, - failIfChecksumMatch = true - ) - UploadOption.ALWAYS -> setUploadFileOptions( - failIfExist = false, - failIfChecksumMatch = false - ) - } - } - - // Commands - external fun sendWriteMessageToChatId(chatId: Long, text: String, callback: ICommandStatusCallback) - external fun getUptime(callback: ICommandStatusCallback) - external fun uploadFile(path: String, destFilePath: String, callback: ICommandStatusCallback) - external fun downloadFile(remoteFilePath: String, localFilePath: String, callback: ICommandStatusCallback) - - // Option configuration - private external fun setUploadFileOptions(failIfExist : Boolean, failIfChecksumMatch : Boolean) - private external fun changeDestinationInfo(ipaddr: String, type: Int, port: Int) : Boolean - external fun getCurrentDestinationInfo() : DestinationInfo - - // Initialize abseil logging - private external fun initLogging() - - init { - System.loadLibrary("tgbotclient") - initLogging() - } -} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/SocketContext.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/SocketContext.kt new file mode 100644 index 00000000..ada8a2f8 --- /dev/null +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/SocketContext.kt @@ -0,0 +1,377 @@ +package com.royna.tgbotclient.net + +import com.google.gson.Gson +import com.royna.tgbotclient.datastore.ChatID +import com.royna.tgbotclient.net.crypto.SHAPartial +import com.royna.tgbotclient.net.data.Command +import com.royna.tgbotclient.net.data.GenericAck +import com.royna.tgbotclient.net.data.GenericAckException +import com.royna.tgbotclient.net.data.Packet +import com.royna.tgbotclient.net.data.PayloadType +import com.royna.tgbotclient.util.Logging +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.Connection +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.aSocket +import io.ktor.network.sockets.connection +import io.ktor.utils.io.read +import io.ktor.utils.io.writeFully +import kotlinx.coroutines.Dispatchers +import java.io.File +import java.nio.ByteBuffer + +class SocketContext { + private suspend fun openConnection() : Connection { + val selectorManager = SelectorManager(Dispatchers.IO) + val serverSocket = aSocket(selectorManager).tcp().connect(mAddress) + Logging.info("Waiting connection on $mAddress") + val socket = serverSocket.connection() + Logging.info("Connected") + return socket + } + + private suspend fun doOpenSession(channels: Connection) = runCatching { + Logging.debug("Opened connection") + var mSessionToken = String(ByteArray(Packet.SESSION_TOKEN_LENGTH)) + val openSessionPacket = Packet.create( + command = Command.CMD_OPEN_SESSION, + payloadType = PayloadType.Binary, + sessionToken = mSessionToken + ).getOrElse { + Logging.error("Failed to create packet", it) + throw RuntimeException("Failed to create packet") + } + + channels.output.writeFully(openSessionPacket) + channels.output.flush() + Logging.debug("Wrote CMD_OPEN_SESSION") + + lateinit var header : Packet + channels.input.read(Packet.PACKET_HEADER_LEN) { bytebuffer -> + header = Packet.fromByteBuffer(bytebuffer).getOrThrow() + if (header.command != Command.CMD_OPEN_SESSION_ACK) { + Logging.error("Invalid response: ${header.command}") + throw RuntimeException("Invalid response") + } + Logging.debug("Read CMD_OPEN_SESSION_ACK") + mSessionToken = header.sessionToken + Logging.debug("Got session token: $mSessionToken") + } + // Process payload + channels.input.read(Packet.PACKET_HEADER_LEN) { bytebuffer -> + Packet.readPayload(bytebuffer, header).onSuccess { + header.payload = it + }.getOrThrow() + } + Logging.info("Opened session") + mSessionToken + } + + private suspend fun closeSession(channels: Connection, mSessionToken: String) { + val closeSessionPacket = Packet.create( + command = Command.CMD_CLOSE_SESSION, + payloadType = PayloadType.Binary, + sessionToken = mSessionToken + ).getOrElse { + Logging.error("Failed to create packet", it) + throw RuntimeException("Failed to create packet") + } + channels.output.writeFully(closeSessionPacket) + channels.output.flush() + Logging.debug("Wrote CMD_CLOSE_SESSION") + } + + private suspend fun readGenericAck(channels: Connection) = runCatching { + lateinit var header : Packet + channels.input.read(Packet.PACKET_HEADER_LEN) { bytebuffer -> + header = Packet.fromByteBuffer(bytebuffer).getOrThrow() + require(header.command == Command.CMD_GENERIC_ACK) { + "Invalid response: ${header.command}" + } + } + Logging.debug("Read generic ack") + + lateinit var payload : ByteArray + // Process payload + channels.input.read(header.length) { bytebuffer -> + Packet.readPayload(bytebuffer, header).onSuccess { + payload = it + }.getOrThrow() + } + Logging.debug("Payload: ${payload.decodeToString()}") + GenericAck.fromJson(payload.decodeToString()) + } + + enum class UploadOption(val value: Int) { + MUST_NOT_EXIST(1), + MUST_NOT_MATCH_CHECKSUM(2), + ALWAYS(3) + } + + data class SendMessageData(val chat: ChatID, val message: String) + + suspend fun sendMessage(chatId: Long, message: String) = runCatching { + val channels = openConnection() + val sessionToken = doOpenSession(channels).onFailure { + Logging.error("Failed to open session", it) + }.getOrThrow() + + val sendMessagePacket = Packet.create( + Command.CMD_WRITE_MSG_TO_CHAT_ID, + PayloadType.Json, + sessionToken, + Gson().toJson(SendMessageData(chatId, message)).toByteArray()) + .getOrElse { + Logging.error("Failed to create packet", it) + throw it + } + channels.output.writeFully(sendMessagePacket) + channels.output.flush() + Logging.debug("Wrote CMD_SEND_FILE_TO_CHAT_ID") + readGenericAck(channels).onSuccess { + if (!it.success()) { + throw GenericAckException(it) + } + }.onFailure { + Logging.error("Failed to read generic ack", it) + }.getOrThrow() + closeSession(channels, sessionToken) + } + + data class GetUptimeData(val start_time: String, val current_time: String, val uptime: String) + suspend fun getUptime() = runCatching { + val channels = openConnection() + val sessionToken = doOpenSession(channels).onFailure { + Logging.error("Failed to open session", it) + }.getOrThrow() + + val sendMessagePacket = Packet.create( + Command.CMD_GET_UPTIME, + PayloadType.Json, + sessionToken + ).getOrElse { + Logging.error("Failed to create packet", it) + throw it + } + channels.output.writeFully(sendMessagePacket) + channels.output.flush() + Logging.debug("Wrote CMD_GET_UPTIME") + + var result: GetUptimeData? = null + // Read the ACK + runCatching { + lateinit var header : Packet + channels.input.read(Packet.PACKET_HEADER_LEN) { bytebuffer -> + header = Packet.fromByteBuffer(bytebuffer).getOrThrow() + require(header.command == Command.CMD_GET_UPTIME_CALLBACK) { + "Invalid response: ${header.command}" + } + } + Logging.debug("Read ${header.command}") + + lateinit var payload : ByteArray + channels.input.read(header.length) { bytebuffer -> + Packet.readPayload(bytebuffer, header).onSuccess { + payload = it + }.getOrThrow() + } + val str = payload.decodeToString() + Logging.debug("Payload: $str") + result = Gson().fromJson(str, GetUptimeData::class.java) + if (result == null) { + Logging.error("Failed to parse CMD_GET_UPTIME ack") + throw RuntimeException("Failed to parse CMD_GET_UPTIME ack") + } + }.onFailure { + Logging.error("Failed to read CMD_GET_UPTIME ack", it) + }.getOrThrow() + closeSession(channels, sessionToken) + result?.uptime + } + + data class TransferFileData(val destfilepath: String, val srcfilepath: String, val hash: String, + val options: Options) { + data class Options ( + val overwrite: Boolean, + val hash_ignore: Boolean, + var dry_run : Boolean + ) + } + data class TransferUploadData(val destfilepath: String, val srcfilepath: String, val options: Options) { + data class Options ( + val overwrite: Boolean, + val hash_ignore: Boolean, + var dry_run : Boolean + ) + } + + suspend fun uploadFile(sourcePath: File, destPath: String) = runCatching { + val channels = openConnection() + val sessionToken = doOpenSession(channels).onFailure { + Logging.error("Failed to open session", it) + }.getOrThrow() + + Logging.debug("File size: ${sourcePath.length()}") + + val (overwrite, hash_ignore) = when (mUploadOption) { + UploadOption.MUST_NOT_EXIST -> Pair(false, false) + UploadOption.MUST_NOT_MATCH_CHECKSUM -> Pair(true, false) + UploadOption.ALWAYS -> Pair(true, true) + } + val fileBuf = sourcePath.readBytes() + val hash = SHAPartial().create(fileBuf).toString() + val data = TransferFileData( + destfilepath = destPath, + srcfilepath = sourcePath.absolutePath, + hash = hash, + options = TransferFileData.Options( + overwrite = overwrite, + hash_ignore = hash_ignore, + dry_run = true + ) + ) + + val uploadDryPacket = Packet.create( + Command.CMD_TRANSFER_FILE, + PayloadType.Json, + sessionToken, + Gson().toJson(data).toByteArray().also { + Logging.debug("Payload: ${it.decodeToString()}") + } + ) + + channels.output.writeFully(uploadDryPacket.getOrElse { + Logging.error("Failed to create CMD_TRANSFER_FILE packet", it) + throw it + }) + channels.output.flush() + Logging.debug("Wrote CMD_TRANSFER_FILE") + + readGenericAck(channels).onSuccess { + if (it.success()) { + data.options.dry_run = false + val jsonPayload = Gson().toJson(data).toByteArray() + Logging.debug("Payload: ${jsonPayload.decodeToString()}, size: ${jsonPayload.size}") + val uploadPkt = Packet.create( + Command.CMD_TRANSFER_FILE, + PayloadType.Json, + sessionToken, + ByteBuffer.allocate(jsonPayload.size + fileBuf.size + 1).apply { + put(jsonPayload) + put(0xFFu.toByte()) + put(fileBuf) + }.array() + ).getOrElse { thr -> + Logging.error("Failed to create CMD_TRANSFER_FILE packet", thr) + throw thr + } + channels.output.writeFully(uploadPkt) + channels.output.flush() + Logging.debug("Wrote CMD_TRANSFER_FILE") + readGenericAck(channels).onFailure { fail -> + Logging.error("Failed to read generic ack", fail) + throw fail + } + } else { + throw GenericAckException(it) + } + }.onFailure { + Logging.error("Failed to read generic ack", it) + throw it + } + closeSession(channels, sessionToken) + } + + suspend fun downloadFile(sourcePath: String, destPath: String) = runCatching { + val channels = openConnection() + val sessionToken = doOpenSession(channels).onFailure { + Logging.error("Failed to open session", it) + }.getOrThrow() + + val data = TransferUploadData( + destfilepath = destPath, + srcfilepath = sourcePath, + options = TransferUploadData.Options( + overwrite = false, + hash_ignore = true, + dry_run = true + ) + ) + + val transferRequest = Packet.create( + Command.CMD_TRANSFER_FILE_REQUEST, + PayloadType.Json, + sessionToken, + Gson().toJson(data).toByteArray().also { + Logging.debug("Payload: ${it.decodeToString()}") + } + ) + channels.output.writeFully(transferRequest.getOrElse { + Logging.error("Failed to create CMD_TRANSFER_FILE_REQUEST packet", it) + throw it + }) + channels.output.flush() + Logging.debug("Wrote CMD_TRANSFER_FILE_REQUEST") + + lateinit var header : Packet + channels.input.read(Packet.PACKET_HEADER_LEN) { bytebuffer -> + header = Packet.fromByteBuffer(bytebuffer).getOrThrow() + require(header.command == Command.CMD_TRANSFER_FILE || header.command == Command.CMD_GENERIC_ACK) { + "Invalid response: ${header.command}" + } + } + Logging.debug("Read ${header.command}") + + lateinit var payload : ByteArray + // Process payload + channels.input.read(header.length) { bytebuffer -> + Packet.readPayload(bytebuffer, header).onSuccess { + payload = it + }.getOrThrow() + } + when (header.command) { + Command.CMD_GENERIC_ACK -> { + val ack = GenericAck.fromJson(payload.decodeToString()) + if (!ack.success()) { + throw GenericAckException(ack) + } + } + Command.CMD_TRANSFER_FILE -> { + val jsonDataOffset = payload.indexOfFirst { + it == 0xFF.toByte() + } + val jsonData = payload.sliceArray(0 until jsonDataOffset) + val fileData = payload.sliceArray(jsonDataOffset + 1 until payload.size) + Logging.debug("Payload: ${jsonData.decodeToString()}") + Logging.debug("File size: ${fileData.size}") + File(destPath).writeBytes(fileData) + } + else -> { + throw IllegalArgumentException("Invalid response") + } + } + closeSession(channels, sessionToken) + } + fun setUploadFileOptions(options: UploadOption) { + mUploadOption = options + } + + var destination: InetSocketAddress + get() = mAddress + set(value) { + mAddress = value + } + + private var mAddress = InetSocketAddress("127.0.0.1", 0) + private var mUploadOption = UploadOption.MUST_NOT_EXIST + + companion object { + private var mInstance: SocketContext? = null + fun getInstance(): SocketContext { + if (mInstance == null) { + mInstance = SocketContext() + } + return mInstance!! + } + } +} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/crypto/AES.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/crypto/AES.kt new file mode 100644 index 00000000..68ef628f --- /dev/null +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/crypto/AES.kt @@ -0,0 +1,33 @@ +package com.royna.tgbotclient.net.crypto + +import com.royna.tgbotclient.net.data.Packet +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +object AES { + private const val GCM_TAG_LENGTH = Packet.TAG_LENGTH * 8 // Tag length in bits + private const val ALGORITHM = "AES/GCM/NoPadding" + private const val GCM_IV_LENGTH = Packet.INIT_VECTOR_LENGTH + private val cipher = Cipher.getInstance(ALGORITHM) + + fun encrypt(secretKey: SecretKey, iv: ByteArray, payload: ByteArray) : ByteArray { + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec) + return cipher.doFinal(payload) + } + + fun decrypt(secretKey: SecretKey, iv: ByteArray, payload: ByteArray) : ByteArray { + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec) + return cipher.doFinal(payload) + } + + // Generate a random IV + fun generateIV(): ByteArray { + val iv = ByteArray(GCM_IV_LENGTH) + SecureRandom().nextBytes(iv) + return iv + } +} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/crypto/HMAC.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/crypto/HMAC.kt new file mode 100644 index 00000000..d887e060 --- /dev/null +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/crypto/HMAC.kt @@ -0,0 +1,19 @@ +package com.royna.tgbotclient.net.crypto + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +object HMAC { + private const val ALGORITHM = "HmacSHA256" + private val hmac = Mac.getInstance(ALGORITHM) + + fun compute(secretKey: ByteArray, payload: ByteArray): ByteArray { + hmac.init(SecretKeySpec(secretKey, ALGORITHM)) + hmac.update(payload) + return hmac.doFinal() + } + + fun compare(secretKey: ByteArray, payload: ByteArray, expectedHMAC: ByteArray): Boolean { + return compute(secretKey, payload).copyOf(expectedHMAC.size).contentEquals(expectedHMAC) + } +} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/crypto/SHAPartial.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/crypto/SHAPartial.kt new file mode 100644 index 00000000..386a18fa --- /dev/null +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/crypto/SHAPartial.kt @@ -0,0 +1,41 @@ +package com.royna.tgbotclient.net.crypto + +import java.security.MessageDigest + +class SHAPartial { + private val sha256 = MessageDigest.getInstance("SHA-256") + + data class SHAResult(val hash: ByteArray) { + override fun toString(): String { + // Convert the byte array to a hexadecimal string + val hexString = StringBuilder() + for (b in hash) { + // Mask to ensure non-negative values + val hex = Integer.toHexString(0xff and b.toInt()) + if (hex.length == 1) { + hexString.append('0') // Add leading zero if needed + } + hexString.append(hex) + } + return hexString.toString() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SHAResult + + return hash.contentEquals(other.hash) + } + + override fun hashCode(): Int { + return hash.contentHashCode() + } + } + + fun create(payload: ByteArray): SHAResult { + sha256.update(payload) + return SHAResult(sha256.digest()) + } +} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/Command.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/Command.kt new file mode 100644 index 00000000..7f04e56e --- /dev/null +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/Command.kt @@ -0,0 +1,20 @@ +package com.royna.tgbotclient.net.data + +enum class Command(val value: Int) { + CMD_INVALID(0), + CMD_WRITE_MSG_TO_CHAT_ID(1), + CMD_CTRL_SPAMBLOCK(2), + CMD_OBSERVE_CHAT_ID(3), + CMD_SEND_FILE_TO_CHAT_ID(4), + CMD_OBSERVE_ALL_CHATS(5), + CMD_GET_UPTIME(6), + CMD_TRANSFER_FILE(7), + CMD_TRANSFER_FILE_REQUEST(8), + + // Below are internal commands + CMD_GET_UPTIME_CALLBACK(100), + CMD_GENERIC_ACK(101), + CMD_OPEN_SESSION(102), + CMD_OPEN_SESSION_ACK(103), + CMD_CLOSE_SESSION(104) +}; \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/GenericAck.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/GenericAck.kt new file mode 100644 index 00000000..3fabbc3f --- /dev/null +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/GenericAck.kt @@ -0,0 +1,44 @@ +package com.royna.tgbotclient.net.data + +import com.google.gson.Gson + +data class GenericAck( + val ackType: AckType, + val message: String +) { + enum class AckType { + SUCCESS, + ERROR_TGAPI_EXCEPTION, + ERROR_INVALID_ARGUMENT, + ERROR_COMMAND_IGNORED, + ERROR_RUNTIME_ERROR, + ERROR_CLIENT_ERROR, + } + + data class Json(val result: Boolean, val error_type: String, val error_msg: String) + + companion object { + fun fromJson(json: String): GenericAck { + val gson = Gson() + val jsonSerialized = gson.fromJson(json, Json::class.java) + + if (jsonSerialized.result) { + return GenericAck( + ackType = AckType.SUCCESS, + message = "" + ) + } + for (type in AckType.entries) { + if (type.name.endsWith(jsonSerialized.error_type)) { + return GenericAck( + ackType = type, + message = jsonSerialized.error_msg + ) + } + } + throw IllegalArgumentException("Unknown error type: ${jsonSerialized.error_type}") + } + } + + fun success() = ackType == AckType.SUCCESS +} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/GenericAckException.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/GenericAckException.kt new file mode 100644 index 00000000..cb40bbeb --- /dev/null +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/GenericAckException.kt @@ -0,0 +1,8 @@ +package com.royna.tgbotclient.net.data + +class GenericAckException(val data: GenericAck) : RuntimeException() { + override val message: String + get() { + return "${data.ackType}: ${data.message}" + } +} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/Packet.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/Packet.kt new file mode 100644 index 00000000..8500880a --- /dev/null +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/Packet.kt @@ -0,0 +1,158 @@ +package com.royna.tgbotclient.net.data + +import com.royna.tgbotclient.net.crypto.AES +import com.royna.tgbotclient.net.crypto.HMAC +import com.royna.tgbotclient.util.Logging +import java.nio.ByteBuffer +import java.nio.ByteOrder +import javax.crypto.spec.SecretKeySpec + +data class Packet(val command: Command, + val payloadType: PayloadType, + val length: Int, + val sessionToken : String, + val hmac : ByteArray, + val initVector: ByteArray, + var payload: ByteArray? = null) { + companion object { + const val MAGIC: Long = 0xDEADFACE + const val HEADER_VERSION: Int = 12 + const val HMAC_LENGTH = 64 + const val PACKET_HEADER_LEN = 144 + + // Specification of AES-GCM + const val SESSION_TOKEN_LENGTH = 32 + const val INIT_VECTOR_LENGTH = 12 + const val TAG_LENGTH = 16 + + fun create(command: Command, payloadType: PayloadType, sessionToken: String, payload: ByteArray? = null) = runCatching { + val initVector = AES.generateIV() + val hmac = ByteArray(HMAC_LENGTH) + var data = payload + + if (data != null) { + data = AES.encrypt(SecretKeySpec(sessionToken.toByteArray(), "AES"), initVector, data) + HMAC.compute(sessionToken.toByteArray(), data).copyInto(hmac) + } + + val header = Packet( + command = command, + payloadType = payloadType, + hmac = hmac, + length = data?.size ?: 0, + sessionToken = sessionToken, + initVector = initVector, + payload = data + ) + header.toByteArray().getOrThrow() + } + + private inline fun > ByteBuffer.getEnumByValue( + crossinline valueSelector: (E) -> Int + ): E { + val intValue = this.getInt() + return enumValues().find { valueSelector(it) == intValue } + ?: throw IllegalArgumentException("Invalid enum ${E::class.simpleName} value: $intValue") + } + + fun fromByteBuffer(buffer: ByteBuffer) = runCatching { + // We always use little endian + buffer.order(ByteOrder.LITTLE_ENDIAN) + + val magic = buffer.getLong() + require(magic == MAGIC + HEADER_VERSION) { "Invalid magic number" } + + val command : Command = buffer.getEnumByValue { it.value } + val payloadType: PayloadType = buffer.getEnumByValue { it.value } + val length = buffer.getInt() + val sessionToken = ByteArray(SESSION_TOKEN_LENGTH) + buffer.get(sessionToken) + buffer.getInt() // padding 1 + buffer.getLong() // nonce + val macBuffer = ByteArray(HMAC_LENGTH) + buffer.get(macBuffer) + val initVectorBuffer = ByteArray(INIT_VECTOR_LENGTH) + buffer.get(initVectorBuffer) + buffer.getInt() // padding 2 + Logging.debug("Read header: version: $HEADER_VERSION command: $command payloadType: $payloadType length: $length") + + Packet( + command, payloadType, length, sessionToken.decodeToString(), macBuffer, initVectorBuffer, ByteArray(0) + ) + } + fun readPayload(buffer: ByteBuffer, packet: Packet) = runCatching { + // Fetch Payload + val payload = ByteArray(packet.length) + buffer.get(payload) + + // Match HMAC + val computedHMAC = HMAC.compare(packet.sessionToken.toByteArray(), payload, packet.hmac) + require(computedHMAC) { "Invalid HMAC" } + + // Decrypt + AES.decrypt(SecretKeySpec(packet.sessionToken.toByteArray(), + "AES"), packet.initVector, payload) + } + } + + fun toByteArray() = runCatching { + // Validate hmac and initVector lengths + require(hmac.size == HMAC_LENGTH) { "HMAC must be $HMAC_LENGTH bytes" } + require(initVector.size == INIT_VECTOR_LENGTH) { "Initialization Vector must be $INIT_VECTOR_LENGTH bytes" } + require(sessionToken.length == SESSION_TOKEN_LENGTH) { "Session Token must be $SESSION_TOKEN_LENGTH bytes" } + + // Create a ByteBuffer to write the header + val buffer = ByteBuffer.allocate(PACKET_HEADER_LEN + (payload?.size ?: 0)) + buffer.order(ByteOrder.LITTLE_ENDIAN) + + Logging.debug("Write header: version: $HEADER_VERSION command: $command payloadType: $payloadType length: $length") + + // Write fields to buffer + buffer.putLong(MAGIC + HEADER_VERSION) // magic + buffer.putInt(command.value) // command + buffer.putInt(payloadType.value) // payload type + buffer.putInt(length) // length + buffer.put(sessionToken.toByteArray()) // session token + buffer.putInt(0) // padding 1 + buffer.putLong(System.currentTimeMillis()) // nonce + buffer.put(hmac) // HMAC + buffer.put(initVector) // Initialization Vector + buffer.putInt(0) // padding 2 + + require(buffer.position() == PACKET_HEADER_LEN) { + "Buffer size mismatch: ${buffer.position()} and $PACKET_HEADER_LEN" + } + + payload?.let { buffer.put(it) } // payload + buffer.array() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Packet + + if (command != other.command) return false + if (payloadType != other.payloadType) return false + if (length != other.length) return false + if (!sessionToken.contentEquals(other.sessionToken)) return false + if (!hmac.contentEquals(other.hmac)) return false + if (!initVector.contentEquals(other.initVector)) return false + if (!payload.contentEquals(other.payload)) return false + + return true + } + + override fun hashCode(): Int { + var result = command.hashCode() + result = 31 * result + payloadType.hashCode() + result = 31 * result + length + result = 31 * result + sessionToken.hashCode() + result = 31 * result + hmac.contentHashCode() + result = 31 * result + initVector.contentHashCode() + result = 31 * result + payload.contentHashCode() + return result + } + +} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/PayloadType.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/PayloadType.kt new file mode 100644 index 00000000..231e126a --- /dev/null +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/net/data/PayloadType.kt @@ -0,0 +1,6 @@ +package com.royna.tgbotclient.net.data + +enum class PayloadType(val value : Int) { + Binary(0), + Json(1) +} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/CurrentSettingFragment.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/CurrentSettingFragment.kt index 5ae5b7d5..73efa30b 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/CurrentSettingFragment.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/CurrentSettingFragment.kt @@ -5,9 +5,13 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import com.royna.tgbotclient.R -import com.royna.tgbotclient.SocketCommandNative.getCurrentDestinationInfo import com.royna.tgbotclient.databinding.FragmentCurrentSettingChildBinding +import com.royna.tgbotclient.net.SocketContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class CurrentSettingFragment : Fragment() { private var _binding: FragmentCurrentSettingChildBinding? = null @@ -22,23 +26,37 @@ class CurrentSettingFragment : Fragment() { savedInstanceState: Bundle? ): View { _binding = FragmentCurrentSettingChildBinding.inflate(inflater, container, false) + update() return binding.root } - fun update () { - val info = getCurrentDestinationInfo() - binding.currentIp.text = resources.getString(R.string.ip_address_fmt, - info.ipaddr) - binding.currentPort.text = resources.getString(R.string.port_fmt, - info.port) - binding.currentType.text = resources.getString(R.string.address_type_fmt, - info.type.name) + private fun update() = viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + val info = SocketContext.getInstance().destination + val hostname = info.hostname + val port = info.port + + withContext(Dispatchers.Main) { + if (_binding != null) { + synchronized(_binding as Any) { + binding.currentIp.text = resources.getString( + R.string.ip_address_fmt, hostname + ) + binding.currentPort.text = resources.getString( + R.string.port_fmt, port + ) + } + } + } + } } override fun onDestroyView() { super.onDestroyView() - _binding = null + synchronized(_binding as Any) { + _binding = null + } } override fun onResume() { diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/DualViewModelBase.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/DualViewModelBase.kt deleted file mode 100644 index 4994da8f..00000000 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/DualViewModelBase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.royna.tgbotclient.ui - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData - -abstract class DualViewModelBase : SingleViewModelBase() { - private var _liveData2 = MutableLiveData() - val liveData2: LiveData get() = _liveData2 - fun setLiveData2(inval: V) { - _liveData2.value = inval - } -} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/SingleViewModelBase.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/SingleViewModelBase.kt deleted file mode 100644 index 304bec18..00000000 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/SingleViewModelBase.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.royna.tgbotclient.ui - -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.royna.tgbotclient.SocketCommandNative - -abstract class SingleViewModelBase : ViewModel() { - protected var _liveData = MutableLiveData() - val liveData: LiveData get() = _liveData - fun setLiveData(inval: T) { - _liveData.value = inval - } - - protected val gMainScope = viewModelScope - abstract suspend fun coroutineFunction(activity: FragmentActivity) : V - abstract fun execute(activity: FragmentActivity, callback: SocketCommandNative.ICommandCallback) -} \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/downloadfile/DownloadFileFragment.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/downloadfile/DownloadFileFragment.kt index 56146693..0846861f 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/downloadfile/DownloadFileFragment.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/downloadfile/DownloadFileFragment.kt @@ -6,18 +6,21 @@ import android.os.Environment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.fragment.app.commit +import androidx.lifecycle.Lifecycle import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.ViewModelProvider -import com.google.android.material.snackbar.Snackbar +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.royna.tgbotclient.R -import com.royna.tgbotclient.SocketCommandNative import com.royna.tgbotclient.databinding.FragmentDownloadFileBinding import com.royna.tgbotclient.ui.CurrentSettingFragment import com.royna.tgbotclient.util.FileUtils.dirJoin +import kotlinx.coroutines.launch import java.io.File class DownloadFileFragment : Fragment() { @@ -38,25 +41,17 @@ class DownloadFileFragment : Fragment() { val startForResult = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { if (it != null) { - vm.setLiveData(it) + vm.setFileUrl(it) } } binding.downloadSelectFileButton.setOnClickListener { startForResult.launch(null) } binding.downloadDownloadButton.setOnClickListener { - vm.execute(requireActivity(), object : SocketCommandNative.ICommandCallback { - override fun onSuccess(result: Any?) { - Snackbar.make(it, "File downloaded", Snackbar.LENGTH_SHORT).show() - } - - override fun onError(error: String) { - Snackbar.make(it, "File download failed: $error", Snackbar.LENGTH_SHORT).show() - } - }) + vm.execute(requireActivity()) } binding.downloadFileName.doAfterTextChanged { - vm.setLiveData2(it.toString()) + vm.setSourceFile(it.toString()) } // Default values binding.downloadFileView.text = resources.getString(R.string.no_file_selected) @@ -64,11 +59,11 @@ class DownloadFileFragment : Fragment() { // Observe the Uri and Filename val mediatorLiveData = MediatorLiveData>() - mediatorLiveData.addSource(vm.liveData) { value1 -> - mediatorLiveData.value = Pair(value1, vm.liveData2.value) + mediatorLiveData.addSource(vm.outFileURI) { value1 -> + mediatorLiveData.value = Pair(value1, vm.sourceFilePath.value) } - mediatorLiveData.addSource(vm.liveData2) { value2 -> - mediatorLiveData.value = Pair(vm.liveData.value, value2) + mediatorLiveData.addSource(vm.sourceFilePath) { value2 -> + mediatorLiveData.value = Pair(vm.outFileURI.value, value2) } mediatorLiveData.observe(viewLifecycleOwner) { pair -> // Use both values here @@ -93,6 +88,14 @@ class DownloadFileFragment : Fragment() { binding.downloadFileView.text = listOfNotNull(value2, dirname, overwriting).joinToString("\n") } + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + vm.downloadEvent.collect { message -> + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + } + } + } + return root } @@ -107,5 +110,4 @@ class DownloadFileFragment : Fragment() { super.onDestroyView() _binding = null } - } \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/downloadfile/DownloadFileViewModel.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/downloadfile/DownloadFileViewModel.kt index 99b18096..01f627b7 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/downloadfile/DownloadFileViewModel.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/downloadfile/DownloadFileViewModel.kt @@ -1,43 +1,51 @@ package com.royna.tgbotclient.ui.commands.downloadfile -import android.content.Context import android.net.Uri import android.webkit.MimeTypeMap import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.FragmentActivity -import com.royna.tgbotclient.SocketCommandNative -import com.royna.tgbotclient.ui.DualViewModelBase +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import com.royna.tgbotclient.net.SocketContext import com.royna.tgbotclient.util.FileUtils.copyFromExt import com.royna.tgbotclient.util.FileUtils.queryFileName import com.royna.tgbotclient.util.Logging import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.io.File import java.io.FileInputStream import java.io.IOException -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -class DownloadFileViewModel : DualViewModelBase() { - inner class Cleanup(private val context: Context, - private val tempfile: File, - private val newFileUri: Uri) { - fun delete() { - tempfile.delete() - DocumentFile.fromSingleUri(context, newFileUri)?.delete() - } - } +class DownloadFileViewModel : ViewModel() { + // The URI to write the resulting file + private var _outFileUri = MutableLiveData() + // Source path in the server + private val _sourceFilePath = MutableLiveData() + // Event when a download succeeded + private val _downloadEvent = MutableSharedFlow() - override suspend fun coroutineFunction(activity: FragmentActivity) = suspendCancellableCoroutine { - cancellableContinuation -> - val contentUri = liveData.value!! - val outFile = File(liveData2.value!!) - val tempFile = File(activity.cacheDir, outFile.name) - tempFile.delete() - outFile.delete() + val downloadEvent: SharedFlow + get() = _downloadEvent + val outFileURI : LiveData + get() = _outFileUri + val sourceFilePath : LiveData + get() = _sourceFilePath + + fun setFileUrl(uri: Uri) { + _outFileUri.value = uri + } + fun setSourceFile(path: String) { + _sourceFilePath.value = path + } + private fun openFile(activity: FragmentActivity): Uri { + val contentUri = _outFileUri.value!! + val outFile = File(_sourceFilePath.value!!) val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(outFile.extension) ?: "application/octet-stream" val docFile = DocumentFile.fromTreeUri(activity, contentUri)?.run { findFile(outFile.name)?.let { @@ -48,53 +56,47 @@ class DownloadFileViewModel : DualViewModelBase - SocketCommandNative.sendWriteMessageToChatId(chatid, message, - object : SocketCommandNative.ICommandStatusCallback { - override fun onStatusUpdate(status: SocketCommandNative.Status) { - - } - - override fun onSuccess(result: Any?) { - continuation.resumeWith(Result.success(Unit)) - } - override fun onError(error: String) { - continuation.resumeWith(Result.failure(RuntimeException(error))) - } - }) - } - fun send(chat: Long, message: String) { - gMainScope.launch { - var success = true - try { - withContext(Dispatchers.IO) { - sendAndWait(chat, message) - } - } catch (e: RuntimeException) { - success = false - _sendResult.value = "Failed: ${e.message}" - } - if (success) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + SocketContext.getInstance().sendMessage(chat, message) + }.onSuccess { _sendResult.value = "Message sent" + }.onFailure { e -> + _sendResult.value = "Failed: ${e.message}" } } } - override fun onCleared() { - super.onCleared() - gMainScope.cancel() - } - fun getAll() = viewModelScope.async { withContext(Dispatchers.IO) { operation.getAll() diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uploadfile/UploadFileFragment.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uploadfile/UploadFileFragment.kt index 7c38ecea..1a2cd64d 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uploadfile/UploadFileFragment.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uploadfile/UploadFileFragment.kt @@ -5,21 +5,22 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import androidx.fragment.app.commit +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.preference.PreferenceManager -import com.google.android.material.snackbar.Snackbar import com.royna.tgbotclient.R -import com.royna.tgbotclient.SocketCommandNative -import com.royna.tgbotclient.SocketCommandNative.setUploadFileOptions -import com.royna.tgbotclient.SocketCommandNative.UploadOption.* import com.royna.tgbotclient.databinding.FragmentUploadFileBinding +import com.royna.tgbotclient.net.SocketContext import com.royna.tgbotclient.ui.CurrentSettingFragment import com.royna.tgbotclient.util.FileUtils.queryFileName -import com.royna.tgbotclient.util.Logging +import kotlinx.coroutines.launch class UploadFileFragment : Fragment() { private var _binding: FragmentUploadFileBinding? = null @@ -39,7 +40,7 @@ class UploadFileFragment : Fragment() { val startForResult: ActivityResultLauncher> = registerForActivityResult(ActivityResultContracts.OpenDocument()) { if (it != null) { - vm.setLiveData(it) + vm.setFileUri(it) queryFileName(requireActivity().contentResolver, it) ?.let { filename -> binding.uploadFileView.text = filename @@ -55,50 +56,49 @@ class UploadFileFragment : Fragment() { startForResult.launch(arrayOf("*/*")) } binding.uploadUploadButton.setOnClickListener { - vm.execute(requireActivity(), object : SocketCommandNative.ICommandCallback { - override fun onSuccess(result: Any?) { - Snackbar.make(it, "File uploaded", Snackbar.LENGTH_SHORT).show() - } - - override fun onError(error: String) { - Snackbar.make(it, "File upload failed: $error", Snackbar.LENGTH_SHORT).show() - } - }) + vm.execute(requireActivity()) } binding.uploadUploadButton.isEnabled = false val pref = PreferenceManager.getDefaultSharedPreferences(requireContext()) - fun updateOptionAndPref(option: SocketCommandNative.UploadOption) { - setUploadFileOptions(option) + fun updateOptionAndPref(option: SocketContext.UploadOption) { + SocketContext.getInstance().setUploadFileOptions(option) pref.edit().putInt(UploadOptionPref, option.value).apply() } // Allowance level 1 binding.uploadOption1.setOnClickListener { - updateOptionAndPref(MUST_NOT_EXIST) + updateOptionAndPref(SocketContext.UploadOption.MUST_NOT_EXIST) } // Allowance level 2 binding.uploadOption2.setOnClickListener { - updateOptionAndPref(MUST_NOT_MATCH_CHECKSUM) + updateOptionAndPref(SocketContext.UploadOption.MUST_NOT_MATCH_CHECKSUM) } // Allowance level 3 binding.uploadOption3.setOnClickListener { - updateOptionAndPref(ALWAYS) + updateOptionAndPref(SocketContext.UploadOption.ALWAYS) } val kBindingMap = mapOf( - MUST_NOT_EXIST to binding.uploadOption1, - MUST_NOT_MATCH_CHECKSUM to binding.uploadOption2, - ALWAYS to binding.uploadOption3 + SocketContext.UploadOption.MUST_NOT_EXIST to binding.uploadOption1, + SocketContext.UploadOption.MUST_NOT_MATCH_CHECKSUM to binding.uploadOption2, + SocketContext.UploadOption.ALWAYS to binding.uploadOption3 ) - pref.getUploadOption(UploadOptionPref, MUST_NOT_EXIST).let { num -> + pref.getUploadOption(UploadOptionPref, SocketContext.UploadOption.MUST_NOT_EXIST).let { num -> kBindingMap[num]?.isChecked = true updateOptionAndPref(num) } + lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + vm.uploadResult.collect { message -> + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + } + } + } return root } - private fun SharedPreferences.getUploadOption(key: String, default: SocketCommandNative.UploadOption): - SocketCommandNative.UploadOption { + private fun SharedPreferences.getUploadOption(key: String, default: SocketContext.UploadOption): + SocketContext.UploadOption { return getInt(key, default.value).let { - SocketCommandNative.UploadOption.entries.find { ent -> + SocketContext.UploadOption.entries.find { ent -> ent.value == it } } ?: default diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uploadfile/UploadFileViewModel.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uploadfile/UploadFileViewModel.kt index c5e70a8c..0a56cbe2 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uploadfile/UploadFileViewModel.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uploadfile/UploadFileViewModel.kt @@ -2,25 +2,37 @@ package com.royna.tgbotclient.ui.commands.uploadfile import android.net.Uri import androidx.fragment.app.FragmentActivity -import com.royna.tgbotclient.SocketCommandNative -import com.royna.tgbotclient.ui.SingleViewModelBase +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import com.royna.tgbotclient.net.SocketContext import com.royna.tgbotclient.util.FileUtils.copyToExt import com.royna.tgbotclient.util.FileUtils.getFileExtension import com.royna.tgbotclient.util.FileUtils.queryFileName import com.royna.tgbotclient.util.Logging import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -class UploadFileViewModel : SingleViewModelBase() { - override suspend fun coroutineFunction(activity: FragmentActivity) = suspendCancellableCoroutine { - cancellableContinuation -> - val contentUri = liveData.value!! +class UploadFileViewModel : ViewModel() { + // The Uri of the selected file + private val sourceFileUri = MutableLiveData() + fun setFileUri(uri: Uri) { + sourceFileUri.value = uri + } + + // The result of the upload + private val _uploadResult = MutableSharedFlow() + val uploadResult: SharedFlow + get() = _uploadResult + + + private suspend fun uploadFile(activity: FragmentActivity) = runCatching { + val contentUri = sourceFileUri.value!! val fileExtension = getFileExtension(activity, contentUri) val fileName = "temp_file" + if (fileExtension != null) ".$fileExtension" else "" @@ -34,38 +46,25 @@ class UploadFileViewModel : SingleViewModelBase() { try { activity.contentResolver.openInputStream(contentUri)?.copyToExt(FileOutputStream(tempFile)) } catch (e: Exception) { - e.printStackTrace() - cancellableContinuation.resumeWithException(e) - return@suspendCancellableCoroutine + Logging.error("Failed to copy to destination", e) + throw e } val name = queryFileName(activity.contentResolver,contentUri) ?: fileName Logging.info("Uploading file as : $name") - SocketCommandNative.uploadFile(tempFile.absolutePath, name, - object : SocketCommandNative.ICommandStatusCallback { - override fun onStatusUpdate(status: SocketCommandNative.Status) { - } - - override fun onSuccess(result: Any?) { - Logging.info ("File uploaded successfully") - cancellableContinuation.resume(Unit) - } - - override fun onError(error: String) { - Logging.error ("File upload failed: $error") - cancellableContinuation.resumeWithException(RuntimeException(error)) - } - }) + SocketContext.getInstance().uploadFile(tempFile, name).getOrThrow() } - override fun execute(activity: FragmentActivity, callback: SocketCommandNative.ICommandCallback) { - gMainScope.launch { - try { - withContext(Dispatchers.IO){ - coroutineFunction(activity) - } - callback.onSuccess(null) - } catch (e: RuntimeException) { - callback.onError(e.message.toString()) + + fun execute(activity: FragmentActivity) { + activity.lifecycleScope.launch { + withContext(Dispatchers.IO){ + uploadFile(activity) + }.onFailure { + Logging.error("Failed to upload file", it) + _uploadResult.emit("Failed to upload file: ${it.message}") + }.onSuccess { + Logging.info("File uploaded") + _uploadResult.emit("File uploaded") } } } diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uptime/UptimeFragment.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uptime/UptimeFragment.kt index 537a26c4..65d400ee 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uptime/UptimeFragment.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uptime/UptimeFragment.kt @@ -9,7 +9,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.commit import androidx.lifecycle.ViewModelProvider import com.royna.tgbotclient.R -import com.royna.tgbotclient.SocketCommandNative import com.royna.tgbotclient.databinding.FragmentUptimeBinding import com.royna.tgbotclient.ui.CurrentSettingFragment @@ -27,35 +26,23 @@ class UptimeFragment : Fragment() { savedInstanceState: Bundle? ): View { val uptimeViewModel = - ViewModelProvider(this).get(UptimeViewModel::class.java) + ViewModelProvider(this)[UptimeViewModel::class.java] _binding = FragmentUptimeBinding.inflate(inflater, container, false) val root: View = binding.root val textView: TextView = binding.uptimeText - uptimeViewModel.liveData.observe(viewLifecycleOwner) { + uptimeViewModel.uptimeValue.observe(viewLifecycleOwner) { textView.text = it } + uptimeViewModel.fetchInProgress.observe(viewLifecycleOwner) { + binding.uptimeRefreshBtn.isEnabled = !it + binding.uptimeRefreshBtn.text = if (it) getString(R.string.get_wip) else getString(R.string.get) + } binding.uptimeRefreshBtn.setOnClickListener { - binding.uptimeRefreshBtn.text = getString(R.string.get_wip) - binding.uptimeRefreshBtn.isEnabled = false - uptimeViewModel.execute(requireActivity(), object : SocketCommandNative.ICommandCallback { - override fun onSuccess(result: Any?) { - update(result as String) - } - - override fun onError(error: String) { - update(error) - } - - fun update(text: String) { - binding.uptimeRefreshBtn.text = getString(R.string.get) - binding.uptimeRefreshBtn.isEnabled = true - binding.uptimeText.text = text - } - }) + uptimeViewModel.execute() } - uptimeViewModel.setLiveData(getString(R.string.get_desc)) + textView.text = getString(R.string.get_desc) return root } diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uptime/UptimeViewModel.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uptime/UptimeViewModel.kt index c1fabca3..9fd4af30 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uptime/UptimeViewModel.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/commands/uptime/UptimeViewModel.kt @@ -1,52 +1,35 @@ package com.royna.tgbotclient.ui.commands.uptime -import androidx.fragment.app.FragmentActivity -import com.royna.tgbotclient.R -import com.royna.tgbotclient.SocketCommandNative -import com.royna.tgbotclient.SocketCommandNative.getUptime -import com.royna.tgbotclient.ui.SingleViewModelBase +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.royna.tgbotclient.net.SocketContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlin.coroutines.resumeWithException -class UptimeViewModel : SingleViewModelBase() { +class UptimeViewModel : ViewModel() { + private var _uptimeValue = MutableLiveData() + private var _fetchInProgress = MutableLiveData() - override suspend fun coroutineFunction(activity: FragmentActivity): String = suspendCancellableCoroutine { - getUptime(object : SocketCommandNative.ICommandStatusCallback { - override fun onStatusUpdate(status: SocketCommandNative.Status) { + val uptimeValue: MutableLiveData + get() = _uptimeValue + val fetchInProgress: MutableLiveData + get() = _fetchInProgress - } - - override fun onSuccess(result: Any?) { - if (result is String) { - it.resumeWith(Result.success(result)) - } else { - it.resumeWithException(AssertionError("Unknown result type")) - } - } - - override fun onError(error: String) { - it.resumeWithException(RuntimeException(error)) - } - }) - } - override fun execute( - activity: FragmentActivity, - callback: SocketCommandNative.ICommandCallback - ) { - gMainScope.launch { + fun execute() { + viewModelScope.launch { val result : String - _liveData.postValue(activity.getString(R.string.get_wip)) - try { - withContext(Dispatchers.IO) { - result = coroutineFunction(activity) - } - callback.onSuccess(result) - } catch (e: Exception) { - callback.onError(e.message.toString()) + _fetchInProgress.postValue(true) + withContext(Dispatchers.IO) { + SocketContext.getInstance().getUptime() + }.onSuccess { + result = it as String + _uptimeValue.postValue(result) + }.onFailure { + _uptimeValue.postValue(it.message) } + _fetchInProgress.postValue(false) } } } \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/home/HomeFragment.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/home/HomeFragment.kt index b64736fa..1e78c8f8 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/home/HomeFragment.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/home/HomeFragment.kt @@ -10,8 +10,6 @@ import android.widget.TextView import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider -import com.royna.tgbotclient.SocketCommandNative -import com.royna.tgbotclient.SocketCommandNative.changeDestinationInfo import com.royna.tgbotclient.databinding.FragmentHomeBinding class HomeFragment : Fragment() { diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/home/HomeViewModel.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/home/HomeViewModel.kt index 29d9e0cb..a6871874 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/home/HomeViewModel.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/home/HomeViewModel.kt @@ -3,7 +3,6 @@ package com.royna.tgbotclient.ui.home import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.royna.tgbotclient.SocketCommandNative class HomeViewModel : ViewModel() { } \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/settings/TgClientSettings.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/settings/TgClientSettings.kt index 06ee43cb..402e184d 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/settings/TgClientSettings.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/ui/settings/TgClientSettings.kt @@ -4,52 +4,40 @@ import android.content.Context import android.os.Bundle import android.util.Log import android.widget.Toast -import androidx.preference.CheckBoxPreference import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.royna.tgbotclient.R -import com.royna.tgbotclient.SocketCommandNative +import com.royna.tgbotclient.net.SocketContext +import io.ktor.network.sockets.InetSocketAddress +import java.net.UnknownHostException class TgClientSettings : PreferenceFragmentCompat() { private lateinit var mIPAddressEditText: EditTextPreference private lateinit var mPortEditText: EditTextPreference - private lateinit var mIPv4Switch: CheckBoxPreference - private lateinit var mIPv6Switch: CheckBoxPreference - private var mConfig = SocketCommandNative.DestinationType.IPv4 - private var mIPAddressStr = "127.0.0.1" - private var mPort = 0 + + data class InetConfig(var mHostName: String, var mPort: Int) + private var mAddress = InetConfig(DEFAULT_INET_ADDRESS, DEFAULT_PORT) override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.preference_main) - mIPv4Switch = findPreference("ipv4")!! - mIPv6Switch = findPreference("ipv6")!! mIPAddressEditText = findPreference("ip_address")!! mPortEditText = findPreference("port_number")!! PreferenceManager.getDefaultSharedPreferences(requireContext()).apply { - val mIPAddressStr_in = getString(kIPAddressPref, mIPAddressStr)!! - val mConfig_in = toInetConfig(getInt(kInetConfigPref, kInet4)) - val mPort_in = getInt(kPortPref, mPort) + val mIPAddressStr_in = getString(kIPAddressPref, DEFAULT_INET_ADDRESS)!! + val mPort_in = getInt(kPortPref, DEFAULT_PORT) - update(UpdateInfo(mIPAddressStr_in, mConfig_in, mPort_in), true) + update(mIPAddressStr_in, mPort_in, true) } - mIPv4Switch.setOnPreferenceClickListener { - update(UpdateInfo(type = SocketCommandNative.DestinationType.IPv4)) - true - } - mIPv6Switch.setOnPreferenceClickListener { - update(UpdateInfo(type = SocketCommandNative.DestinationType.IPv6)) - true - } mIPAddressEditText.setOnPreferenceChangeListener { _, newValue -> val mNewIPAddressStr = newValue as String if (!RegexIPv4.matches(mNewIPAddressStr) and !RegexIPv6.matches(mNewIPAddressStr)) { Toast.makeText(context, "Invalid IP address, not saved", Toast.LENGTH_SHORT).show() return@setOnPreferenceChangeListener false } - update(UpdateInfo(ipaddr = mNewIPAddressStr)) + update(mHostName = mNewIPAddressStr) true } mPortEditText.setOnPreferenceChangeListener { _, newValue -> @@ -58,95 +46,73 @@ class TgClientSettings : PreferenceFragmentCompat() { Toast.makeText(context, "Invalid port number, not saved", Toast.LENGTH_SHORT).show() return@setOnPreferenceChangeListener false } - update(UpdateInfo(port=mNewPortStr.toInt())) + update(mPort=mNewPortStr.toInt()) true } } - data class UpdateInfo(var ipaddr: String? = null, - var type: SocketCommandNative.DestinationType? = null, - var port: Int? = null) - - private fun update(updateInfo: UpdateInfo, force: Boolean = false) { - var mConfigChanged = false + private fun update(mHostName: String? = null, mPort: Int? = null, force: Boolean = false) { var mIPAddressStrChanged = false var mPortChanged = false - if (updateInfo.type?.let { updateInfo.type != mConfig || force } == true) { - mConfigChanged = true - mConfig = updateInfo.type!! - } - if (updateInfo.ipaddr?.let { updateInfo.ipaddr != mIPAddressStr || force } == true) { + if (mHostName?.let { mHostName != mAddress.mHostName || force } == true) { mIPAddressStrChanged = true - mIPAddressStr = updateInfo.ipaddr!! + mAddress.mHostName = mHostName } - if (updateInfo.port?.let { updateInfo.port != mPort || force } == true) { + if (mPort?.let { mPort != mAddress.mPort || force } == true) { mPortChanged = true - mPort = updateInfo.port!! - } - if (mConfigChanged) { - mIPv4Switch.isChecked = mConfig == SocketCommandNative.DestinationType.IPv4 - mIPv6Switch.isChecked = mConfig == SocketCommandNative.DestinationType.IPv6 + mAddress.mPort = mPort } if (mIPAddressStrChanged) { - mIPAddressEditText.text = mIPAddressStr - mIPAddressEditText.summary = mIPAddressStr + mIPAddressEditText.text = mAddress.mHostName + mIPAddressEditText.summary = mAddress.mHostName } if (mPortChanged) { mPortEditText.summary = mPort.toString() } + if (mIPAddressStrChanged or mPortChanged) { + if (!updateDestination(mAddress)) { + Toast.makeText(context, "Cannot apply changes", Toast.LENGTH_SHORT).show() + return + } + } PreferenceManager.getDefaultSharedPreferences(requireContext()).apply { if (mIPAddressStrChanged) { - edit().putString(kIPAddressPref, mIPAddressStr).apply() - } - if (mConfigChanged) { - when (mConfig) { - SocketCommandNative.DestinationType.IPv4 -> { - edit().putInt(kInetConfigPref, kInet4).apply() - } - SocketCommandNative.DestinationType.IPv6 -> { - edit().putInt(kInetConfigPref, kInet6).apply() - } - } + edit().putString(kIPAddressPref, mAddress.mHostName).apply() } if (mPortChanged) { - edit().putInt(kPortPref, mPort).apply() + edit().putInt(kPortPref, mAddress.mPort).apply() } } - if (mConfigChanged or mIPAddressStrChanged or mPortChanged) { - SocketCommandNative.changeDestinationInfo(mIPAddressStr, mConfig, mPort) - } } + companion object { private val RegexIPv4 = Regex("\\b((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\b") private val RegexIPv6 = Regex("\\b([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\b") private const val kIPAddressPref = "ip_address" - private const val kInetConfigPref = "inet_version" private const val kPortPref = "port_num" - private const val kInet4 = 4 - private const val kInet6 = 6 private const val kTag = "TgClientSettings" - fun toInetConfig(num : Int) : SocketCommandNative.DestinationType { - return when (num) { - kInet4 -> { - SocketCommandNative.DestinationType.IPv4 - } - kInet6 -> { - SocketCommandNative.DestinationType.IPv6 - } - else -> { - Log.e(kTag, "Invalid inet version, default to IPv4") - SocketCommandNative.DestinationType.IPv4 - } + private const val DEFAULT_INET_ADDRESS = "127.0.0.1" + private const val DEFAULT_PORT = 50000 + + private fun updateDestination(mInetConfig: InetConfig): Boolean { + try { + SocketContext.getInstance().destination = InetSocketAddress(mInetConfig.mHostName, mInetConfig.mPort) + } catch (e: UnknownHostException) { + Log.e(kTag, "Failed to update destination", e) + return false + } catch (e: IllegalArgumentException) { + Log.e(kTag, "Failed to update destination", e) + return false } + return true } + fun loadConfig(c: Context) { PreferenceManager.getDefaultSharedPreferences(c).apply { - val mIPAddressStr_in = getString(kIPAddressPref, "127.0.0.1")!! - val mConfig_in = - toInetConfig(getInt(kInetConfigPref, kInet4)) - val mPort_in = getInt(kPortPref, 0) - SocketCommandNative.changeDestinationInfo(mIPAddressStr_in, mConfig_in, mPort_in) + val mIPAddressStr_in = getString(kIPAddressPref, DEFAULT_INET_ADDRESS)!! + val mPort_in = getInt(kPortPref, DEFAULT_PORT) + updateDestination(InetConfig(mIPAddressStr_in, mPort_in)) } } } diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/util/DeviceUtils.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/util/DeviceUtils.kt index 054c16aa..24760da7 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/util/DeviceUtils.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/util/DeviceUtils.kt @@ -1,6 +1,9 @@ package com.royna.tgbotclient.util import android.app.Activity +import android.content.Context +import android.view.View +import android.view.inputmethod.InputMethodManager import androidx.window.layout.WindowMetricsCalculator object DeviceUtils { @@ -9,4 +12,9 @@ object DeviceUtils { fun getScreenWidth(activity: Activity): Int = WindowMetricsCalculator.getOrCreate() .computeCurrentWindowMetrics(activity).bounds.width() + + fun hideKeyboard(view: View) { + val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + } } \ No newline at end of file diff --git a/clientapp/android/app/src/main/java/com/royna/tgbotclient/util/Logging.kt b/clientapp/android/app/src/main/java/com/royna/tgbotclient/util/Logging.kt index 47a51554..61152bf0 100644 --- a/clientapp/android/app/src/main/java/com/royna/tgbotclient/util/Logging.kt +++ b/clientapp/android/app/src/main/java/com/royna/tgbotclient/util/Logging.kt @@ -4,33 +4,34 @@ import android.util.Log import com.royna.tgbotclient.BuildConfig object Logging { - private const val TAG = "TGBotCli::APP" + private const val TAG = "TGBotCli" private val DEBUG = BuildConfig.DEBUG fun info(message: String) { - Log.i(TAG, "[${getCaller()}] $message") + Log.i(getTag(), message) } fun error(message: String) { - Log.e(TAG, "[${getCaller()}] $message") + Log.e(getTag(), message) } + fun error(message: String, t: Throwable) { - Log.e(TAG, "[${getCaller()}] $message", t) + Log.e(getTag(), message, t) } fun warn(message: String) { - Log.w(TAG, "[${getCaller()}] $message") + Log.w(getTag(), message) } fun debug(message: String) { if (DEBUG) { - Log.d(TAG, "[${getCaller()}] $message") + Log.d(getTag(), message) } } fun verbose(message: String) { if (DEBUG) { - Log.v(TAG, "[${getCaller()}] $message") + Log.v(TAG, message) } } @@ -40,21 +41,9 @@ object Logging { it.className.contains(myPackageName) && !it.className .contains(Logging.javaClass.simpleName) } - if (element == null) { - // Raise empty exception to log stack trace - Log.e(TAG, "getCaller: Failed to find caller", Exception()) - return "Unknown" - } - var className = element.className.removePrefix("$myPackageName.") - val methodName = element.methodName - if (className.contains("$")) { - val classNameSplit = className.split("$") - // Actual class name - className = classNameSplit[0] - // Method of the class name - val memberFunctionName = classNameSplit[1] - className = "(anonymous class of $className::$memberFunctionName)" - } - return "Class '$className', Method '$methodName'" + return element?.className?.substringAfterLast('.')?.substringBefore('$') ?: "Unknown" + } + private fun getTag() : String { + return "$TAG::${getCaller()}" } } diff --git a/clientapp/android/app/src/main/res/layout/fragment_current_setting_child.xml b/clientapp/android/app/src/main/res/layout/fragment_current_setting_child.xml index 37101848..25dfe48e 100644 --- a/clientapp/android/app/src/main/res/layout/fragment_current_setting_child.xml +++ b/clientapp/android/app/src/main/res/layout/fragment_current_setting_child.xml @@ -22,15 +22,9 @@ - diff --git a/clientapp/android/app/src/main/res/xml/preference_main.xml b/clientapp/android/app/src/main/res/xml/preference_main.xml index 4037012e..cff7aab2 100644 --- a/clientapp/android/app/src/main/res/xml/preference_main.xml +++ b/clientapp/android/app/src/main/res/xml/preference_main.xml @@ -14,14 +14,4 @@ android:inputType="number"/> - - - - - \ No newline at end of file diff --git a/clientapp/android/gradle/libs.versions.toml b/clientapp/android/gradle/libs.versions.toml index f7b533f7..91f9f70b 100644 --- a/clientapp/android/gradle/libs.versions.toml +++ b/clientapp/android/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] agp = "8.6.1" +gsonVersion = "2.11.0" hilt = "2.48" espressoCoreVersion = "3.6.1" junitJupiter = "5.8.1" @@ -7,12 +8,13 @@ kotlin = "2.0.0" coreKtx = "1.15.0" junit = "4.13.2" appcompat = "1.7.0" +ktorNetwork = "3.0.2" material = "1.12.0" activity = "1.9.3" lifecycleLivedataKtx = "2.8.7" lifecycleViewmodelKtx = "2.8.7" -navigationFragmentKtx = "2.8.4" -navigationUiKtx = "2.8.4" +navigationFragmentKtx = "2.8.5" +navigationUiKtx = "2.8.5" preferenceKtx = "1.2.1" fragmentKtx = "1.8.5" roomCompiler = "2.6.1" @@ -28,12 +30,14 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomCompiler" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomCompiler" } androidx-window = { module = "androidx.window:window", version.ref = "window" } +google-gson = { module = "com.google.code.gson:gson", version.ref = "gsonVersion" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } hilt = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" } +ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktorNetwork" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleLivedataKtx" }