From 7aaecd92781b201cf8b33c4271f65f25f051eec0 Mon Sep 17 00:00:00 2001 From: MX <10697207+xMasterX@users.noreply.github.com> Date: Wed, 18 Jan 2023 20:05:01 +0300 Subject: [PATCH] Add text viewer --- ReadMe.md | 3 +- applications/plugins/text_viewer/LICENSE | 21 ++ applications/plugins/text_viewer/README.md | 9 + .../plugins/text_viewer/application.fam | 16 + .../plugins/text_viewer/icons/text_10px.png | Bin 0 -> 158 bytes .../plugins/text_viewer/text_viewer.c | 282 ++++++++++++++++++ .../plugins/text_viewer/textviewerflipper.PNG | Bin 0 -> 11796 bytes 7 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 applications/plugins/text_viewer/LICENSE create mode 100644 applications/plugins/text_viewer/README.md create mode 100644 applications/plugins/text_viewer/application.fam create mode 100644 applications/plugins/text_viewer/icons/text_10px.png create mode 100644 applications/plugins/text_viewer/text_viewer.c create mode 100644 applications/plugins/text_viewer/textviewerflipper.PNG diff --git a/ReadMe.md b/ReadMe.md index c6e29a6be..3ae674140 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -126,7 +126,8 @@ You can support us by using links or addresses below: - **iButton Fuzzer** [(by xMasterX)](https://github.com/xMasterX/ibutton-fuzzer) - HEX Viewer [(by QtRoS)](https://github.com/QtRoS/flipper-zero-hex-viewer) - POCSAG Pager [(by xMasterX & Shmuma)](https://github.com/xMasterX/flipper-pager) -- UART Terminal [(by cool4uma)](https://github.com/cool4uma/UART_Terminal/tree/main) +- Text Viewer [(by kowalski7cc & kyhwana)](https://github.com/kowalski7cc/flipper-zero-text-viewer/tree/refactor-text-app) +- **UART Terminal** [(by cool4uma)](https://github.com/cool4uma/UART_Terminal/tree/main) Games: - DOOM (fixed) [(by p4nic4ttack)](https://github.com/p4nic4ttack/doom-flipper-zero/) diff --git a/applications/plugins/text_viewer/LICENSE b/applications/plugins/text_viewer/LICENSE new file mode 100644 index 000000000..69004dc62 --- /dev/null +++ b/applications/plugins/text_viewer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Roman Shchekin + +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. diff --git a/applications/plugins/text_viewer/README.md b/applications/plugins/text_viewer/README.md new file mode 100644 index 000000000..cc41931be --- /dev/null +++ b/applications/plugins/text_viewer/README.md @@ -0,0 +1,9 @@ +# flipper-zero-text-viewer + +Text Viewer application for Flipper Zero! + +A fork with a few changes from [QTRoS' hex viewer](https://github.com/QtRoS/flipper-zero-hex-viewer) to just display text without any hex byte representation + +![Text Viewer app!](https://github.com/kyhwana/flipper-zero-hex-viewer/blob/master/textviewerflipper.PNG?raw=true) + +[Link to FAP](https://github.com/kyhwana/latest_flipper_zero_apps/raw/main/text_viewer.fap) diff --git a/applications/plugins/text_viewer/application.fam b/applications/plugins/text_viewer/application.fam new file mode 100644 index 000000000..dcd573c9d --- /dev/null +++ b/applications/plugins/text_viewer/application.fam @@ -0,0 +1,16 @@ +App( + appid="text_viewer", + name="Text Viewer", + apptype=FlipperAppType.EXTERNAL, + entry_point="text_viewer_app", + cdefines=["APP_TEXT_VIEWER"], + requires=[ + "gui", + "dialogs", + ], + stack_size=2 * 1024, + order=20, + fap_icon="icons/text_10px.png", + fap_category="Misc", + fap_icon_assets="icons", +) diff --git a/applications/plugins/text_viewer/icons/text_10px.png b/applications/plugins/text_viewer/icons/text_10px.png new file mode 100644 index 0000000000000000000000000000000000000000..8e8a6183dd50535729dc9c9b4f220a12dd4c600f GIT binary patch literal 158 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2VGmzZ%#=aj&u?6^qxB}__|Nk$&IsYz@rRC}3 z7*a7OIiZ2U&CSi=;0cBn1vTatM&Z;3u7g(^G9`qQn09G2aWeNXaKC0S=Q~tg57Z@F z;u=vBoS#-wo>-L1;E+?AmspUPnOCA;ke9BToS%}K{MA`f4ycg9)78&qol`;+00Iau A9smFU literal 0 HcmV?d00001 diff --git a/applications/plugins/text_viewer/text_viewer.c b/applications/plugins/text_viewer/text_viewer.c new file mode 100644 index 000000000..6c7b46579 --- /dev/null +++ b/applications/plugins/text_viewer/text_viewer.c @@ -0,0 +1,282 @@ +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#define TAG "TextViewer" + +#define TEXT_VIEWER_APP_PATH_FOLDER ANY_PATH("") +#define TEXT_VIEWER_APP_EXTENSION "*" + +#define TEXT_VIEWER_BYTES_PER_LINE 20u +#define TEXT_VIEWER_LINES_ON_SCREEN 5u +#define TEXT_VIEWER_BUF_SIZE (TEXT_VIEWER_LINES_ON_SCREEN * TEXT_VIEWER_BYTES_PER_LINE) + +typedef struct { + uint8_t file_bytes[TEXT_VIEWER_LINES_ON_SCREEN][TEXT_VIEWER_BYTES_PER_LINE]; + uint32_t file_offset; + uint32_t file_read_bytes; + uint32_t file_size; + Stream* stream; + bool mode; // Print address or content +} TextViewerModel; + +typedef struct { + TextViewerModel* model; + FuriMutex** mutex; + + FuriMessageQueue* input_queue; + + ViewPort* view_port; + Gui* gui; + Storage* storage; +} TextViewer; + +static void render_callback(Canvas* canvas, void* ctx) { + TextViewer* text_viewer = ctx; + furi_check(furi_mutex_acquire(text_viewer->mutex, FuriWaitForever) == FuriStatusOk); + + canvas_clear(canvas); + canvas_set_color(canvas, ColorBlack); + + //elements_button_left(canvas, text_viewer->model->mode ? "Addr" : "Text"); + text_viewer->model->mode = 1; //text mode + //elements_button_right(canvas, "Info"); + + int ROW_HEIGHT = 12; + int TOP_OFFSET = 10; + int LEFT_OFFSET = 3; + + uint32_t line_count = text_viewer->model->file_size / TEXT_VIEWER_BYTES_PER_LINE; + if(text_viewer->model->file_size % TEXT_VIEWER_BYTES_PER_LINE != 0) line_count += 1; + uint32_t first_line_on_screen = text_viewer->model->file_offset / TEXT_VIEWER_BYTES_PER_LINE; + if(line_count > TEXT_VIEWER_LINES_ON_SCREEN) { + uint8_t width = canvas_width(canvas); + elements_scrollbar_pos( + canvas, + width, + 0, + ROW_HEIGHT * TEXT_VIEWER_LINES_ON_SCREEN, + first_line_on_screen, // TODO + line_count - (TEXT_VIEWER_LINES_ON_SCREEN - 1)); + } + + char temp_buf[32]; + uint32_t row_iters = text_viewer->model->file_read_bytes / TEXT_VIEWER_BYTES_PER_LINE; + if(text_viewer->model->file_read_bytes % TEXT_VIEWER_BYTES_PER_LINE != 0) row_iters += 1; + + for(uint32_t i = 0; i < row_iters; ++i) { + uint32_t bytes_left_per_row = + text_viewer->model->file_read_bytes - i * TEXT_VIEWER_BYTES_PER_LINE; + bytes_left_per_row = MIN(bytes_left_per_row, TEXT_VIEWER_BYTES_PER_LINE); + + if(text_viewer->model->mode) { + memcpy(temp_buf, text_viewer->model->file_bytes[i], bytes_left_per_row); + temp_buf[bytes_left_per_row] = '\0'; + for(uint32_t j = 0; j < bytes_left_per_row; ++j) + if(!isprint((int)temp_buf[j])) temp_buf[j] = ' '; + + canvas_set_font(canvas, FontKeyboard); + canvas_draw_str(canvas, LEFT_OFFSET, TOP_OFFSET + i * ROW_HEIGHT, temp_buf); + } else { + uint32_t addr = text_viewer->model->file_offset + i * TEXT_VIEWER_BYTES_PER_LINE; + snprintf(temp_buf, 32, "%04lX", addr); + + canvas_set_font(canvas, FontKeyboard); + canvas_draw_str(canvas, LEFT_OFFSET, TOP_OFFSET + i * ROW_HEIGHT, temp_buf); + } + } + + furi_mutex_release(text_viewer->mutex); +} + +static void input_callback(InputEvent* input_event, void* ctx) { + TextViewer* text_viewer = ctx; + if(input_event->type == InputTypeShort || input_event->type == InputTypeRepeat) { + furi_message_queue_put(text_viewer->input_queue, input_event, 0); + } +} + +static TextViewer* text_viewer_alloc() { + TextViewer* instance = malloc(sizeof(TextViewer)); + + instance->model = malloc(sizeof(TextViewerModel)); + memset(instance->model, 0x0, sizeof(TextViewerModel)); + + instance->mutex = furi_mutex_alloc(FuriMutexTypeNormal); + + instance->input_queue = furi_message_queue_alloc(8, sizeof(InputEvent)); + + instance->view_port = view_port_alloc(); + view_port_draw_callback_set(instance->view_port, render_callback, instance); + view_port_input_callback_set(instance->view_port, input_callback, instance); + + instance->gui = furi_record_open(RECORD_GUI); + gui_add_view_port(instance->gui, instance->view_port, GuiLayerFullscreen); + + instance->storage = furi_record_open(RECORD_STORAGE); + + return instance; +} + +static void text_viewer_free(TextViewer* instance) { + furi_record_close(RECORD_STORAGE); + + gui_remove_view_port(instance->gui, instance->view_port); + furi_record_close(RECORD_GUI); + view_port_free(instance->view_port); + + furi_message_queue_free(instance->input_queue); + + furi_mutex_free(instance->mutex); + + if(instance->model->stream) buffered_file_stream_close(instance->model->stream); + + free(instance->model); + free(instance); +} + +static bool text_viewer_open_file(TextViewer* text_viewer, const char* file_path) { + furi_assert(text_viewer); + furi_assert(file_path); + + text_viewer->model->stream = buffered_file_stream_alloc(text_viewer->storage); + bool isOk = true; + + do { + if(!buffered_file_stream_open( + text_viewer->model->stream, file_path, FSAM_READ, FSOM_OPEN_EXISTING)) { + FURI_LOG_E(TAG, "Unable to open stream: %s", file_path); + isOk = false; + break; + }; + + text_viewer->model->file_size = stream_size(text_viewer->model->stream); + } while(false); + + return isOk; +} + +static bool text_viewer_read_file(TextViewer* text_viewer) { + furi_assert(text_viewer); + furi_assert(text_viewer->model->stream); + furi_assert(text_viewer->model->file_offset % TEXT_VIEWER_BYTES_PER_LINE == 0); + + memset(text_viewer->model->file_bytes, 0x0, TEXT_VIEWER_BUF_SIZE); + bool isOk = true; + + do { + uint32_t offset = text_viewer->model->file_offset; + if(!stream_seek(text_viewer->model->stream, offset, true)) { + FURI_LOG_E(TAG, "Unable to seek stream"); + isOk = false; + break; + } + + text_viewer->model->file_read_bytes = stream_read( + text_viewer->model->stream, + (uint8_t*)text_viewer->model->file_bytes, + TEXT_VIEWER_BUF_SIZE); + } while(false); + + return isOk; +} + +int32_t text_viewer_app(void* p) { + TextViewer* text_viewer = text_viewer_alloc(); + + FuriString* file_path; + file_path = furi_string_alloc(); + + do { + if(p && strlen(p)) { + furi_string_set(file_path, (const char*)p); + } else { + furi_string_set(file_path, TEXT_VIEWER_APP_PATH_FOLDER); + + DialogsFileBrowserOptions browser_options; + dialog_file_browser_set_basic_options( + &browser_options, TEXT_VIEWER_APP_EXTENSION, &I_text_10px); + browser_options.hide_ext = false; + + DialogsApp* dialogs = furi_record_open(RECORD_DIALOGS); + bool res = dialog_file_browser_show(dialogs, file_path, file_path, &browser_options); + + furi_record_close(RECORD_DIALOGS); + if(!res) { + FURI_LOG_I(TAG, "No file selected"); + break; + } + } + + FURI_LOG_I(TAG, "File selected: %s", furi_string_get_cstr(file_path)); + + if(!text_viewer_open_file(text_viewer, furi_string_get_cstr(file_path))) break; + text_viewer_read_file(text_viewer); + + InputEvent input; + while(furi_message_queue_get(text_viewer->input_queue, &input, FuriWaitForever) == + FuriStatusOk) { + if(input.key == InputKeyBack) { + break; + } else if(input.key == InputKeyUp) { + furi_check(furi_mutex_acquire(text_viewer->mutex, FuriWaitForever) == FuriStatusOk); + if(text_viewer->model->file_offset > 0) { + text_viewer->model->file_offset -= TEXT_VIEWER_BYTES_PER_LINE; + if(!text_viewer_read_file(text_viewer)) break; + } + furi_mutex_release(text_viewer->mutex); + } else if(input.key == InputKeyDown) { + furi_check(furi_mutex_acquire(text_viewer->mutex, FuriWaitForever) == FuriStatusOk); + uint32_t last_byte_on_screen = + text_viewer->model->file_offset + text_viewer->model->file_read_bytes; + + if(text_viewer->model->file_size > last_byte_on_screen) { + text_viewer->model->file_offset += TEXT_VIEWER_BYTES_PER_LINE; + if(!text_viewer_read_file(text_viewer)) break; + } + furi_mutex_release(text_viewer->mutex); + } else if(input.key == InputKeyLeft) { + furi_check(furi_mutex_acquire(text_viewer->mutex, FuriWaitForever) == FuriStatusOk); + text_viewer->model->mode = !text_viewer->model->mode; + furi_mutex_release(text_viewer->mutex); + } else if(input.key == InputKeyRight) { + FuriString* buffer; + buffer = furi_string_alloc(); + furi_string_printf( + buffer, + "File path: %s\nFile size: %lu (0x%lX)", + furi_string_get_cstr(file_path), + text_viewer->model->file_size, + text_viewer->model->file_size); + + DialogsApp* dialogs = furi_record_open(RECORD_DIALOGS); + DialogMessage* message = dialog_message_alloc(); + dialog_message_set_header(message, "Text Viewer v1.1", 16, 2, AlignLeft, AlignTop); + dialog_message_set_icon(message, &I_text_10px, 3, 2); + dialog_message_set_text( + message, furi_string_get_cstr(buffer), 3, 16, AlignLeft, AlignTop); + dialog_message_set_buttons(message, NULL, NULL, "Back"); + dialog_message_show(dialogs, message); + + furi_string_free(buffer); + dialog_message_free(message); + furi_record_close(RECORD_DIALOGS); + } + view_port_update(text_viewer->view_port); + } + } while(false); + + furi_string_free(file_path); + text_viewer_free(text_viewer); + + return 0; +} \ No newline at end of file diff --git a/applications/plugins/text_viewer/textviewerflipper.PNG b/applications/plugins/text_viewer/textviewerflipper.PNG new file mode 100644 index 0000000000000000000000000000000000000000..d357b7455fced494b392f417a676360cb4f33388 GIT binary patch literal 11796 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 W