فهرست منبع

Add quac from https://github.com/xMasterX/all-the-plugins

git-subtree-dir: quac
git-subtree-mainline: 0758879dc56072fc5c5e7a3f7695dd039f9afeac
git-subtree-split: 30bd363ae6a83654e2ee0f87bca589f21dddf8ba
Willy-JL 1 سال پیش
والد
کامیت
259f455807
63فایلهای تغییر یافته به همراه3748 افزوده شده و 0 حذف شده
  1. 41 0
      quac/.github/workflows/build.yml
  2. 6 0
      quac/.gitignore
  3. 1 0
      quac/.gitsubtree
  4. 35 0
      quac/CHANGELOG.md
  5. 132 0
      quac/README.md
  6. 48 0
      quac/README_flipperlab.md
  7. 22 0
      quac/actions/action.c
  8. 13 0
      quac/actions/action.h
  9. 12 0
      quac/actions/action_i.h
  10. 70 0
      quac/actions/action_ir.c
  11. 205 0
      quac/actions/action_ir_utils.c
  12. 38 0
      quac/actions/action_ir_utils.h
  13. 45 0
      quac/actions/action_nfc.c
  14. 173 0
      quac/actions/action_qpl.c
  15. 111 0
      quac/actions/action_rfid.c
  16. 305 0
      quac/actions/action_subghz.c
  17. 17 0
      quac/application.fam
  18. 0 0
      quac/images/.gitkeep
  19. BIN
      quac/images/ArrowDown_8x4.png
  20. BIN
      quac/images/ArrowUp_8x4.png
  21. BIN
      quac/images/Directory_10px.png
  22. BIN
      quac/images/IR_10px.png
  23. BIN
      quac/images/NFC_10px.png
  24. BIN
      quac/images/Playlist_10px.png
  25. BIN
      quac/images/RFID_10px.png
  26. BIN
      quac/images/Settings_10px.png
  27. BIN
      quac/images/SubGHz_10px.png
  28. BIN
      quac/images/Unknown_10px.png
  29. BIN
      quac/images/quac.png
  30. 170 0
      quac/item.c
  31. 65 0
      quac/item.h
  32. 115 0
      quac/quac.c
  33. 67 0
      quac/quac.h
  34. 179 0
      quac/quac_settings.c
  35. 14 0
      quac/quac_settings.h
  36. 6 0
      quac/scenes/.gitignore
  37. 41 0
      quac/scenes/scene_about.c
  38. 8 0
      quac/scenes/scene_about.h
  39. 91 0
      quac/scenes/scene_action_create_group.c
  40. 8 0
      quac/scenes/scene_action_create_group.h
  41. 115 0
      quac/scenes/scene_action_ir_list.c
  42. 8 0
      quac/scenes/scene_action_ir_list.h
  43. 104 0
      quac/scenes/scene_action_rename.c
  44. 8 0
      quac/scenes/scene_action_rename.h
  45. 307 0
      quac/scenes/scene_action_settings.c
  46. 8 0
      quac/scenes/scene_action_settings.h
  47. 183 0
      quac/scenes/scene_items.c
  48. 9 0
      quac/scenes/scene_items.h
  49. 221 0
      quac/scenes/scene_settings.c
  50. 10 0
      quac/scenes/scene_settings.h
  51. 55 0
      quac/scenes/scenes.c
  52. 34 0
      quac/scenes/scenes.h
  53. BIN
      quac/screenshots/screenshot_1.png
  54. BIN
      quac/screenshots/screenshot_1_orig.png
  55. BIN
      quac/screenshots/screenshot_2.png
  56. BIN
      quac/screenshots/screenshot_2_orig.png
  57. BIN
      quac/screenshots/screenshot_3.png
  58. BIN
      quac/screenshots/screenshot_3_orig.png
  59. BIN
      quac/screenshots/screenshot_4.png
  60. BIN
      quac/screenshots/screenshot_4_90.png
  61. BIN
      quac/screenshots/screenshot_4_orig.png
  62. 528 0
      quac/views/action_menu.c
  63. 120 0
      quac/views/action_menu.h

+ 41 - 0
quac/.github/workflows/build.yml

@@ -0,0 +1,41 @@
+name: "FAP: Build for multiple SDK sources"
+# This will build your app for dev and release channels on GitHub. 
+# It will also build your app every day to make sure it's up to date with the latest SDK changes.
+# See https://github.com/marketplace/actions/build-flipper-application-package-fap for more information
+
+on:
+  push:
+    ## put your main branch name under "branches"
+    #branches: 
+    #  - master 
+  pull_request:
+  schedule: 
+    # do a build every day
+    - cron: "1 1 * * *"
+
+jobs:
+  ufbt-build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        include:
+          - name: dev channel
+            sdk-channel: dev
+          - name: release channel
+            sdk-channel: release
+          # You can add unofficial channels here. See ufbt action docs for more info.
+    name: 'ufbt: Build for ${{ matrix.name }}'
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Build with ufbt
+        uses: flipperdevices/flipperzero-ufbt-action@v0.1
+        id: build-app
+        with:
+          sdk-channel: ${{ matrix.sdk-channel }}
+      - name: Upload app artifacts
+        uses: actions/upload-artifact@v3
+        with:
+          # See ufbt action docs for other output variables
+          name: ${{ github.event.repository.name }}-${{ steps.build-app.outputs.suffix }}
+          path: ${{ steps.build-app.outputs.fap-artifacts }}

+ 6 - 0
quac/.gitignore

@@ -0,0 +1,6 @@
+dist/*
+.vscode
+.clang-format
+.editorconfig
+.env
+.ufbt

+ 1 - 0
quac/.gitsubtree

@@ -0,0 +1 @@
+https://github.com/xMasterX/all-the-plugins dev non_catalog_apps/quac

+ 35 - 0
quac/CHANGELOG.md

@@ -0,0 +1,35 @@
+## 0.6
+
+- Basic NFC support
+- Improved IR file import, prompts for specific IR command
+- NFC default duration in Settings
+
+## 0.5
+
+- Basic infrared support
+- Action settings: Rename, Delete, Import, Create Group
+- Support for Sub-GHz external antenna in Settings
+- About screen
+
+## 0.4
+
+- New UI with horizontal/vertical layout support
+- Added icons, better header font
+- Settings menu to control UI
+- RFID duration suppport added to playlists
+
+## 0.3
+
+- Updated to firmware SDK 0.99.1
+
+## 0.2
+
+- Playlist support
+- Hidden file/folder support
+
+## 0.1
+
+- First release!
+- Supports Sub-GHz and RFID files
+- Items can be sorted based on filename prefix
+- Item labels are pretty-printed

+ 132 - 0
quac/README.md

@@ -0,0 +1,132 @@
+# Quac! Remote
+
+## Quick Action Remote Control for Flipperzero
+
+[Latest Version: 0.6](https://github.com/rdefeo/quac/releases)
+
+![build status badge](https://github.com/rdefeo/quac/actions/workflows/build.yml/badge.svg)
+
+This app allows you to organize previously recorded signals, of any type*, so that you can quickly and easily play them back. No more needing to recall whether that door is Sub-GHz or RFID! Just navigate to that action in **Quac!** and press OK to send!
+
+The app does not provide any recording functionality - you must use the existing Flipperzero apps to create the saved files for your action/device. Quac! provides some basic functionality to manage your files. Or, you can manage the folder structure manually on your SD card.
+
+> Support files include: Sub-Ghz (.sub), RFID (.rfid), Infrared (.ir), and NFC (.nfc)
+
+## Features
+
+* [Playback of rfid, sub-ghz, IR, NFC signals](README.md#signal-playback)
+* [Easy navigation](README.md#navigation--controls)
+* [Flexible signal organization](README.md#signal-organization) - utilizing the SDcard filesystem
+* [In-app file management](README.md#action-settings) - rename, delete, import
+* [Playlist support](README.md#playlists)
+* [Flexible naming/sorting, hidden file/folder support](README.md#sorting-and-naming)
+* [Customizable UI](README.md#application-settings)
+
+## Screenshots
+
+![top-level view](screenshots/screenshot_1.png)
+![group view](screenshots/screenshot_3.png)
+![vertical layout](screenshots/screenshot_4_90.png)
+
+## Navigation / Controls
+
+* Pressing `OK` on a folder label will open/navigate to that folder and display it's contents
+* Pressing `OK` on a signal will transmit that signal
+* Pressing `Back` will take you up one folder
+* Pressing `Up` and `Down` will, you know, select things up and down...
+* Long pressing `Right` will open that item's settings: Rename, Delete, Import Here, Create Group
+
+## Signal playback
+
+The signal files are played back as recorded. During playback/transmit, the LED light will flash blue until the action is complete. For RFID signals, they are continuously played back for 2.5 seconds, by default. This can be changed in [application Settings](README.md#application-settings).
+
+## Signal Organization
+
+The key to organizing your Quac! interface is to organize your `/ext/apps_data/quac` folder structure. The UI is derived directly from the filesystem structure. Every individual file/signal is given a label on screen. And every folder/directory is a logical group of more files/folders. Selecting a group in the UI will show you the contents of that folder. There is no limit on the number of actions or folders - nest as deep as you want!
+
+You can organize your files by device type, or by function. For example, you may have a folder of "TV" actions, which correspond to Channel Up, Channel Down, Volume Up, Volume Down, etc. Or you may have a "Work Access" folder, which contains files/actions that correspond to Parking Gate, Garage Door, Lobby Entrance.
+
+The files in a folder can be of mixed types. **This is Quac!'s main strength!** So continuing with the "Work Access" example, the Parking Gate can be Sub-Ghz and the Garage Door can be RFID.
+
+Here's an example file layout for the screenshots above:
+
+```text
+/ext/apps_data/quac
+  /00_Work
+    00_ParkingGate.sub
+    01_Garage_door.rfid
+    02_Office.rfid
+  01_Front_Door.rfid
+  /02_Jam_Lists
+     00_Gate_Spam.qpl
+     01_TVs_OFF.qpl
+     02_my_Playlist.qpl
+```
+
+## Action Settings
+
+Long pressing the `Right` button will launch a settings menu for the currently selected action. This provides the following options:
+
+* Rename: Allows you to rename the selected item. Useful for changing sorting order. The file extension is preserved on signal files. **Note: folder renaming is broken right now**
+* Delete: Deletes files and folders - folders must be empty
+* Import Here: Launches file browser to let you select a signal file from anywhere on the SDcard and then copies it to the current folder.
+  * When importing an IR file, you are prompted to select which IR command to import. This individual command is imported as it's own `.ir` file into the current location.
+* Create Group: Prompts for the name of a new folder that will be created at that point in the folder structure.
+
+## Playlists
+
+You can chain multiple signal playback actions together by creating a playlist. Simply create a text file, with extension `.qpl`, which contains a list of paths to the signals you wish to transmit - they will be played sequentially. Playlist names show up as clickable action, like all other individual signals/actions. Playlist files feature the following:
+
+* Comments: lines that start with a `#` are ignored
+* `pause <ms>` on a line will pause the playback by the specified millisecond duration
+* Signal file names can be absolute (full path) or relative to the current directory
+* RFID files can have an optional duration specified. Simply add a space, followed by a millisecond duration. This duration will override the Quac! Settings value, just for this one signal.
+
+Errors found in the playlist will halt playback and vibrate the Flipper. Blank lines are ignored.
+
+Here's an example playlist called `/ext/apps_data/quac/arrive_home.qpl`:
+
+```text
+# Home arrival playlist
+
+.exterior_light_on.sub
+.side_door_unlock.sub
+
+pause 2500
+
+/ext/apps_data/quac/03_Stereo/00_On.sub
+/ext/apps_data/quac/06_Lights/Disco_Ball.sub
+
+Lava_Lamp.rfid 4000
+```
+
+The first two `.sub` files live in the `/ext/apps_data/quac` folder, which is where `arrive_home.qpl` is located, and will not show up in any UI screen since they are hidden (start with a `.`). Next, we pause the playlist for 2.5 sec. The next two files live elsewhere, but can still be referenced by the playlist. Lastly, the RFID signal is transmitted for 4000ms, instead of the duration listed in Quac! Settings.
+
+## Sorting and Naming
+
+The list view UI is based on the sorted file and folder order. This is enforced by sorting the actual filenames. When there are cases where you need to force a specific order, you can prepend the file and folder names with `XX_` where `X` is a digit between 0-9. This will let you place an action called `On` before `Off`, even though when sorted alphabeticaly, `Off` would come before `On`. Therefore, you would name your files `00_On.rfid` and `01_Off.rfid`. But that looks ugly! When the files and folders are rendered for display, any `XX_` prefix will be stripped. All underscores will be replaced with spaces. Extensions will be stripped. Casing is preserved. Additionally, all files and folders that begin with a `.` will be ignored when drawing the UI - these are "hidden" files. However, they can still be referenced in playlists. This keeps the UI uncluttered.
+
+## Application Settings
+
+![Settings menu](screenshots/screenshot_2.png)
+
+The settings menu will appear as the last item when you are viewing the "root" directory. Within the settings you can control:
+
+* Layout: Switch between Horizontal and Vertical layout
+* Show Icons: Toggles display of all icons
+* Show Headers: Toggles display of header/folder text at the top, giving you room for one more item on screen!
+* RFID Duration: Changes the length of time a RFID signal is transmitted. Within playlists, this can be overridden per `.rfid` file.
+* NFC Duration: Changes the length of time a NFC signal is transmitted. Within playlists, this can be overridden per `.nfc` file.
+* SubGhz Ext Ant: Whether to try using the external antenna for sub-ghz signals. If this is "Enabled" but no external antenna is attached, or the external antenna can't be accessed, Quac! will fall back to using the internal antenna.
+* Show Hidden: Will display files and folders that start with a period (`.`)
+* About: Application info
+
+## On deck
+
+* suggestions??
+
+## Building / Install
+
+This app is currently built with `ufbt`, intended for the stock firmware. I have not tested this on other firmwares. The `.fap` file can be found in the Releases section on the right.
+
+<a href="https://www.buymeacoffee.com/rdefeo" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>

+ 48 - 0
quac/README_flipperlab.md

@@ -0,0 +1,48 @@
+# Quac! Remote
+
+## QUick ACtion Remote Control for Flipperzero
+
+This app allows you to organize previously recorded signals, of any type, so that you can quickly and easily play them back. No more needing to recall whether that door is Sub-GHz or RFID! Just navigate to that action in **Quac** and press OK to send!
+
+The app does not provide any recording functionality - you can use the existing Flipperzero apps to create the saved files or import from existing files. Quac! provides some basic functionality to manage your files. Or, you can manage the folder structure manually on your SD card on your PC.
+
+* Supported files include: Sub-Ghz (.sub), RFID (.rfid), Infrared (.ir), and NFC (.nfc)
+
+## Features
+
+* Playback of rfid, sub-ghz, IR, and NFC signals
+* Easy navigation
+* Flexible signal organization
+* In-app file management
+* Playlist support
+* Flexible naming/sorting, hidden file/folder support
+* Customizable UI
+
+## Signal playback
+
+The signal files are played back as recorded. During playback/transmit, the LED light will flash blue until the action is complete. For RFID and NFC signals, they are continuously played back for the durations specified in the Settings.
+
+## Signal Organization
+
+The key to organizing your Quac interface is to organize your **/ext/apps_data/quac** folder structure. The UI is derived directly from the filesystem structure. Every individual file/signal is assigned a button. This is an "action". And every folder/directory is a logical group of more files/folders. Selecting a group in the UI will show you the contents of that folder. There is no limit on the number of actions or folders - nest as deep as you want!
+
+You can organize your files by device type, or by function. For example, you may have a folder of "TV" actions, which correspond to Channel Up, Channel Down, Volume Up, Volume Down, etc. Or you may have a "Work Access" folder, which contains files/actions that correspond to Parking Gate, Garage Door, Lobby Entrance.
+
+The files in a folder can be of mixed types. **This is Quac's main strength!** So continuing with the "Work Access" example, the Parking Gate can be Sub-Ghz and the Garage Door can be RFID.
+
+## Playlists
+
+You can chain multiple signal playback actions together by creating a playlist. Simply create a text file which contains a list of paths to the signals you wish to transmit - they will be played sequentially. Playlist names show up as clickable button, like all other individual signals/actions.
+
+Errors found in the playlist will halt playback and vibrate the Flipperzero. Blank lines are ignored.
+
+## Settings
+
+The settings menu will appear as the last item when you are viewing the "root" directory. Within the settings you can control:
+
+* Layout: Switch between Horizontal and Vertical layout
+* Show Icons: Toggles display of all icons
+* Show Headers: Toggles display of header/folder text at the top, giving you room for one more item on screen!
+* RFID Duration: Changes the length of time a RFID signal is transmitted. Can be overridden, per RFID file in a Playlist
+
+**More information can be found in the Git repository**

+ 22 - 0
quac/actions/action.c

@@ -0,0 +1,22 @@
+
+#include "quac.h"
+#include "item.h"
+#include "action_i.h"
+
+void action_tx(void* context, Item* item, FuriString* error) {
+    // FURI_LOG_I(TAG, "action_run: %s : %s", furi_string_get_cstr(item->name), item->ext);
+
+    if(!strcmp(item->ext, ".sub")) {
+        action_subghz_tx(context, item->path, error);
+    } else if(!strcmp(item->ext, ".ir")) {
+        action_ir_tx(context, item->path, error);
+    } else if(!strcmp(item->ext, ".rfid")) {
+        action_rfid_tx(context, item->path, error);
+    } else if(!strcmp(item->ext, ".nfc")) {
+        action_nfc_tx(context, item->path, error);
+    } else if(!strcmp(item->ext, ".qpl")) {
+        action_qpl_tx(context, item->path, error);
+    } else {
+        FURI_LOG_E(TAG, "Unknown item file type! %s", item->ext);
+    }
+}

+ 13 - 0
quac/actions/action.h

@@ -0,0 +1,13 @@
+#pragma once
+
+#define EMPTY_ACTION_INDEX -1
+
+struct Item;
+
+/** Transmits the selected item
+ * 
+ * @param   context     The App
+ * @param   item        Selected item to transmit
+ * @param   error       Error message if unsuccessful
+*/
+void action_tx(void* context, Item* item, FuriString* error);

+ 12 - 0
quac/actions/action_i.h

@@ -0,0 +1,12 @@
+#pragma once
+
+#include <furi.h>
+#include <furi_hal.h>
+
+#define ACTION_SET_ERROR(_msg_fmt, ...) furi_string_printf(error, _msg_fmt, ##__VA_ARGS__)
+
+void action_subghz_tx(void* context, const FuriString* action_path, FuriString* error);
+void action_rfid_tx(void* context, const FuriString* action_path, FuriString* error);
+void action_ir_tx(void* context, const FuriString* action_path, FuriString* error);
+void action_nfc_tx(void* context, const FuriString* action_path, FuriString* error);
+void action_qpl_tx(void* context, const FuriString* action_path, FuriString* error);

+ 70 - 0
quac/actions/action_ir.c

@@ -0,0 +1,70 @@
+// Methods for IR transmission
+
+#include "quac.h"
+#include "action_i.h"
+#include "action_ir_utils.h"
+
+void action_ir_tx(void* context, const FuriString* action_path, FuriString* error) {
+    UNUSED(error);
+    App* app = context;
+    const char* file_name = furi_string_get_cstr(action_path);
+    InfraredSignal* signal = infrared_utils_signal_alloc();
+
+    FlipperFormat* fff_data_file = flipper_format_file_alloc(app->storage);
+    FuriString* temp_str;
+    temp_str = furi_string_alloc();
+    // uint32_t temp_data32;
+
+    // https://developer.flipper.net/flipperzero/doxygen/infrared_file_format.html
+    // TODO: Right now we only read the first signal found in the file. Add support
+    // for reading any signal by 'name'?
+    do {
+        if(!flipper_format_file_open_existing(fff_data_file, file_name)) {
+            ACTION_SET_ERROR("IR: Error opening %s", file_name);
+            break;
+        }
+        uint32_t index = 0;
+        if(!infrared_utils_read_signal_at_index(fff_data_file, index, signal, temp_str)) {
+            ACTION_SET_ERROR("IR: Failed to read from file");
+            break;
+        }
+
+        if(signal->is_raw) {
+            // raw
+            FURI_LOG_I(
+                TAG,
+                "IR: Sending (%s) type=raw => %d timings, %lu Hz, %f",
+                file_name,
+                signal->payload.raw.timings_size,
+                signal->payload.raw.frequency,
+                (double)signal->payload.raw.duty_cycle);
+
+            infrared_send_raw_ext(
+                signal->payload.raw.timings,
+                signal->payload.raw.timings_size,
+                true,
+                signal->payload.raw.frequency,
+                signal->payload.raw.duty_cycle);
+
+            FURI_LOG_I(TAG, "IR: Send complete");
+        } else {
+            //parsed
+            FURI_LOG_I(
+                TAG,
+                "IR: Sending (%s) type=parsed => %s %lu %lu",
+                file_name,
+                infrared_get_protocol_name(signal->payload.message.protocol),
+                signal->payload.message.address,
+                signal->payload.message.command);
+
+            infrared_send(&signal->payload.message, 1);
+
+            FURI_LOG_I(TAG, "IR: Send complete");
+        }
+
+    } while(false);
+
+    furi_string_free(temp_str);
+    flipper_format_free(fff_data_file);
+    infrared_utils_signal_free(signal);
+}

+ 205 - 0
quac/actions/action_ir_utils.c

@@ -0,0 +1,205 @@
+// Utility methods for IR transmission
+#include <furi.h>
+#include "../quac.h"
+
+#include <flipper_format/flipper_format.h>
+
+#include "action_ir_utils.h"
+
+InfraredSignal* infrared_utils_signal_alloc() {
+    InfraredSignal* signal = malloc(sizeof(InfraredSignal));
+    signal->is_raw = false;
+    signal->payload.message.protocol = InfraredProtocolUnknown;
+    return signal;
+}
+
+void infrared_utils_signal_free(InfraredSignal* signal) {
+    if(signal->is_raw) {
+        free(signal->payload.raw.timings);
+        signal->payload.raw.timings = NULL;
+    }
+    free(signal);
+}
+
+bool infrared_utils_read_signal_at_index(
+    FlipperFormat* fff_data_file,
+    uint32_t index,
+    InfraredSignal* signal,
+    FuriString* name) {
+    //
+    FuriString* temp_str;
+    temp_str = furi_string_alloc();
+    uint32_t temp_data32;
+    bool success = false;
+
+    do {
+        if(!flipper_format_read_header(fff_data_file, temp_str, &temp_data32)) {
+            FURI_LOG_E(TAG, "IR: Missing or incorrect header");
+            break;
+        }
+
+        if(!furi_string_cmp_str(temp_str, INFRARED_FILE_TYPE) &&
+           temp_data32 == INFRARED_FILE_VERSION) {
+        } else {
+            // ACTION_SET_ERROR("IR: File type or version mismatch");
+            FURI_LOG_E(TAG, "IR: File type or version mismatch");
+            break;
+        }
+
+        // Read the file until we find the signal we want
+        uint32_t i = 0;
+        bool found = false;
+        while(flipper_format_read_string(fff_data_file, "name", name)) {
+            if(i == index) {
+                found = true;
+                break;
+            }
+            ++i;
+        }
+        if(!found) {
+            // ACTION_SET_ERROR("IR: Could not find command %lu!", index);
+            FURI_LOG_E(TAG, "Requested IR command %lu not found", index);
+            furi_string_reset(name);
+            break;
+        }
+
+        FURI_LOG_I(TAG, "Reading signal %s", furi_string_get_cstr(temp_str));
+        if(!flipper_format_read_string(fff_data_file, "type", temp_str)) {
+            // ACTION_SET_ERROR("IR: Type missing");
+            break;
+        }
+        if(furi_string_equal(temp_str, "parsed")) {
+            signal->is_raw = false;
+
+            if(!flipper_format_read_string(fff_data_file, "protocol", temp_str)) {
+                // ACTION_SET_ERROR("IR: Invalid or missing protocol");
+                break;
+            }
+            signal->payload.message.protocol =
+                infrared_get_protocol_by_name(furi_string_get_cstr(temp_str));
+            if(!infrared_is_protocol_valid(signal->payload.message.protocol)) {
+                // ACTION_SET_ERROR("IR: Invalid or unknown protocol");
+                break;
+            }
+
+            if(!flipper_format_read_hex(
+                   fff_data_file, "address", (uint8_t*)&signal->payload.message.address, 4)) {
+                // ACTION_SET_ERROR("IR: Failed to read address");
+                break;
+            }
+            if(!flipper_format_read_hex(
+                   fff_data_file, "command", (uint8_t*)&signal->payload.message.command, 4)) {
+                // ACTION_SET_ERROR("IR: Failed to read command");
+                break;
+            }
+            success = true;
+        } else if(furi_string_equal(temp_str, "raw")) {
+            signal->is_raw = true;
+
+            if(!flipper_format_read_uint32(
+                   fff_data_file, "frequency", &signal->payload.raw.frequency, 1)) {
+                // ACTION_SET_ERROR("IR: Failed to read frequency");
+                break;
+            }
+            if(!flipper_format_read_float(
+                   fff_data_file, "duty_cycle", &signal->payload.raw.duty_cycle, 1)) {
+                // ACTION_SET_ERROR("IR: Failed to read duty cycle");
+                break;
+            }
+            if(!flipper_format_get_value_count(fff_data_file, "data", &temp_data32)) {
+                // ACTION_SET_ERROR("IR: Failed to get size of data");
+                break;
+            }
+            if(temp_data32 > MAX_TIMINGS_AMOUNT) {
+                // ACTION_SET_ERROR("IR: Data size exceeds limit");
+                break;
+            }
+            signal->payload.raw.timings_size = temp_data32;
+
+            signal->payload.raw.timings =
+                malloc(sizeof(uint32_t) * signal->payload.raw.timings_size);
+            if(!flipper_format_read_uint32(
+                   fff_data_file, "data", signal->payload.raw.timings, temp_data32)) {
+                // ACTION_SET_ERROR("IR: Failed to read data");
+                free(signal->payload.raw.timings);
+                break;
+            }
+            success = true;
+        }
+    } while(false);
+
+    return success;
+}
+
+bool infrared_utils_write_signal(
+    FlipperFormat* fff_data_file,
+    InfraredSignal* signal,
+    FuriString* name) {
+    //
+    bool success = false;
+
+    do {
+        if(!flipper_format_write_header_cstr(
+               fff_data_file, INFRARED_FILE_TYPE, INFRARED_FILE_VERSION)) {
+            FURI_LOG_E(TAG, "Error writing header");
+            break;
+        }
+        if(!flipper_format_write_comment_cstr(fff_data_file, "")) {
+            FURI_LOG_E(TAG, "Error writing blank comment");
+            break;
+        }
+        if(!flipper_format_write_string(fff_data_file, "name", name)) {
+            FURI_LOG_E(TAG, "Error writing name");
+            break;
+        }
+        if(!flipper_format_write_string_cstr(
+               fff_data_file, "type", signal->is_raw ? "raw" : "parsed")) {
+            FURI_LOG_E(TAG, "Error writing type");
+            break;
+        }
+        if(signal->is_raw) {
+            // raw
+            if(!flipper_format_write_uint32(
+                   fff_data_file, "frequency", &signal->payload.raw.frequency, 1)) {
+                FURI_LOG_E(TAG, "Error writing frequency");
+                break;
+            }
+            if(!flipper_format_write_float(
+                   fff_data_file, "duty_cycle", &signal->payload.raw.duty_cycle, 1)) {
+                FURI_LOG_E(TAG, "Error writing duty_cycle");
+                break;
+            }
+            if(!flipper_format_write_uint32(
+                   fff_data_file,
+                   "data",
+                   signal->payload.raw.timings,
+                   signal->payload.raw.timings_size)) {
+                FURI_LOG_E(TAG, "Error writing data");
+                break;
+            }
+            success = true;
+        } else {
+            // parsed
+            if(!flipper_format_write_string_cstr(
+                   fff_data_file,
+                   "protocol",
+                   infrared_get_protocol_name(signal->payload.message.protocol))) {
+                FURI_LOG_E(TAG, "Error writing protocol");
+                break;
+            }
+            if(!flipper_format_write_hex(
+                   fff_data_file, "address", (uint8_t*)&signal->payload.message.address, 4)) {
+                FURI_LOG_E(TAG, "Error writing address");
+                break;
+            }
+            if(!flipper_format_write_hex(
+                   fff_data_file, "command", (uint8_t*)&signal->payload.message.command, 4)) {
+                FURI_LOG_E(TAG, "Error writing command");
+                break;
+            }
+            success = true;
+        }
+    } while(false);
+
+    return success;
+}

+ 38 - 0
quac/actions/action_ir_utils.h

@@ -0,0 +1,38 @@
+#include <furi.h>
+// infrared
+#include <infrared.h>
+#include <infrared/encoder_decoder/infrared.h>
+#include <infrared/worker/infrared_transmit.h>
+#include <infrared/worker/infrared_worker.h>
+
+#include <flipper_format/flipper_format.h>
+
+#define INFRARED_FILE_TYPE "IR signals file"
+#define INFRARED_FILE_VERSION 1
+
+typedef struct {
+    size_t timings_size; /**< Number of elements in the timings array. */
+    uint32_t* timings; /**< Pointer to an array of timings describing the signal. */
+    uint32_t frequency; /**< Carrier frequency of the signal. */
+    float duty_cycle; /**< Duty cycle of the signal. */
+} InfraredRawSignal;
+
+typedef struct InfraredSignal {
+    bool is_raw;
+    union {
+        InfraredMessage message; // protocol, address, command, repeat
+        InfraredRawSignal raw;
+    } payload;
+} InfraredSignal;
+
+InfraredSignal* infrared_utils_signal_alloc();
+
+void infrared_utils_signal_free(InfraredSignal* signal);
+
+bool infrared_utils_read_signal_at_index(
+    FlipperFormat* fffile,
+    uint32_t index,
+    InfraredSignal* signal,
+    FuriString* name);
+
+bool infrared_utils_write_signal(FlipperFormat* fffile, InfraredSignal* signal, FuriString* name);

+ 45 - 0
quac/actions/action_nfc.c

@@ -0,0 +1,45 @@
+// Methods for NFC transmission
+
+// nfc
+#include <furi.h>
+#include <furi_hal.h>
+#include <nfc/nfc.h>
+#include <nfc/nfc_device.h>
+#include <nfc/nfc_listener.h>
+
+#include "action_i.h"
+#include "quac.h"
+
+void action_nfc_tx(void* context, const FuriString* action_path, FuriString* error) {
+    App* app = context;
+
+    FURI_LOG_I(TAG, "NFC: Tx %s", furi_string_get_cstr(action_path));
+    Nfc* nfc = nfc_alloc();
+    NfcDevice* device = nfc_device_alloc();
+
+    if(nfc_device_load(device, furi_string_get_cstr(action_path))) {
+        NfcProtocol protocol = nfc_device_get_protocol(device);
+        FURI_LOG_I(TAG, "NFC: Protocol %s", nfc_device_get_protocol_name(protocol));
+        NfcListener* listener =
+            nfc_listener_alloc(nfc, protocol, nfc_device_get_data(device, protocol));
+        FURI_LOG_I(TAG, "NFC: Starting...");
+        nfc_listener_start(listener, NULL, NULL);
+
+        int16_t time_ms = app->settings.nfc_duration;
+        const int16_t interval_ms = 100;
+        while(time_ms > 0) {
+            furi_delay_ms(interval_ms);
+            time_ms -= interval_ms;
+        }
+
+        FURI_LOG_I(TAG, "NFC: Done");
+        nfc_listener_stop(listener);
+        nfc_listener_free(listener);
+    } else {
+        FURI_LOG_E(TAG, "NFC: Failed to load %s", furi_string_get_cstr(action_path));
+        ACTION_SET_ERROR("Failed to load %s", furi_string_get_cstr(action_path));
+    }
+    nfc_device_clear(device); // probably not needed?
+    nfc_free(nfc);
+    nfc_device_free(device);
+}

+ 173 - 0
quac/actions/action_qpl.c

@@ -0,0 +1,173 @@
+// Methods for Quac Playlist transmission
+
+#include <toolbox/stream/stream.h>
+#include <toolbox/stream/file_stream.h>
+#include <toolbox/path.h>
+#include <toolbox/args.h>
+
+#include <notification/notification_messages.h>
+
+#include "action_i.h"
+#include "quac.h"
+
+/** Open the Playlist file and then transmit each action
+ * 
+ * Each line of the playlist file is one of:
+ *   <file_path>
+ *      Full SD card path, or relative path to action to be transmitted. Must be
+ *      one of the supported filetypes (.sub, .rfid, [.ir coming soon])
+ * 
+ *      If an .rfid file has a space followed by a number, that will be the
+ *      duration for that RFID transmission. All other .rfid files will use
+ *      the value specified in the Settings
+ * 
+ *   pause <ms>
+ *      Pauses the playback for 'ms' milliseconds.
+ * 
+ * Blank lines, and comments (start with '#') are ignored. Whitespace is trimmed.
+ * 
+*/
+void action_qpl_tx(void* context, const FuriString* action_path, FuriString* error) {
+    App* app = context;
+
+    // Save the current RFID and NFC Durations, in case the are changed during playback
+    uint32_t orig_rfid_duration = app->settings.rfid_duration;
+    uint32_t orig_nfc_duration = app->settings.nfc_duration;
+
+    FuriString* buffer;
+    buffer = furi_string_alloc();
+
+    Stream* file = file_stream_alloc(app->storage);
+    if(file_stream_open(file, furi_string_get_cstr(action_path), FSAM_READ, FSOM_OPEN_EXISTING)) {
+        while(stream_read_line(file, buffer)) {
+            furi_string_trim(buffer); // remove '\n\r' line endings, cleanup spaces
+            // FURI_LOG_I(TAG, "line: %s", furi_string_get_cstr(buffer));
+
+            // Skip blank lines
+            if(furi_string_size(buffer) == 0) {
+                continue;
+            }
+
+            // Skip comments
+            char first_char = furi_string_get_char(buffer, 0);
+            if(first_char == '#') {
+                continue;
+            }
+
+            // Check if buffer is a "command", and not just a filename
+            // Commands will contain spaces
+            bool processed_special_command = false;
+            FuriString* args_tmp;
+            args_tmp = furi_string_alloc();
+            do {
+                if(!args_read_string_and_trim(buffer, args_tmp)) {
+                    // No spaces found, buffer and args_tmp are now have same contents
+                    break;
+                }
+
+                // FURI_LOG_I(
+                //     TAG,
+                //     "args_temp: '%s', buffer: '%s'",
+                //     furi_string_get_cstr(args_tmp),
+                //     furi_string_get_cstr(buffer));
+
+                // OK, there's a space, and args_tmp is the first token, buffer is the rest
+                if(furi_string_cmpi_str(args_tmp, "pause") == 0) {
+                    processed_special_command = true;
+                    uint32_t pause_length = 0;
+                    if(sscanf(furi_string_get_cstr(buffer), "%lu", &pause_length) == 1) {
+                        FURI_LOG_I(TAG, "Pausing playlist for %lu ms", pause_length);
+                        furi_delay_ms(pause_length);
+                    } else {
+                        ACTION_SET_ERROR("Playlist: Invalid or missing pause time");
+                    }
+                    break;
+                }
+
+                // First token wasn't "pause", so maybe args_tmp is a .rfid filename followed
+                // by a transmit duration in ms in buffer
+                // Note: Not using path_extract_extension since it expects to find slashes in the
+                // path, and thus won't work if we have a relative path file
+                char ext[MAX_EXT_LEN + 1] = "";
+                size_t dot = furi_string_search_rchar(args_tmp, '.');
+                if(dot != FURI_STRING_FAILURE && furi_string_size(args_tmp) - dot <= MAX_EXT_LEN) {
+                    strlcpy(ext, &(furi_string_get_cstr(args_tmp))[dot], MAX_EXT_LEN);
+                }
+
+                // FURI_LOG_I(TAG, " - Found extension of %s", ext);
+
+                if(!strcmp(ext, ".rfid")) {
+                    uint32_t rfid_duration = 0;
+                    // FURI_LOG_I(TAG, "RFID file with duration");
+                    if(sscanf(furi_string_get_cstr(buffer), "%lu", &rfid_duration) == 1) {
+                        FURI_LOG_I(TAG, "RFID duration = %lu", rfid_duration);
+                        app->settings.rfid_duration = rfid_duration;
+                    }
+                } else if(!strcmp(ext, ".nfc")) {
+                    uint32_t nfc_duration = 0;
+                    if(sscanf(furi_string_get_cstr(buffer), "%lu", &nfc_duration) == 1) {
+                        FURI_LOG_I(TAG, "NFC duration = %lu", nfc_duration);
+                        app->settings.nfc_duration = nfc_duration;
+                    }
+                }
+
+            } while(false);
+
+            furi_string_swap(buffer, args_tmp);
+            furi_string_free(args_tmp);
+
+            if(processed_special_command) {
+                continue;
+            }
+
+            first_char = furi_string_get_char(buffer, 0);
+            // Using relative paths? Prepend path of our playlist file
+            if(first_char != '/') {
+                FuriString* dirname;
+                dirname = furi_string_alloc();
+                path_extract_dirname(furi_string_get_cstr(action_path), dirname);
+                furi_string_cat_printf(dirname, "/%s", furi_string_get_cstr(buffer));
+                furi_string_swap(dirname, buffer);
+                furi_string_free(dirname);
+            }
+
+            char ext[MAX_EXT_LEN + 1] = "";
+            path_extract_extension(buffer, ext, MAX_EXT_LEN);
+            if(!strcmp(ext, ".sub")) {
+                action_subghz_tx(context, buffer, error);
+            } else if(!strcmp(ext, ".rfid")) {
+                action_rfid_tx(context, buffer, error);
+                // Reset our default duration back - in case it was changed during playback
+                app->settings.rfid_duration = orig_rfid_duration;
+            } else if(!strcmp(ext, ".ir")) {
+                action_ir_tx(context, buffer, error);
+            } else if(!strcmp(ext, ".nfc")) {
+                action_nfc_tx(context, buffer, error);
+                // Reset our default duration back - in case it was changed during playback
+                app->settings.nfc_duration = orig_nfc_duration;
+            } else if(!strcmp(ext, ".qpl")) {
+                ACTION_SET_ERROR("Playlist: Can't call playlist from playlist");
+            } else {
+                ACTION_SET_ERROR(
+                    "Playlist: Unknown file/command! %s", furi_string_get_cstr(buffer));
+            }
+
+            if(furi_string_size(error)) {
+                // Abort playing the playlist - one of our actions failed
+                break;
+            }
+
+            // Playlist action complete!
+            // TODO: Do we need a small delay (say 25ms) between actions?
+            // TODO: Should we blip the light a diff color to indicate that
+            //       we're done with one command and moving to the next?
+            // furi_delay_ms(25);
+        }
+    } else {
+        ACTION_SET_ERROR("Could not open playlist");
+    }
+
+    furi_string_free(buffer);
+    file_stream_close(file);
+    stream_free(file);
+}

+ 111 - 0
quac/actions/action_rfid.c

@@ -0,0 +1,111 @@
+// Methods for RFID transmission
+
+// lfrid
+#include <lib/lfrfid/lfrfid_worker.h>
+#include <toolbox/protocols/protocol_dict.h>
+#include <lfrfid/protocols/lfrfid_protocols.h>
+#include <lfrfid/lfrfid_raw_file.h>
+#include <lib/toolbox/args.h>
+
+#include <flipper_format/flipper_format.h>
+
+#include "action_i.h"
+#include "quac.h"
+
+#define RFID_FILE_TYPE "Flipper RFID key"
+#define RFID_FILE_VERSION 1
+
+// lifted from flipperzero-firmware/applications/main/lfrfid/lfrfid_cli.c
+void action_rfid_tx(void* context, const FuriString* action_path, FuriString* error) {
+    UNUSED(error);
+
+    App* app = context;
+    const char* file_name = furi_string_get_cstr(action_path);
+
+    FlipperFormat* fff_data_file = flipper_format_file_alloc(app->storage);
+    FuriString* temp_str;
+    temp_str = furi_string_alloc();
+    uint32_t temp_data32;
+
+    ProtocolDict* dict = protocol_dict_alloc(lfrfid_protocols, LFRFIDProtocolMax);
+    ProtocolId protocol;
+    size_t data_size = protocol_dict_get_max_data_size(dict);
+    uint8_t* data = malloc(data_size);
+
+    // FURI_LOG_I(TAG, "Max dict data size is %d", data_size);
+    bool successful_read = false;
+    do {
+        if(!flipper_format_file_open_existing(fff_data_file, file_name)) {
+            ACTION_SET_ERROR("RFID: Error opening %s", file_name);
+            break;
+        }
+        if(!flipper_format_read_header(fff_data_file, temp_str, &temp_data32)) {
+            ACTION_SET_ERROR("RFID: Missing or incorrect header");
+            break;
+        }
+        if(!strcmp(furi_string_get_cstr(temp_str), RFID_FILE_TYPE) &&
+           temp_data32 == RFID_FILE_VERSION) {
+        } else {
+            ACTION_SET_ERROR("RFID: Type or version mismatch");
+            break;
+        }
+        // read and check the protocol field
+        if(!flipper_format_read_string(fff_data_file, "Key type", temp_str)) {
+            ACTION_SET_ERROR("RFID: Error reading protocol");
+            break;
+        }
+        protocol = protocol_dict_get_protocol_by_name(dict, furi_string_get_cstr(temp_str));
+        if(protocol == PROTOCOL_NO) {
+            ACTION_SET_ERROR("RFID: Unknown protocol: %s", furi_string_get_cstr(temp_str));
+            break;
+        }
+
+        // read and check data field
+        size_t required_size = protocol_dict_get_data_size(dict, protocol);
+        // FURI_LOG_I(TAG, "Protocol req data size is %d", required_size);
+        if(!flipper_format_read_hex(fff_data_file, "Data", data, required_size)) {
+            FURI_LOG_E(TAG, "RFID: Error reading data");
+            ACTION_SET_ERROR("RFID: Error reading data");
+            break;
+        }
+        // FURI_LOG_I(TAG, "Data: %s", furi_string_get_cstr(data_text));
+
+        // if(data_size != required_size) {
+        //     FURI_LOG_E(
+        //         TAG,
+        //         "%s data needs to be %zu bytes long",
+        //         protocol_dict_get_name(dict, protocol),
+        //         required_size);
+        //     break;
+        // }
+
+        protocol_dict_set_data(dict, protocol, data, data_size);
+        successful_read = true;
+        // FURI_LOG_I(TAG, "protocol dict setup complete!");
+    } while(false);
+
+    if(successful_read) {
+        LFRFIDWorker* worker = lfrfid_worker_alloc(dict);
+
+        lfrfid_worker_start_thread(worker);
+        lfrfid_worker_emulate_start(worker, protocol);
+
+        int16_t time_ms = app->settings.rfid_duration;
+        FURI_LOG_I(TAG, "RFID: Emulating (%s) for %d ms", file_name, time_ms);
+        int16_t interval_ms = 100;
+        while(time_ms > 0) {
+            furi_delay_ms(interval_ms);
+            time_ms -= interval_ms;
+        }
+        FURI_LOG_I(TAG, "RFID: Emulation stopped");
+
+        lfrfid_worker_stop(worker);
+        lfrfid_worker_stop_thread(worker);
+        lfrfid_worker_free(worker);
+    }
+
+    furi_string_free(temp_str);
+    free(data);
+    protocol_dict_free(dict);
+    flipper_format_free(fff_data_file);
+}

+ 305 - 0
quac/actions/action_subghz.c

@@ -0,0 +1,305 @@
+// Methods for Sub-GHz transmission
+
+// subghz
+#include <lib/subghz/transmitter.h>
+#include <lib/subghz/devices/devices.h>
+#include <lib/subghz/devices/cc1101_configs.h>
+#include <lib/subghz/protocols/raw.h>
+#include <lib/subghz/subghz_protocol_registry.h>
+
+#include <flipper_format/flipper_format.h>
+
+#include "action_i.h"
+#include "quac.h"
+
+#define SUBGHZ_DEVICE_CC1101_EXT_NAME "cc1101_ext"
+#define SUBGHZ_DEVICE_CC1101_INT_NAME "cc1101_int"
+
+static FuriHalSubGhzPreset action_subghz_get_preset_name(const char* preset_name) {
+    FuriHalSubGhzPreset preset = FuriHalSubGhzPresetIDLE;
+    if(!strcmp(preset_name, "FuriHalSubGhzPresetOok270Async")) {
+        preset = FuriHalSubGhzPresetOok270Async;
+    } else if(!strcmp(preset_name, "FuriHalSubGhzPresetOok650Async")) {
+        preset = FuriHalSubGhzPresetOok650Async;
+    } else if(!strcmp(preset_name, "FuriHalSubGhzPreset2FSKDev238Async")) {
+        preset = FuriHalSubGhzPreset2FSKDev238Async;
+    } else if(!strcmp(preset_name, "FuriHalSubGhzPreset2FSKDev476Async")) {
+        preset = FuriHalSubGhzPreset2FSKDev476Async;
+    } else if(!strcmp(preset_name, "FuriHalSubGhzPresetCustom")) {
+        preset = FuriHalSubGhzPresetCustom;
+    } else {
+        FURI_LOG_E(TAG, "SUBGHZ: Unknown preset!");
+    }
+    return preset;
+}
+
+static const SubGhzDevice* action_subghz_get_device(uint32_t* device_ind) {
+    const SubGhzDevice* device = NULL;
+    switch(*device_ind) {
+    case 1: {
+        // Power on the external antenna
+        uint8_t attempts = 5;
+        while(--attempts > 0) {
+            if(furi_hal_power_enable_otg()) break;
+        }
+        if(attempts == 0) {
+            if(furi_hal_power_get_usb_voltage() < 4.5f) {
+                FURI_LOG_E(
+                    TAG,
+                    "Error power otg enable. BQ2589 check otg fault = %d",
+                    furi_hal_power_check_otg_fault() ? 1 : 0);
+            }
+        }
+        device = subghz_devices_get_by_name(SUBGHZ_DEVICE_CC1101_EXT_NAME);
+        break;
+    }
+    default:
+        device = subghz_devices_get_by_name(SUBGHZ_DEVICE_CC1101_INT_NAME);
+        break;
+    }
+    if(!subghz_devices_is_connect(device)) {
+        // Power off
+        if(furi_hal_power_is_otg_enabled()) {
+            furi_hal_power_disable_otg();
+        }
+        if(*device_ind == 1) {
+            FURI_LOG_W(TAG, "Can't connect to External antenna, using Internal");
+        }
+        device = subghz_devices_get_by_name(SUBGHZ_DEVICE_CC1101_INT_NAME);
+        *device_ind = 0;
+    }
+    return device;
+}
+
+// Lifted from flipperzero-firmware/applications/main/subghz/subghz_cli.c
+void action_subghz_tx(void* context, const FuriString* action_path, FuriString* error) {
+    App* app = context;
+    const char* file_name = furi_string_get_cstr(action_path);
+    uint32_t repeat = app->settings.subghz_repeat; // Defaults to 10 in the CLI
+    uint32_t device_ind = app->settings.subghz_use_ext_antenna ? 1 : 0;
+
+    FlipperFormat* fff_data_file = flipper_format_file_alloc(app->storage);
+    FlipperFormat* fff_data_raw = flipper_format_string_alloc();
+    FuriString* temp_str;
+    temp_str = furi_string_alloc();
+    uint32_t temp_data32;
+    bool check_file = false;
+    const SubGhzDevice* device = NULL;
+
+    uint32_t frequency = 0;
+    SubGhzTransmitter* transmitter = NULL;
+
+    FURI_LOG_I(TAG, "SUBGHZ: Action starting...");
+
+    subghz_devices_init();
+    SubGhzEnvironment* environment = subghz_environment_alloc();
+    if(!subghz_environment_load_keystore(environment, SUBGHZ_KEYSTORE_DIR_NAME)) {
+        FURI_LOG_W(TAG, "Load_keystore keeloq_mfcodes - failed to load");
+    }
+    if(!subghz_environment_load_keystore(environment, SUBGHZ_KEYSTORE_DIR_USER_NAME)) {
+        FURI_LOG_W(TAG, "Load_keystore keeloq_mfcodes_user - failed to load");
+    }
+    subghz_environment_set_came_atomo_rainbow_table_file_name(
+        environment, SUBGHZ_CAME_ATOMO_DIR_NAME);
+    subghz_environment_set_alutech_at_4n_rainbow_table_file_name(
+        environment, SUBGHZ_ALUTECH_AT_4N_DIR_NAME);
+    subghz_environment_set_nice_flor_s_rainbow_table_file_name(
+        environment, SUBGHZ_NICE_FLOR_S_DIR_NAME);
+    subghz_environment_set_protocol_registry(environment, (void*)&subghz_protocol_registry);
+
+    do {
+        device = action_subghz_get_device(&device_ind);
+        if(device == NULL) {
+            FURI_LOG_E(TAG, "Error device not found");
+            ACTION_SET_ERROR("SUBGHZ: Device not found");
+            break;
+        }
+
+        if(!flipper_format_file_open_existing(fff_data_file, file_name)) {
+            FURI_LOG_E(TAG, "Error opening %s", file_name);
+            ACTION_SET_ERROR("SUBGHZ: Error opening %s", file_name);
+            break;
+        }
+
+        if(!flipper_format_read_header(fff_data_file, temp_str, &temp_data32)) {
+            FURI_LOG_E(TAG, "Missing or incorrect header");
+            ACTION_SET_ERROR("SUBGHZ: Missing or incorrect header");
+            break;
+        }
+
+        if(((!strcmp(furi_string_get_cstr(temp_str), SUBGHZ_KEY_FILE_TYPE)) ||
+            (!strcmp(furi_string_get_cstr(temp_str), SUBGHZ_RAW_FILE_TYPE))) &&
+           temp_data32 == SUBGHZ_KEY_FILE_VERSION) {
+        } else {
+            FURI_LOG_E(TAG, "Type or version mismatch");
+            ACTION_SET_ERROR("SUBGHZ: Type or version mismatch");
+            break;
+        }
+
+        if(!flipper_format_read_uint32(fff_data_file, "Frequency", &frequency, 1)) {
+            FURI_LOG_E(TAG, "Missing Frequency");
+            ACTION_SET_ERROR("SUBGHZ: Missing frequency");
+            break;
+        }
+
+        if(!subghz_devices_is_frequency_valid(device, frequency)) {
+            FURI_LOG_E(TAG, "Frequency not supported");
+            ACTION_SET_ERROR("SUBGHZ: Frequency not supported");
+            break;
+        }
+
+        if(!flipper_format_read_string(fff_data_file, "Preset", temp_str)) {
+            FURI_LOG_E(TAG, "Missing Preset");
+            ACTION_SET_ERROR("SUBGHZ: Missing preset");
+            break;
+        }
+
+        FuriHalSubGhzPreset preset = action_subghz_get_preset_name(furi_string_get_cstr(temp_str));
+        if(preset == FuriHalSubGhzPresetIDLE) {
+            ACTION_SET_ERROR("SUBGHZ: Unknown preset");
+            break;
+        }
+
+        subghz_devices_begin(device);
+        subghz_devices_reset(device);
+        subghz_devices_idle(device);
+
+        if(preset == FuriHalSubGhzPresetCustom) {
+            uint8_t* custom_preset_data;
+            uint32_t custom_preset_data_size;
+            if(!flipper_format_get_value_count(fff_data_file, "Custom_preset_data", &temp_data32))
+                break;
+            if(!temp_data32 || (temp_data32 % 2)) {
+                FURI_LOG_E(TAG, "Custom_preset_data size error");
+                ACTION_SET_ERROR("SUBGHZ: Custom_preset_data size error");
+                break;
+            }
+            custom_preset_data_size = sizeof(uint8_t) * temp_data32;
+            custom_preset_data = malloc(custom_preset_data_size);
+            if(!flipper_format_read_hex(
+                   fff_data_file,
+                   "Custom_preset_data",
+                   custom_preset_data,
+                   custom_preset_data_size)) {
+                FURI_LOG_E(TAG, "Custom_preset_data read error");
+                ACTION_SET_ERROR("SUBGHZ: Custom_preset_data read error");
+                break;
+            }
+            subghz_devices_load_preset(device, preset, custom_preset_data);
+            free(custom_preset_data);
+        } else {
+            subghz_devices_load_preset(device, preset, NULL);
+        }
+
+        subghz_devices_set_frequency(device, frequency);
+
+        // Load Protocol
+        if(!flipper_format_read_string(fff_data_file, "Protocol", temp_str)) {
+            FURI_LOG_E(TAG, "Missing protocol");
+            ACTION_SET_ERROR("SUBGHZ: Missing protocol");
+            break;
+        }
+
+        SubGhzProtocolStatus status;
+        bool is_init_protocol = true;
+        if(furi_string_equal(temp_str, "RAW")) {
+            FURI_LOG_I(TAG, "Protocol = RAW");
+            subghz_protocol_raw_gen_fff_data(
+                fff_data_raw, file_name, subghz_devices_get_name(device));
+            transmitter =
+                subghz_transmitter_alloc_init(environment, furi_string_get_cstr(temp_str));
+            if(transmitter == NULL) {
+                FURI_LOG_E(TAG, "Error transmitter");
+                is_init_protocol = false;
+            }
+
+            if(is_init_protocol) {
+                status = subghz_transmitter_deserialize(transmitter, fff_data_raw);
+                if(status != SubGhzProtocolStatusOk) {
+                    FURI_LOG_E(TAG, "Error deserialize protocol");
+                    is_init_protocol = false;
+                }
+            }
+        } else { // if not RAW protocol
+            FURI_LOG_I(TAG, "Protocol != RAW");
+            bool repeat_exists = flipper_format_key_exist(fff_data_file, "Repeat");
+            if(!repeat_exists) {
+                flipper_format_write_uint32(fff_data_file, "Repeat", &repeat, 1);
+            }
+            transmitter =
+                subghz_transmitter_alloc_init(environment, furi_string_get_cstr(temp_str));
+            if(transmitter == NULL) {
+                FURI_LOG_E(TAG, "Error transmitter");
+                is_init_protocol = false;
+            }
+            if(is_init_protocol) {
+                status = subghz_transmitter_deserialize(transmitter, fff_data_file);
+                if(status != SubGhzProtocolStatusOk) {
+                    FURI_LOG_E(TAG, "Error deserialize protocol");
+                    ACTION_SET_ERROR("SUBGHZ: Protocol error");
+                    is_init_protocol = false;
+                }
+            }
+            if(!repeat_exists) {
+                flipper_format_delete_key(fff_data_file, "Repeat");
+            }
+        }
+
+        if(is_init_protocol) {
+            check_file = true;
+        } else {
+            subghz_devices_sleep(device);
+            subghz_devices_end(device);
+            subghz_transmitter_free(transmitter);
+        }
+    } while(false);
+
+    flipper_format_free(fff_data_file);
+
+    if(check_file) {
+        furi_hal_power_suppress_charge_enter();
+        subghz_devices_set_tx(device);
+        FURI_LOG_I(
+            TAG,
+            "Transmitting at %s. Frequency=%lu, Protocol=%s",
+            file_name,
+            frequency,
+            furi_string_get_cstr(temp_str));
+        do {
+            // FURI_LOG_I(TAG, "delaying 200ms");
+            furi_delay_ms(100); // needed? orig 200
+            if(subghz_devices_start_async_tx(device, subghz_transmitter_yield, transmitter)) {
+                while(!subghz_devices_is_async_complete_tx(device)) {
+                    // || cli_cmd_interrupt_received
+                    furi_delay_ms(100); // orig 333
+                }
+                subghz_devices_stop_async_tx(device);
+            } else {
+                FURI_LOG_W(TAG, "Transmission on this frequency is restricted in your region");
+            }
+
+            if(furi_string_equal(temp_str, "RAW")) {
+                subghz_transmitter_stop(transmitter);
+                repeat--;
+                // FURI_LOG_I(TAG, "decrementing repeat: %lu", repeat);
+                if(repeat) subghz_transmitter_deserialize(transmitter, fff_data_raw);
+            }
+
+        } while(repeat && furi_string_equal(temp_str, "RAW"));
+
+        subghz_devices_sleep(device);
+        subghz_devices_end(device);
+        // power off
+        if(furi_hal_power_is_otg_enabled()) furi_hal_power_disable_otg();
+
+        furi_hal_power_suppress_charge_exit();
+        subghz_transmitter_free(transmitter);
+    }
+
+    FURI_LOG_I(TAG, "SUBGHZ: Action complete.");
+
+    flipper_format_free(fff_data_raw);
+    furi_string_free(temp_str);
+    subghz_devices_deinit();
+    subghz_environment_free(environment);
+}

+ 17 - 0
quac/application.fam

@@ -0,0 +1,17 @@
+# For details & more options, see documentation/AppManifests.md in firmware repo
+
+App(
+    appid="quac",  # Must be unique
+    name="Quac!",  # Displayed in menus
+    apptype=FlipperAppType.EXTERNAL,
+    entry_point="quac_app",
+    stack_size=2 * 1024,
+    fap_category="Tools",
+    # Optional values
+    fap_version="0.6",
+    fap_icon="images/quac.png",  # 10x10 1-bit PNG
+    fap_description="Quick Action remote control app",
+    fap_author="Roberto De Feo",
+    fap_weburl="https://github.com/rdefeo/quac",
+    fap_icon_assets="images",  # Image assets to compile for this application
+)

+ 0 - 0
quac/images/.gitkeep


BIN
quac/images/ArrowDown_8x4.png


BIN
quac/images/ArrowUp_8x4.png


BIN
quac/images/Directory_10px.png


BIN
quac/images/IR_10px.png


BIN
quac/images/NFC_10px.png


BIN
quac/images/Playlist_10px.png


BIN
quac/images/RFID_10px.png


BIN
quac/images/Settings_10px.png


BIN
quac/images/SubGHz_10px.png


BIN
quac/images/Unknown_10px.png


BIN
quac/images/quac.png


+ 170 - 0
quac/item.c

@@ -0,0 +1,170 @@
+
+#include <furi.h>
+#include <storage/storage.h>
+#include <toolbox/dir_walk.h>
+#include <toolbox/path.h>
+
+#include "quac.h"
+#include "item.h"
+#include <m-array.h>
+
+ARRAY_DEF(FileArray, FuriString*, FURI_STRING_OPLIST);
+
+ItemsView* item_get_items_view_from_path(void* context, const FuriString* input_path) {
+    App* app = context;
+
+    // Handle the app start condition
+    FuriString* in_path;
+    if(input_path == NULL) {
+        in_path = furi_string_alloc_set_str(APP_DATA_PATH(""));
+    } else {
+        in_path = furi_string_alloc_set(input_path);
+    }
+    if(furi_string_get_char(in_path, furi_string_size(in_path) - 1) == '/') {
+        furi_string_left(in_path, furi_string_size(in_path) - 1);
+    }
+    const char* cpath = furi_string_get_cstr(in_path);
+
+    FURI_LOG_I(TAG, "Reading items from path: %s", cpath);
+    ItemsView* iview = malloc(sizeof(ItemsView));
+    iview->path = furi_string_alloc_set(in_path);
+
+    iview->name = furi_string_alloc();
+    if(app->depth == 0) {
+        furi_string_set_str(iview->name, QUAC_NAME);
+    } else {
+        path_extract_basename(cpath, iview->name);
+        item_prettify_name(iview->name);
+    }
+
+    DirWalk* dir_walk = dir_walk_alloc(app->storage);
+    dir_walk_set_recursive(dir_walk, false);
+
+    FuriString* path = furi_string_alloc();
+    FileArray_t flist;
+    FileArray_init(flist);
+
+    FuriString* filename_tmp;
+    filename_tmp = furi_string_alloc();
+
+    // Walk the directory and store all file names in sorted order
+    if(dir_walk_open(dir_walk, cpath)) {
+        while(dir_walk_read(dir_walk, path, NULL) == DirWalkOK) {
+            // FURI_LOG_I(TAG, "> dir_walk: %s", furi_string_get_cstr(path));
+            const char* cpath = furi_string_get_cstr(path);
+
+            path_extract_filename(path, filename_tmp, false);
+            // Always skip our .quac.conf file!
+            if(!furi_string_cmp_str(filename_tmp, QUAC_SETTINGS_FILENAME)) {
+                continue;
+            }
+
+            // Skip "hidden" files
+            char first_char = furi_string_get_char(filename_tmp, 0);
+            if(first_char == '.' && !app->settings.show_hidden) {
+                // FURI_LOG_I(TAG, ">> skipping hidden file: %s", furi_string_get_cstr(filename_tmp));
+                continue;
+            }
+
+            // Insert the new file path in sorted order to flist
+            uint32_t i = 0;
+            FileArray_it_t it;
+            for(FileArray_it(it, flist); !FileArray_end_p(it); FileArray_next(it), ++i) {
+                if(strcmp(cpath, furi_string_get_cstr(*FileArray_ref(it))) > 0) {
+                    continue;
+                }
+                // FURI_LOG_I(TAG, ">> Inserting at %lu", i);
+                FileArray_push_at(flist, i, path);
+                break;
+            }
+            if(i == FileArray_size(flist)) {
+                // FURI_LOG_I(TAG, "Couldn't insert, so adding at the end!");
+                FileArray_push_back(flist, path);
+            }
+        }
+    }
+
+    furi_string_free(filename_tmp);
+    furi_string_free(path);
+
+    // Generate our Item list
+    FileArray_it_t iter;
+    ItemArray_init(iview->items);
+    for(FileArray_it(iter, flist); !FileArray_end_p(iter); FileArray_next(iter)) {
+        path = *FileArray_ref(iter);
+        const char* found_path = furi_string_get_cstr(path);
+
+        Item* item = ItemArray_push_new(iview->items);
+
+        FileInfo fileinfo;
+        if(storage_common_stat(app->storage, found_path, &fileinfo) == FSE_OK &&
+           file_info_is_dir(&fileinfo)) {
+            item->type = Item_Group;
+        } else {
+            // Action files have extensions, so item->ext starts with '.'
+            item->ext[0] = 0;
+            path_extract_extension(path, item->ext, MAX_EXT_LEN);
+            item->type = item_get_item_type_from_extension(item->ext);
+        }
+
+        item->name = furi_string_alloc();
+        path_extract_filename_no_ext(found_path, item->name);
+        // FURI_LOG_I(TAG, "Basename: %s", furi_string_get_cstr(item->name));
+        item_prettify_name(item->name);
+
+        item->path = furi_string_alloc();
+        furi_string_set(item->path, path);
+        // FURI_LOG_I(TAG, "Path: %s", furi_string_get_cstr(item->path));
+    }
+
+    furi_string_free(in_path);
+    FileArray_clear(flist);
+    dir_walk_free(dir_walk);
+
+    return iview;
+}
+
+void item_items_view_free(ItemsView* items_view) {
+    furi_string_free(items_view->name);
+    furi_string_free(items_view->path);
+    ItemArray_it_t iter;
+    for(ItemArray_it(iter, items_view->items); !ItemArray_end_p(iter); ItemArray_next(iter)) {
+        furi_string_free(ItemArray_ref(iter)->name);
+        furi_string_free(ItemArray_ref(iter)->path);
+    }
+    ItemArray_clear(items_view->items);
+    free(items_view);
+}
+
+void item_prettify_name(FuriString* name) {
+    // FURI_LOG_I(TAG, "Converting %s to...", furi_string_get_cstr(name));
+    if(furi_string_size(name) > 3) {
+        char c = furi_string_get_char(name, 2);
+        if(c == '_') {
+            char a = furi_string_get_char(name, 0);
+            char b = furi_string_get_char(name, 1);
+            if(a >= '0' && a <= '9' && b >= '0' && b <= '9') {
+                furi_string_right(name, 3);
+            }
+        }
+    }
+    furi_string_replace_all_str(name, "_", " ");
+    // FURI_LOG_I(TAG, "... %s", furi_string_get_cstr(name));
+}
+
+ItemType item_get_item_type_from_extension(const char* ext) {
+    ItemType type = Item_Unknown;
+
+    if(!strcmp(ext, ".sub")) {
+        type = Item_SubGhz;
+    } else if(!strcmp(ext, ".rfid")) {
+        type = Item_RFID;
+    } else if(!strcmp(ext, ".ir")) {
+        type = Item_IR;
+    } else if(!strcmp(ext, ".nfc")) {
+        type = Item_NFC;
+    } else if(!strcmp(ext, ".qpl")) {
+        type = Item_Playlist;
+    }
+    return type;
+}

+ 65 - 0
quac/item.h

@@ -0,0 +1,65 @@
+#pragma once
+
+#include <m-array.h>
+
+// Max length of a filename, final path element only
+#define MAX_NAME_LEN 64
+#define MAX_EXT_LEN 6
+
+/** Defines an individual item action or item group. Each object contains
+ * the relevant file and type information needed to both render correctly
+ * on-screen as well as to perform that action.
+*/
+
+typedef enum {
+    Item_SubGhz,
+    Item_RFID,
+    Item_IR,
+    Item_NFC,
+    Item_Playlist,
+    Item_Group,
+    Item_Settings,
+    Item_Unknown,
+    Item_count
+} ItemType;
+
+typedef struct Item {
+    ItemType type;
+    FuriString* name;
+    FuriString* path;
+    char ext[MAX_EXT_LEN + 1];
+} Item;
+
+ARRAY_DEF(ItemArray, Item, M_POD_OPLIST);
+
+typedef struct ItemsView {
+    FuriString* name;
+    FuriString* path;
+    ItemArray_t items;
+} ItemsView;
+
+/** Allocates and returns an ItemsView* which contains the list of
+ * items to display for the given path. Contains everything needed
+ * to render a scene_items.
+ * 
+ * @param   context App*
+ * @param   path    FuriString*
+ * @return  ItemsView*
+*/
+ItemsView* item_get_items_view_from_path(void* context, const FuriString* path);
+
+/** Free ItemsView
+ * @param   items_view
+*/
+void item_items_view_free(ItemsView* items_view);
+
+/** Prettify the name by removing a leading XX_, only if both X are digits,
+ * as well as replace all '_' with ' '.
+ * @param   name    FuriString*
+*/
+void item_prettify_name(FuriString* name);
+
+/** Return the ItemType enum for the given extension
+ * @param   ext     File extension
+*/
+ItemType item_get_item_type_from_extension(const char* ext);

+ 115 - 0
quac/quac.c

@@ -0,0 +1,115 @@
+#include <furi.h>
+
+#include "quac.h"
+#include "quac_settings.h"
+
+#include "item.h"
+#include "scenes/scenes.h"
+#include "scenes/scene_items.h"
+
+/* generated by fbt from .png files in images folder */
+#include <quac_icons.h>
+
+App* app_alloc() {
+    App* app = malloc(sizeof(App));
+    app->scene_manager = scene_manager_alloc(&app_scene_handlers, app);
+    app->view_dispatcher = view_dispatcher_alloc();
+    view_dispatcher_enable_queue(app->view_dispatcher);
+    view_dispatcher_set_event_callback_context(app->view_dispatcher, app);
+    view_dispatcher_set_custom_event_callback(app->view_dispatcher, app_scene_custom_callback);
+    view_dispatcher_set_navigation_event_callback(app->view_dispatcher, app_back_event_callback);
+
+    // Create our UI elements
+    // Main interface
+    app->action_menu = action_menu_alloc();
+    view_dispatcher_add_view(
+        app->view_dispatcher, QView_ActionMenu, action_menu_get_view(app->action_menu));
+
+    // App settings
+    app->vil_settings = variable_item_list_alloc();
+    view_dispatcher_add_view(
+        app->view_dispatcher, QView_Settings, variable_item_list_get_view(app->vil_settings));
+
+    // Misc interfaces
+    app->sub_menu = submenu_alloc();
+    view_dispatcher_add_view(app->view_dispatcher, QView_SubMenu, submenu_get_view(app->sub_menu));
+
+    app->text_input = text_input_alloc();
+    view_dispatcher_add_view(
+        app->view_dispatcher, QView_TextInput, text_input_get_view(app->text_input));
+
+    app->popup = popup_alloc();
+    view_dispatcher_add_view(app->view_dispatcher, QView_Popup, popup_get_view(app->popup));
+
+    // Storage
+    app->storage = furi_record_open(RECORD_STORAGE);
+
+    // Notifications - for LED light access
+    app->notifications = furi_record_open(RECORD_NOTIFICATION);
+
+    app->dialog = furi_record_open(RECORD_DIALOGS);
+
+    // data member initialize
+    app->depth = 0;
+    app->selected_item = -1;
+
+    app->temp_str = furi_string_alloc();
+
+    return app;
+}
+
+void app_free(App* app) {
+    furi_assert(app);
+
+    item_items_view_free(app->items_view);
+
+    view_dispatcher_remove_view(app->view_dispatcher, QView_ActionMenu);
+    view_dispatcher_remove_view(app->view_dispatcher, QView_Settings);
+    view_dispatcher_remove_view(app->view_dispatcher, QView_SubMenu);
+    view_dispatcher_remove_view(app->view_dispatcher, QView_TextInput);
+    view_dispatcher_remove_view(app->view_dispatcher, QView_Popup);
+
+    action_menu_free(app->action_menu);
+    variable_item_list_free(app->vil_settings);
+    submenu_free(app->sub_menu);
+    text_input_free(app->text_input);
+
+    scene_manager_free(app->scene_manager);
+    view_dispatcher_free(app->view_dispatcher);
+
+    furi_string_free(app->temp_str);
+
+    furi_record_close(RECORD_STORAGE);
+    furi_record_close(RECORD_NOTIFICATION);
+    furi_record_close(RECORD_DIALOGS);
+
+    free(app);
+}
+
+// FAP Entry Point
+int32_t quac_app(void* p) {
+    UNUSED(p);
+    FURI_LOG_I(TAG, "QUAC! QUAC!");
+
+    size_t free_start = memmgr_get_free_heap();
+    furi_assert(0);
+
+    App* app = app_alloc();
+    quac_load_settings(app);
+
+    // Read items at our root
+    app->items_view = item_get_items_view_from_path(app, NULL);
+
+    Gui* gui = furi_record_open(RECORD_GUI);
+    view_dispatcher_attach_to_gui(app->view_dispatcher, gui, ViewDispatcherTypeFullscreen);
+    scene_manager_next_scene(app->scene_manager, QScene_Items);
+    view_dispatcher_run(app->view_dispatcher);
+
+    furi_record_close(RECORD_GUI);
+    app_free(app);
+
+    size_t free_end = memmgr_get_free_heap();
+    FURI_LOG_W(TAG, "Heap: Start = %d, End = %d", free_start, free_end);
+
+    return 0;
+}

+ 67 - 0
quac/quac.h

@@ -0,0 +1,67 @@
+#pragma once
+
+#include <gui/gui.h>
+#include <gui/scene_manager.h>
+#include <gui/view_dispatcher.h>
+#include <gui/scene_manager.h>
+#include <gui/modules/variable_item_list.h>
+#include <gui/modules/submenu.h>
+#include <gui/modules/text_input.h>
+#include <gui/modules/popup.h>
+#include <dialogs/dialogs.h>
+
+#include <storage/storage.h>
+#include <notification/notification_messages.h>
+
+#include "views/action_menu.h"
+#include "item.h"
+
+#define QUAC_NAME "Quac!"
+#define QUAC_VERSION "v0.6"
+#define QUAC_ABOUT                                    \
+    "Quick Action remote control\n" QUAC_VERSION "\n" \
+    "github.com/rdefeo/quac"
+#define TAG "Quac" // log statement id
+
+// Location of our actions and folders
+#define QUAC_SETTINGS_FILENAME ".quac.conf"
+#define QUAC_SETTINGS_PATH APP_DATA_PATH(QUAC_SETTINGS_FILENAME)
+
+typedef enum { QUAC_APP_PORTRAIT, QUAC_APP_LANDSCAPE } QuacAppLayout;
+
+typedef struct App {
+    SceneManager* scene_manager;
+    ViewDispatcher* view_dispatcher;
+
+    ActionMenu* action_menu;
+    VariableItemList* vil_settings;
+    DialogsApp* dialog;
+    Submenu* sub_menu;
+    TextInput* text_input;
+    Popup* popup;
+
+    Storage* storage;
+    NotificationApp* notifications;
+
+    ItemsView* items_view;
+    int depth;
+    int selected_item;
+
+    FuriString* temp_str; // used for renames/etc
+    char temp_cstr[MAX_NAME_LEN]; // used for renames/etc
+
+    struct {
+        QuacAppLayout layout; // Defaults to Portrait
+        bool show_icons; // Defaults to True
+        bool show_headers; // Defaults to True
+        uint32_t rfid_duration; // Defaults to 2500 ms
+        uint32_t nfc_duration; // Defaults to 1000 ms
+        uint32_t subghz_repeat; // Defaults to 10, just like the CLI
+        bool subghz_use_ext_antenna; // Defaults to False
+        bool show_hidden; // Defaults to False
+    } settings;
+
+} App;
+
+App* app_alloc();
+void app_free(App* app);

+ 179 - 0
quac/quac_settings.c

@@ -0,0 +1,179 @@
+#include "quac_settings.h"
+
+#include <flipper_format/flipper_format.h>
+
+// Quac Settings File Info
+#define QUAC_SETTINGS_FILE_TYPE "Quac Settings File"
+#define QUAC_SETTINGS_FILE_VERSION 1
+
+void quac_set_default_settings(App* app) {
+    app->settings.layout = QUAC_APP_LANDSCAPE;
+    app->settings.show_icons = true;
+    app->settings.show_headers = true;
+    app->settings.rfid_duration = 2500;
+    app->settings.nfc_duration = 1000;
+    app->settings.subghz_repeat = 10;
+    app->settings.subghz_use_ext_antenna = false;
+    app->settings.show_hidden = false;
+}
+
+void quac_load_settings(App* app) {
+    FlipperFormat* fff_settings = flipper_format_file_alloc(app->storage);
+    FuriString* temp_str;
+    temp_str = furi_string_alloc();
+    uint32_t temp_data32 = 0;
+
+    // Initialize settings to the defaults
+    quac_set_default_settings(app);
+
+    FURI_LOG_I(TAG, "SETTINGS: Reading: %s", QUAC_SETTINGS_PATH);
+    do {
+        if(!flipper_format_file_open_existing(fff_settings, QUAC_SETTINGS_PATH)) {
+            FURI_LOG_I(TAG, "SETTINGS: File not found, using defaults");
+            break;
+        }
+
+        if(!flipper_format_read_header(fff_settings, temp_str, &temp_data32)) {
+            FURI_LOG_E(TAG, "SETTINGS: Missing or incorrect header");
+            break;
+        }
+
+        if((!strcmp(furi_string_get_cstr(temp_str), QUAC_SETTINGS_FILE_TYPE)) &&
+           (temp_data32 == QUAC_SETTINGS_FILE_VERSION)) {
+        } else {
+            FURI_LOG_E(TAG, "SETTINGS: Type or version mismatch");
+            break;
+        }
+
+        // Now read actual values we care about
+        if(!flipper_format_read_string(fff_settings, "Layout", temp_str)) {
+            FURI_LOG_W(TAG, "SETTINGS: Missing Layout");
+        } else {
+            if(!strcmp(furi_string_get_cstr(temp_str), "Landscape")) {
+                app->settings.layout = QUAC_APP_LANDSCAPE;
+            } else if(!strcmp(furi_string_get_cstr(temp_str), "Portrait")) {
+                app->settings.layout = QUAC_APP_PORTRAIT;
+            } else {
+                FURI_LOG_E(TAG, "SETTINGS: Invalid Layout");
+            }
+        }
+
+        if(!flipper_format_read_uint32(fff_settings, "Show Icons", &temp_data32, 1)) {
+            FURI_LOG_W(TAG, "SETTINGS: Missing 'Show Icons'");
+        } else {
+            app->settings.show_icons = (temp_data32 == 0) ? false : true;
+        }
+
+        if(!flipper_format_read_uint32(fff_settings, "Show Headers", &temp_data32, 1)) {
+            FURI_LOG_W(TAG, "SETTINGS: Missing 'Show Headers'");
+        } else {
+            app->settings.show_headers = (temp_data32 == 1) ? true : false;
+        }
+
+        if(!flipper_format_read_uint32(fff_settings, "RFID Duration", &temp_data32, 1)) {
+            FURI_LOG_W(TAG, "SETTINGS: Missing 'RFID Duration'");
+        } else {
+            app->settings.rfid_duration = temp_data32;
+        }
+
+        if(!flipper_format_read_uint32(fff_settings, "NFC Duration", &temp_data32, 1)) {
+            FURI_LOG_W(TAG, "SETTINGS: Missing 'NFC Duration'");
+        } else {
+            app->settings.nfc_duration = temp_data32;
+        }
+
+        if(!flipper_format_read_uint32(fff_settings, "SubGHz Repeat", &temp_data32, 1)) {
+            FURI_LOG_W(TAG, "SETTINGS: Missing 'SubGHz Repeat'");
+        } else {
+            app->settings.subghz_repeat = temp_data32;
+        }
+
+        if(!flipper_format_read_uint32(fff_settings, "SubGHz Ext Antenna", &temp_data32, 1)) {
+            FURI_LOG_W(TAG, "SETTINGS: Missing 'SubGHz Ext Antenna'");
+        } else {
+            app->settings.subghz_use_ext_antenna = (temp_data32 == 1) ? true : false;
+        }
+
+        if(!flipper_format_read_uint32(fff_settings, "Show Hidden", &temp_data32, 1)) {
+            FURI_LOG_W(TAG, "SETTINGS: Missing 'Show Hidden'");
+        } else {
+            app->settings.show_hidden = (temp_data32 == 1) ? true : false;
+        }
+    } while(false);
+
+    furi_string_free(temp_str);
+    flipper_format_free(fff_settings);
+}
+
+void quac_save_settings(App* app) {
+    FlipperFormat* fff_settings = flipper_format_file_alloc(app->storage);
+    uint32_t temp_data32;
+
+    FURI_LOG_I(TAG, "SETTINGS: Saving");
+    bool successful = false;
+    do {
+        if(!flipper_format_file_open_always(fff_settings, QUAC_SETTINGS_PATH)) {
+            FURI_LOG_E(TAG, "SETTINGS: Unable to open file for save!!");
+            break;
+        }
+
+        if(!flipper_format_write_header_cstr(
+               fff_settings, QUAC_SETTINGS_FILE_TYPE, QUAC_SETTINGS_FILE_VERSION)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed writing file type and version");
+            break;
+        }
+        // layout, icons, headers, duration
+        if(!flipper_format_write_string_cstr(
+               fff_settings,
+               "Layout",
+               app->settings.layout == QUAC_APP_LANDSCAPE ? "Landscape" : "Portrait")) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write Layout");
+            break;
+        }
+
+        temp_data32 = app->settings.show_icons ? 1 : 0;
+        if(!flipper_format_write_uint32(fff_settings, "Show Icons", &temp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'Show Icons'");
+            break;
+        }
+        temp_data32 = app->settings.show_headers ? 1 : 0;
+        if(!flipper_format_write_uint32(fff_settings, "Show Headers", &temp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'Show Headers'");
+            break;
+        }
+        if(!flipper_format_write_uint32(
+               fff_settings, "RFID Duration", &app->settings.rfid_duration, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'RFID Duration'");
+            break;
+        }
+        if(!flipper_format_write_uint32(
+               fff_settings, "NFC Duration", &app->settings.nfc_duration, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'NFC Duration'");
+            break;
+        }
+        if(!flipper_format_write_uint32(
+               fff_settings, "SubGHz Repeat", &app->settings.subghz_repeat, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'SubGHz Repeat'");
+            break;
+        }
+        temp_data32 = app->settings.subghz_use_ext_antenna ? 1 : 0;
+        if(!flipper_format_write_uint32(fff_settings, "SubGHz Ext Antenna", &temp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'SubGHz Ext Antenna'");
+            break;
+        }
+        temp_data32 = app->settings.show_hidden ? 1 : 0;
+        if(!flipper_format_write_uint32(fff_settings, "Show Hidden", &temp_data32, 1)) {
+            FURI_LOG_E(TAG, "SETTINGS: Failed to write 'Show Hidden'");
+            break;
+        }
+
+        successful = true;
+    } while(false);
+
+    if(!successful) {
+        FURI_LOG_E(TAG, "SETTINGS: Failed to save settings!!");
+    }
+
+    flipper_format_file_close(fff_settings);
+    flipper_format_free(fff_settings);
+}

+ 14 - 0
quac/quac_settings.h

@@ -0,0 +1,14 @@
+#pragma once
+
+#include "quac.h"
+
+/** Set the default Settings for Quac */
+void quac_set_default_settings(App* app);
+
+/** Load the Settings from the .quac.conf file. If not found,
+ * then load the defaults.
+*/
+void quac_load_settings(App* app);
+
+/** Save the current settings to the .quac.conf file */
+void quac_save_settings(App* app);

+ 6 - 0
quac/scenes/.gitignore

@@ -0,0 +1,6 @@
+dist/*
+.vscode
+.clang-format
+.editorconfig
+.env
+.ufbt

+ 41 - 0
quac/scenes/scene_about.c

@@ -0,0 +1,41 @@
+#include <furi.h>
+
+#include <gui/view_dispatcher.h>
+#include <gui/scene_manager.h>
+#include <flipper_application/flipper_application.h>
+
+#include "quac.h"
+#include "scenes.h"
+#include "scene_about.h"
+#include "../actions/action.h"
+#include "quac_icons.h"
+
+enum {
+    SceneActionRenameEvent,
+};
+
+void scene_about_callback(void* context) {
+    App* app = context;
+    view_dispatcher_send_custom_event(app->view_dispatcher, SceneActionRenameEvent);
+}
+
+void scene_about_on_enter(void* context) {
+    App* app = context;
+
+    Popup* popup = app->popup;
+    popup_set_header(popup, QUAC_NAME, 68, 1, AlignCenter, AlignTop);
+    popup_set_text(popup, QUAC_ABOUT, 0, 15, AlignLeft, AlignTop);
+    popup_set_icon(popup, 38, 0, &I_quac);
+    view_dispatcher_switch_to_view(app->view_dispatcher, QView_Popup);
+}
+
+bool scene_about_on_event(void* context, SceneManagerEvent event) {
+    UNUSED(context);
+    UNUSED(event);
+    return false;
+}
+
+void scene_about_on_exit(void* context) {
+    App* app = context;
+    popup_reset(app->popup);
+}

+ 8 - 0
quac/scenes/scene_about.h

@@ -0,0 +1,8 @@
+#pragma once
+
+#include <gui/scene_manager.h>
+
+// For each scene, implement handler callbacks
+void scene_about_on_enter(void* context);
+bool scene_about_on_event(void* context, SceneManagerEvent event);
+void scene_about_on_exit(void* context);

+ 91 - 0
quac/scenes/scene_action_create_group.c

@@ -0,0 +1,91 @@
+#include <furi.h>
+
+#include <gui/view_dispatcher.h>
+#include <gui/scene_manager.h>
+#include <gui/modules/text_input.h>
+
+#include "quac.h"
+#include "scenes.h"
+#include "scene_action_create_group.h"
+#include "../actions/action.h"
+
+#include <lib/toolbox/path.h>
+
+enum {
+    SceneActionCreateGroupEvent,
+};
+
+void scene_action_create_group_callback(void* context) {
+    App* app = context;
+    view_dispatcher_send_custom_event(app->view_dispatcher, SceneActionCreateGroupEvent);
+}
+
+void scene_action_create_group_on_enter(void* context) {
+    App* app = context;
+    TextInput* text = app->text_input;
+
+    text_input_set_header_text(text, "Enter new group name:");
+
+    app->temp_cstr[0] = 0;
+    text_input_set_result_callback(
+        text, scene_action_create_group_callback, app, app->temp_cstr, MAX_NAME_LEN, false);
+
+    // TextInputValidatorCallback
+    // text_input_set_validator(text, validator_callback, context)
+
+    view_dispatcher_switch_to_view(app->view_dispatcher, QView_TextInput);
+}
+
+bool scene_action_create_group_on_event(void* context, SceneManagerEvent event) {
+    App* app = context;
+    bool consumed = false;
+    if(event.type == SceneManagerEventTypeCustom) {
+        if(event.event == SceneActionCreateGroupEvent) {
+            // FURI_LOG_I(TAG, "Attempting to create group %s", app->temp_cstr);
+            if(!strcmp(app->temp_cstr, "")) {
+                return false;
+            }
+
+            FuriString* current_path = furi_string_alloc();
+            if(app->selected_item != EMPTY_ACTION_INDEX) {
+                Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+                path_extract_dirname(furi_string_get_cstr(item->path), current_path);
+            } else {
+                furi_string_set(current_path, app->items_view->path);
+            }
+
+            FuriString* new_group_path = furi_string_alloc();
+            furi_string_printf(
+                new_group_path, "%s/%s", furi_string_get_cstr(current_path), app->temp_cstr);
+            // FURI_LOG_I(TAG, "Full new path: %s", furi_string_get_cstr(new_group_path));
+
+            FS_Error fs_result =
+                storage_common_mkdir(app->storage, furi_string_get_cstr(new_group_path));
+            if(fs_result == FSE_OK) {
+                ItemsView* new_items = item_get_items_view_from_path(app, current_path);
+                item_items_view_free(app->items_view);
+                app->items_view = new_items;
+            } else {
+                FURI_LOG_E(
+                    TAG, "Create Group failed! %s", filesystem_api_error_get_desc(fs_result));
+                FuriString* error_msg = furi_string_alloc_printf(
+                    "Create Group failed!\nError: %s", filesystem_api_error_get_desc(fs_result));
+                dialog_message_show_storage_error(app->dialog, furi_string_get_cstr(error_msg));
+                furi_string_free(error_msg);
+            }
+
+            furi_string_free(current_path);
+            furi_string_free(new_group_path);
+
+            scene_manager_search_and_switch_to_previous_scene(app->scene_manager, QScene_Items);
+            consumed = true;
+        }
+    }
+
+    return consumed;
+}
+
+void scene_action_create_group_on_exit(void* context) {
+    App* app = context;
+    text_input_reset(app->text_input);
+}

+ 8 - 0
quac/scenes/scene_action_create_group.h

@@ -0,0 +1,8 @@
+#pragma once
+
+#include <gui/scene_manager.h>
+
+// For each scene, implement handler callbacks
+void scene_action_create_group_on_enter(void* context);
+bool scene_action_create_group_on_event(void* context, SceneManagerEvent event);
+void scene_action_create_group_on_exit(void* context);

+ 115 - 0
quac/scenes/scene_action_ir_list.c

@@ -0,0 +1,115 @@
+#include <furi.h>
+
+#include <gui/view_dispatcher.h>
+#include <gui/scene_manager.h>
+#include <gui/modules/submenu.h>
+#include <lib/toolbox/path.h>
+
+#include "quac.h"
+#include "scenes.h"
+#include "scene_action_ir_list.h"
+#include "../actions/action_ir_utils.h"
+
+#include <flipper_format/flipper_format.h>
+
+void scene_action_ir_list_callback(void* context, uint32_t index) {
+    App* app = context;
+    view_dispatcher_send_custom_event(app->view_dispatcher, index);
+}
+
+void scene_action_ir_list_on_enter(void* context) {
+    App* app = context;
+
+    Submenu* menu = app->sub_menu;
+    submenu_reset(menu);
+
+    // Our selected IR File is app->temp_str
+    submenu_set_header(menu, "Select IR Command");
+
+    // read the IR file and load the names of all of the commands
+    FuriString* name = furi_string_alloc();
+
+    uint32_t index = 0;
+    FlipperFormat* fff_data_file = flipper_format_file_alloc(app->storage);
+    if(flipper_format_file_open_existing(fff_data_file, furi_string_get_cstr(app->temp_str))) {
+        while(flipper_format_read_string(fff_data_file, "name", name)) {
+            submenu_add_item(
+                menu, furi_string_get_cstr(name), index, scene_action_ir_list_callback, app);
+            index++;
+        }
+    }
+
+    if(index == 0) {
+        FURI_LOG_E(TAG, "Failed to get commands from %s", furi_string_get_cstr(app->temp_str));
+    }
+
+    flipper_format_file_close(fff_data_file);
+    flipper_format_free(fff_data_file);
+    furi_string_free(name);
+
+    view_dispatcher_switch_to_view(app->view_dispatcher, QView_SubMenu);
+}
+
+bool scene_action_ir_list_on_event(void* context, SceneManagerEvent event) {
+    App* app = context;
+    bool consumed = false;
+    if(event.type == SceneManagerEventTypeCustom) {
+        consumed = true;
+        uint32_t index = event.event;
+        InfraredSignal* signal = infrared_utils_signal_alloc();
+
+        // extract that item as it's own file and place it "here", as defined by
+        // the currently selected_item
+        FuriString* name = furi_string_alloc(); // IR command name
+        FlipperFormat* fff_data_file = flipper_format_file_alloc(app->storage);
+        FuriString* file_name = furi_string_alloc(); // new IR file name
+
+        do {
+            if(!flipper_format_file_open_existing(
+                   fff_data_file, furi_string_get_cstr(app->temp_str))) {
+                FURI_LOG_E(TAG, "Failed to open %s", furi_string_get_cstr(app->temp_str));
+                break;
+            }
+            if(!infrared_utils_read_signal_at_index(fff_data_file, index, signal, name)) {
+                FURI_LOG_E(TAG, "Failed to read signal at %lu", index);
+                break;
+            }
+            FURI_LOG_I(TAG, "Read IR signal: %s", furi_string_get_cstr(name));
+            flipper_format_file_close(fff_data_file);
+
+            // generate the new path, based on current item's dir and new command name
+            Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+            path_extract_dirname(furi_string_get_cstr(item->path), file_name);
+            furi_string_cat_printf(file_name, "/%s.ir", furi_string_get_cstr(name));
+
+            FURI_LOG_I(TAG, "Writing new IR file: %s", furi_string_get_cstr(file_name));
+            if(!flipper_format_file_open_new(fff_data_file, furi_string_get_cstr(file_name))) {
+                FURI_LOG_E(TAG, "Error creating new file: %s", furi_string_get_cstr(file_name));
+                break;
+            }
+            if(!infrared_utils_write_signal(fff_data_file, signal, name)) {
+                FURI_LOG_E(TAG, "Failed to write signal!");
+                break;
+            }
+
+            // Import successful!
+            // Leave the user on this scene, in case they want to import
+            // more commands from this IR file
+            notification_message(app->notifications, &sequence_success);
+
+        } while(false);
+
+        // cleanup
+        flipper_format_file_close(fff_data_file);
+        flipper_format_free(fff_data_file);
+        furi_string_free(name);
+        furi_string_free(file_name);
+        infrared_utils_signal_free(signal);
+    }
+    return consumed;
+}
+
+void scene_action_ir_list_on_exit(void* context) {
+    App* app = context;
+    submenu_reset(app->sub_menu);
+}

+ 8 - 0
quac/scenes/scene_action_ir_list.h

@@ -0,0 +1,8 @@
+#pragma once
+
+#include <gui/scene_manager.h>
+
+// For each scene, implement handler callbacks
+void scene_action_ir_list_on_enter(void* context);
+bool scene_action_ir_list_on_event(void* context, SceneManagerEvent event);
+void scene_action_ir_list_on_exit(void* context);

+ 104 - 0
quac/scenes/scene_action_rename.c

@@ -0,0 +1,104 @@
+#include <furi.h>
+
+#include <gui/view_dispatcher.h>
+#include <gui/scene_manager.h>
+#include <gui/modules/text_input.h>
+
+#include "quac.h"
+#include "scenes.h"
+#include "scene_action_rename.h"
+#include "../actions/action.h"
+
+#include <lib/toolbox/path.h>
+
+enum {
+    SceneActionRenameEvent,
+};
+
+void scene_action_rename_callback(void* context) {
+    App* app = context;
+    view_dispatcher_send_custom_event(app->view_dispatcher, SceneActionRenameEvent);
+}
+
+void scene_action_rename_on_enter(void* context) {
+    App* app = context;
+    TextInput* text = app->text_input;
+
+    Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+
+    text_input_set_header_text(text, "Enter new name:");
+
+    FuriString* file_name = furi_string_alloc();
+    path_extract_filename_no_ext(furi_string_get_cstr(item->path), file_name);
+    strncpy(app->temp_cstr, furi_string_get_cstr(file_name), MAX_NAME_LEN);
+
+    text_input_set_result_callback(
+        text, scene_action_rename_callback, app, app->temp_cstr, MAX_NAME_LEN, false);
+
+    furi_string_free(file_name);
+    view_dispatcher_switch_to_view(app->view_dispatcher, QView_TextInput);
+}
+
+bool scene_action_rename_on_event(void* context, SceneManagerEvent event) {
+    App* app = context;
+    bool consumed = false;
+    if(event.type == SceneManagerEventTypeCustom) {
+        if(event.event == SceneActionRenameEvent) {
+            // FURI_LOG_I(TAG, "Attempting rename to %s", app->temp_cstr);
+            if(!strcmp(app->temp_cstr, "")) {
+                return false;
+            }
+            Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+            const char* old_path = furi_string_get_cstr(item->path);
+
+            FuriString* file_name = furi_string_alloc();
+            path_extract_filename(item->path, file_name, true);
+            // FURI_LOG_I(TAG, "Original name is %s", furi_string_get_cstr(file_name));
+            if(!furi_string_cmp_str(file_name, app->temp_cstr)) {
+                FURI_LOG_W(TAG, "Rename: File names are the same!");
+                furi_string_free(file_name);
+                return false;
+            }
+
+            // build the new name full path, with extension
+            FuriString* dir_name = furi_string_alloc();
+            path_extract_dirname(old_path, dir_name);
+            FuriString* new_path = furi_string_alloc_printf(
+                "%s/%s%s", furi_string_get_cstr(dir_name), app->temp_cstr, item->ext);
+
+            FURI_LOG_I(TAG, "Rename: %s to %s", old_path, furi_string_get_cstr(new_path));
+            FS_Error fs_result =
+                storage_common_rename(app->storage, old_path, furi_string_get_cstr(new_path));
+            if(fs_result == FSE_OK) {
+                ItemsView* new_items = item_get_items_view_from_path(app, dir_name);
+                item_items_view_free(app->items_view);
+                app->items_view = new_items;
+                // furi_string_swap(item->path, new_path);
+                // furi_string_set_str(item->name, app->temp_cstr);
+                // item_prettify_name(item->name);
+            } else {
+                FURI_LOG_E(
+                    TAG, "Rename file failed! %s", filesystem_api_error_get_desc(fs_result));
+                FuriString* error_msg = furi_string_alloc_printf(
+                    "Rename failed!\nError: %s", filesystem_api_error_get_desc(fs_result));
+                dialog_message_show_storage_error(app->dialog, furi_string_get_cstr(error_msg));
+                furi_string_free(error_msg);
+            }
+
+            scene_manager_search_and_switch_to_previous_scene(app->scene_manager, QScene_Items);
+
+            furi_string_free(dir_name);
+            furi_string_free(file_name);
+            furi_string_free(new_path);
+
+            consumed = true;
+        }
+    }
+
+    return consumed;
+}
+
+void scene_action_rename_on_exit(void* context) {
+    App* app = context;
+    text_input_reset(app->text_input);
+}

+ 8 - 0
quac/scenes/scene_action_rename.h

@@ -0,0 +1,8 @@
+#pragma once
+
+#include <gui/scene_manager.h>
+
+// For each scene, implement handler callbacks
+void scene_action_rename_on_enter(void* context);
+bool scene_action_rename_on_event(void* context, SceneManagerEvent event);
+void scene_action_rename_on_exit(void* context);

+ 307 - 0
quac/scenes/scene_action_settings.c

@@ -0,0 +1,307 @@
+#include <furi.h>
+
+#include <gui/view_dispatcher.h>
+#include <gui/scene_manager.h>
+#include <gui/modules/submenu.h>
+#include <lib/toolbox/path.h>
+
+#include "quac.h"
+#include "scenes.h"
+#include "scene_action_settings.h"
+#include "../actions/action.h"
+#include "quac_icons.h"
+
+// Define different settings per Action
+typedef enum {
+    ActionSettingsRename, // Rename file or folder
+    ActionSettingsDelete, // Delete file or folder on SDcard
+    ActionSettingsImport, // Copy a remote file into "current" folder
+    ActionSettingsCreateGroup, // Create new empty folder in "current" folder
+    ActionSettingsCreatePlaylist, // Turn this folder into a playlist
+    ActionSettingsAddToPlaylist, // Append a remote file to this playlist
+} ActionSettingsIndex;
+
+// Delete the file of the currently selected item
+// Update items_view list before returning so that UI is updated and correct
+bool scene_action_settings_delete(App* app) {
+    bool success = false;
+    Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+
+    DialogMessage* dialog = dialog_message_alloc();
+    dialog_message_set_header(dialog, "Delete?", 64, 0, AlignCenter, AlignTop);
+    FuriString* text = furi_string_alloc();
+    furi_string_printf(text, "%s\n\n%s", furi_string_get_cstr(item->name), "Are you sure?");
+    dialog_message_set_text(dialog, furi_string_get_cstr(text), 64, 18, AlignCenter, AlignTop);
+    dialog_message_set_buttons(dialog, "Cancel", NULL, "OK");
+    DialogMessageButton button = dialog_message_show(app->dialog, dialog);
+
+    if(button == DialogMessageButtonRight) {
+        FuriString* current_path = furi_string_alloc();
+        path_extract_dirname(furi_string_get_cstr(item->path), current_path);
+
+        FS_Error fs_result = storage_common_remove(app->storage, furi_string_get_cstr(item->path));
+        if(fs_result == FSE_OK) {
+            success = true;
+            FURI_LOG_I(TAG, "Deleted file: %s", furi_string_get_cstr(item->path));
+            // ItemsView* new_items = item_get_items_view_from_path(app, current_path);
+            // item_items_view_free(app->items_view);
+            // app->items_view = new_items;
+        } else {
+            FURI_LOG_E(
+                TAG, "Error deleting file! Error=%s", filesystem_api_error_get_desc(fs_result));
+            FuriString* error_msg = furi_string_alloc();
+            furi_string_printf(
+                error_msg, "Delete failed!\nError: %s", filesystem_api_error_get_desc(fs_result));
+            dialog_message_show_storage_error(app->dialog, furi_string_get_cstr(error_msg));
+            furi_string_free(error_msg);
+        }
+
+        furi_string_free(current_path);
+    } else {
+        // FURI_LOG_I(TAG, "Used cancelled Delete");
+    }
+
+    furi_string_free(text);
+    dialog_message_free(dialog);
+    return success;
+}
+
+static bool scene_action_settings_import_file_browser_callback(
+    FuriString* path,
+    void* context,
+    uint8_t** icon,
+    FuriString* item_name) {
+    UNUSED(context);
+    UNUSED(item_name);
+    char ext[MAX_EXT_LEN];
+    path_extract_extension(path, ext, MAX_EXT_LEN);
+    if(!strcmp(ext, ".sub")) {
+        memcpy(*icon, icon_get_data(&I_SubGHz_10px), 32); // TODO: find the right size!
+    } else if(!strcmp(ext, ".rfid")) {
+        memcpy(*icon, icon_get_data(&I_RFID_10px), 32);
+    } else if(!strcmp(ext, ".ir")) {
+        memcpy(*icon, icon_get_data(&I_IR_10px), 32);
+    } else if(!strcmp(ext, ".nfc")) {
+        memcpy(*icon, icon_get_data(&I_NFC_10px), 32);
+    } else if(!strcmp(ext, ".qpl")) {
+        memcpy(*icon, icon_get_data(&I_Playlist_10px), 32);
+    } else {
+        return false;
+    }
+    return true;
+}
+
+// Ask user for file to import from elsewhere on the SD card
+FuriString* scene_action_get_file_to_import_alloc(App* app) {
+    // Setup our file browser options
+    DialogsFileBrowserOptions fb_options;
+    dialog_file_browser_set_basic_options(&fb_options, "", NULL);
+    fb_options.base_path = STORAGE_EXT_PATH_PREFIX;
+    fb_options.skip_assets = true;
+    furi_string_set_str(app->temp_str, fb_options.base_path);
+    fb_options.item_loader_callback = scene_action_settings_import_file_browser_callback;
+    fb_options.item_loader_context = app;
+
+    FuriString* full_path = NULL;
+    if(dialog_file_browser_show(app->dialog, app->temp_str, app->temp_str, &fb_options)) {
+        full_path = furi_string_alloc_set(app->temp_str);
+    }
+    return full_path;
+}
+
+// Import a file from elsewhere on the SD card
+// Update items_view list before returning so that UI is updated and correct
+bool scene_action_settings_import(App* app) {
+    bool success = false;
+    FuriString* current_path = furi_string_alloc();
+    if(app->selected_item != EMPTY_ACTION_INDEX) {
+        Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+        path_extract_dirname(furi_string_get_cstr(item->path), current_path);
+    } else {
+        furi_string_set(current_path, app->items_view->path);
+    }
+
+    // Setup our file browser options
+    DialogsFileBrowserOptions fb_options;
+    dialog_file_browser_set_basic_options(&fb_options, "", NULL);
+    fb_options.base_path = furi_string_get_cstr(current_path);
+    fb_options.skip_assets = true;
+    furi_string_set_str(app->temp_str, fb_options.base_path);
+    fb_options.item_loader_callback = scene_action_settings_import_file_browser_callback;
+    fb_options.item_loader_context = app;
+
+    if(dialog_file_browser_show(app->dialog, app->temp_str, app->temp_str, &fb_options)) {
+        // FURI_LOG_I(TAG, "Selected file is %s", furi_string_get_cstr(app->temp_str));
+        // TODO: this should be a method
+        FuriString* file_name = furi_string_alloc();
+        path_extract_filename(app->temp_str, file_name, false);
+        // FURI_LOG_I(TAG, "Importing file %s", furi_string_get_cstr(file_name));
+        FuriString* full_path;
+        full_path = furi_string_alloc_printf(
+            "%s/%s", furi_string_get_cstr(current_path), furi_string_get_cstr(file_name));
+        // FURI_LOG_I(TAG, "New path is %s", furi_string_get_cstr(full_path));
+
+        FS_Error fs_result = storage_common_copy(
+            app->storage, furi_string_get_cstr(app->temp_str), furi_string_get_cstr(full_path));
+        if(fs_result == FSE_OK) {
+            success = true;
+            // FURI_LOG_I(TAG, "File copied / updating items view list");
+            // ItemsView* new_items = item_get_items_view_from_path(app, current_path);
+            // item_items_view_free(app->items_view);
+            // app->items_view = new_items;
+        } else {
+            FURI_LOG_E(TAG, "File copy failed! %s", filesystem_api_error_get_desc(fs_result));
+            FuriString* error_msg = furi_string_alloc_printf(
+                "File copy failed!\nError: %s", filesystem_api_error_get_desc(fs_result));
+            dialog_message_show_storage_error(app->dialog, furi_string_get_cstr(error_msg));
+            furi_string_free(error_msg);
+        }
+        furi_string_free(file_name);
+        furi_string_free(full_path);
+    } else {
+        // FURI_LOG_I(TAG, "User cancelled");
+    }
+
+    furi_string_free(current_path);
+    return success;
+}
+
+// Prompt user for the name of the new Group
+// Update items_view list before returning so that UI is updated and correct
+bool scene_action_settings_create_group(App* app) {
+    UNUSED(app);
+    return false;
+}
+
+void scene_action_settings_callback(void* context, uint32_t index) {
+    App* app = context;
+    view_dispatcher_send_custom_event(app->view_dispatcher, index);
+}
+
+void scene_action_settings_on_enter(void* context) {
+    App* app = context;
+
+    Submenu* menu = app->sub_menu;
+    submenu_reset(menu);
+
+    if(app->selected_item >= 0) {
+        Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+        submenu_set_header(menu, furi_string_get_cstr(item->name));
+
+        submenu_add_item(
+            menu, "Rename", ActionSettingsRename, scene_action_settings_callback, app);
+        submenu_add_item(
+            menu, "Delete", ActionSettingsDelete, scene_action_settings_callback, app);
+    } else {
+        submenu_set_header(menu, furi_string_get_cstr(app->items_view->name));
+    }
+
+    submenu_add_item(
+        menu, "Import Here", ActionSettingsImport, scene_action_settings_callback, app);
+    submenu_add_item(
+        menu, "Create Group", ActionSettingsCreateGroup, scene_action_settings_callback, app);
+
+    view_dispatcher_switch_to_view(app->view_dispatcher, QView_SubMenu);
+}
+
+bool scene_action_settings_on_event(void* context, SceneManagerEvent event) {
+    App* app = context;
+    bool consumed = false;
+    if(event.type == SceneManagerEventTypeCustom) {
+        switch(event.event) {
+        case ActionSettingsRename:
+            consumed = true;
+            scene_manager_next_scene(app->scene_manager, QScene_ActionRename);
+            break;
+        case ActionSettingsDelete:
+            consumed = true;
+            if(scene_action_settings_delete(app)) {
+                scene_manager_previous_scene(app->scene_manager);
+            }
+            break;
+        case ActionSettingsImport:
+            consumed = true;
+            // get the filename to import
+            FuriString* import_file = scene_action_get_file_to_import_alloc(app);
+            if(import_file) {
+                FURI_LOG_I(TAG, "Importing %s", furi_string_get_cstr(import_file));
+                // if it's a .ir file, switch to a scene that lets user pick the command from the file
+                // only if there's more than one command in the file. then copy that relevant chunk
+                // to the local directory
+                char ext[MAX_EXT_LEN] = {0};
+
+                path_extract_extension(import_file, ext, MAX_EXT_LEN);
+                if(!strcmp(ext, ".ir")) {
+                    FURI_LOG_I(TAG, "Loading ir file %s", furi_string_get_cstr(app->temp_str));
+                    // load scene that takes filename and lists all commands
+                    // the scene should write the new file, eh?
+                    scene_manager_next_scene(app->scene_manager, QScene_ActionIRList);
+                } else {
+                    // just copy the file here
+                    FuriString* current_path = furi_string_alloc();
+                    if(app->selected_item != EMPTY_ACTION_INDEX) {
+                        Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+                        path_extract_dirname(furi_string_get_cstr(item->path), current_path);
+                    } else {
+                        furi_string_set(current_path, app->items_view->path);
+                    }
+                    // TODO: this should be a method
+                    FuriString* file_name = furi_string_alloc();
+                    path_extract_filename(import_file, file_name, false);
+                    // FURI_LOG_I(TAG, "Importing file %s", furi_string_get_cstr(file_name));
+                    FuriString* full_path;
+                    full_path = furi_string_alloc_printf(
+                        "%s/%s",
+                        furi_string_get_cstr(current_path),
+                        furi_string_get_cstr(file_name));
+                    // FURI_LOG_I(TAG, "New path is %s", furi_string_get_cstr(full_path));
+
+                    FURI_LOG_I(
+                        TAG,
+                        "Copy: %s to %s",
+                        furi_string_get_cstr(import_file),
+                        furi_string_get_cstr(full_path));
+                    FS_Error fs_result = storage_common_copy(
+                        app->storage,
+                        furi_string_get_cstr(import_file),
+                        furi_string_get_cstr(full_path));
+                    if(fs_result != FSE_OK) {
+                        FURI_LOG_E(
+                            TAG, "Copy file failed! %s", filesystem_api_error_get_desc(fs_result));
+                        FuriString* error_msg = furi_string_alloc_printf(
+                            "Copy failed!\nError: %s", filesystem_api_error_get_desc(fs_result));
+                        dialog_message_show_storage_error(
+                            app->dialog, furi_string_get_cstr(error_msg));
+                        furi_string_free(error_msg);
+                    }
+                    furi_string_free(file_name);
+                    furi_string_free(full_path);
+                }
+                furi_string_free(import_file);
+            } else {
+                scene_manager_previous_scene(app->scene_manager);
+            }
+
+            // if(scene_action_settings_import(app)) {
+            //     scene_manager_previous_scene(app->scene_manager);
+            // }
+            break;
+        case ActionSettingsCreateGroup:
+            consumed = true;
+            scene_manager_next_scene(app->scene_manager, QScene_ActionCreateGroup);
+            break;
+        }
+    }
+
+    return consumed;
+}
+
+void scene_action_settings_on_exit(void* context) {
+    App* app = context;
+    submenu_reset(app->sub_menu);
+
+    // Rebuild our list on exit, to pick up any renames
+    ItemsView* new_items = item_get_items_view_from_path(app, app->items_view->path);
+    item_items_view_free(app->items_view);
+    app->items_view = new_items;
+}

+ 8 - 0
quac/scenes/scene_action_settings.h

@@ -0,0 +1,8 @@
+#pragma once
+
+#include <gui/scene_manager.h>
+
+// For each scene, implement handler callbacks
+void scene_action_settings_on_enter(void* context);
+bool scene_action_settings_on_event(void* context, SceneManagerEvent event);
+void scene_action_settings_on_exit(void* context);

+ 183 - 0
quac/scenes/scene_items.c

@@ -0,0 +1,183 @@
+#include <furi.h>
+
+#include <gui/view_dispatcher.h>
+#include <gui/scene_manager.h>
+#include <gui/modules/dialog_ex.h>
+
+#include <notification/notification_messages.h>
+
+#include "quac.h"
+#include "scenes.h"
+#include "scene_items.h"
+#include "../actions/action.h"
+#include "../views/action_menu.h"
+
+#include <lib/toolbox/path.h>
+
+static const ActionMenuItemType ItemToMenuItem[] = {
+    [Item_SubGhz] = ActionMenuItemTypeSubGHz,
+    [Item_RFID] = ActionMenuItemTypeRFID,
+    [Item_IR] = ActionMenuItemTypeIR,
+    [Item_NFC] = ActionMenuItemTypeNFC,
+    [Item_Playlist] = ActionMenuItemTypePlaylist,
+    [Item_Group] = ActionMenuItemTypeGroup,
+    [Item_Settings] = ActionMenuItemTypeSettings,
+    [Item_Unknown] = ActionMenuItemTypeUnknown,
+};
+
+void scene_items_item_callback(void* context, int32_t index, InputType type) {
+    App* app = context;
+
+    // FURI_LOG_I(TAG, "scene_items callback, type == %s", input_get_type_name(type));
+
+    if(type == InputTypeShort) {
+        app->selected_item = index;
+        view_dispatcher_send_custom_event(app->view_dispatcher, Event_ButtonPressed);
+    } else if(type == InputTypeLong) {
+        app->selected_item = index;
+        view_dispatcher_send_custom_event(app->view_dispatcher, Event_ButtonPressedLong);
+    } else {
+        // do nothing
+    }
+}
+
+// For each scene, implement handler callbacks
+void scene_items_on_enter(void* context) {
+    App* app = context;
+
+    ActionMenu* menu = app->action_menu;
+    action_menu_reset(menu);
+    if(app->settings.layout == QUAC_APP_LANDSCAPE)
+        action_menu_set_layout(menu, ActionMenuLayoutLandscape);
+    else
+        action_menu_set_layout(menu, ActionMenuLayoutPortrait);
+    action_menu_set_show_icons(menu, app->settings.show_icons);
+    action_menu_set_show_headers(menu, app->settings.show_headers);
+
+    ItemsView* items_view = app->items_view;
+    FURI_LOG_I(
+        TAG, "Generating scene: [depth=%d] %s", app->depth, furi_string_get_cstr(items_view->path));
+
+    action_menu_set_header(menu, furi_string_get_cstr(items_view->name));
+
+    size_t item_view_size = ItemArray_size(items_view->items);
+    if(item_view_size > 0) {
+        ItemArray_it_t iter;
+        int32_t index = 0;
+        for(ItemArray_it(iter, items_view->items); !ItemArray_end_p(iter);
+            ItemArray_next(iter), ++index) {
+            const char* label = furi_string_get_cstr(ItemArray_cref(iter)->name);
+            ActionMenuItemType type = ItemToMenuItem[ItemArray_cref(iter)->type];
+            action_menu_add_item(menu, label, index, scene_items_item_callback, type, app);
+        }
+    } else {
+        FURI_LOG_W(TAG, "No items for: %s", furi_string_get_cstr(items_view->path));
+        // Add a bogus item - this lets the user still access the Action menu to import, etc
+        action_menu_add_item(
+            menu,
+            "<Empty>",
+            EMPTY_ACTION_INDEX,
+            scene_items_item_callback,
+            ActionMenuItemTypeGroup,
+            app);
+    }
+
+    // Always add the "Settings" item at the end of our list - but only at top level!
+    if(app->depth == 0) {
+        action_menu_add_item(
+            menu,
+            "Settings",
+            item_view_size, // last item!
+            scene_items_item_callback,
+            ActionMenuItemTypeSettings,
+            app);
+    }
+
+    view_dispatcher_switch_to_view(app->view_dispatcher, QView_ActionMenu);
+}
+bool scene_items_on_event(void* context, SceneManagerEvent event) {
+    App* app = context;
+    bool consumed = false;
+
+    switch(event.type) {
+    case SceneManagerEventTypeCustom:
+        if(event.event == Event_ButtonPressed && app->selected_item != EMPTY_ACTION_INDEX) {
+            consumed = true;
+            // FURI_LOG_I(TAG, "button pressed is %d", app->selected_item);
+            if(app->selected_item < (int)ItemArray_size(app->items_view->items)) {
+                Item* item = ItemArray_get(app->items_view->items, app->selected_item);
+                if(item->type == Item_Group) {
+                    app->depth++;
+                    ItemsView* new_items = item_get_items_view_from_path(app, item->path);
+                    item_items_view_free(app->items_view);
+                    app->items_view = new_items;
+                    scene_manager_next_scene(app->scene_manager, QScene_Items);
+                } else {
+                    FURI_LOG_I(
+                        TAG, "Initiating item action: %s", furi_string_get_cstr(item->name));
+
+                    // LED goes blinky blinky
+                    App* app = context;
+                    notification_message(app->notifications, &sequence_blink_start_blue);
+
+                    // Prepare error string for action calls
+                    FuriString* error;
+                    error = furi_string_alloc();
+
+                    action_tx(app, item, error);
+
+                    if(furi_string_size(error)) {
+                        FURI_LOG_E(TAG, furi_string_get_cstr(error));
+                        // Fire up the LED and vibrate!
+                        notification_message(app->notifications, &sequence_error);
+                    }
+
+                    furi_string_free(error);
+
+                    // Turn off LED light
+                    notification_message(app->notifications, &sequence_blink_stop);
+                }
+            } else {
+                // FURI_LOG_I(TAG, "Selected Settings!");
+                // TODO: Do we need to free this current items_view??
+                scene_manager_next_scene(app->scene_manager, QScene_Settings);
+            }
+        } else if(event.event == Event_ButtonPressedLong) {
+            if(app->selected_item < (int)ItemArray_size(app->items_view->items)) {
+                consumed = true;
+                scene_manager_next_scene(app->scene_manager, QScene_ActionSettings);
+            }
+        }
+        break;
+    case SceneManagerEventTypeBack:
+        // FURI_LOG_I(TAG, "Back button pressed!");
+        consumed = false; // Ensure Back event continues to propagate
+        if(app->depth > 0) {
+            // take our current ItemsView path, and go back up a level
+            FuriString* parent_path;
+            parent_path = furi_string_alloc();
+            path_extract_dirname(furi_string_get_cstr(app->items_view->path), parent_path);
+
+            app->depth--;
+            ItemsView* new_items = item_get_items_view_from_path(app, parent_path);
+            item_items_view_free(app->items_view);
+            app->items_view = new_items;
+
+            furi_string_free(parent_path);
+        } else {
+            // FURI_LOG_I(TAG, "At the root level!");
+        }
+        break;
+    default:
+        FURI_LOG_I(TAG, "Custom event not handled");
+        break;
+    }
+    // FURI_LOG_I(TAG, "Generic event not handled");
+    return consumed;
+}
+
+void scene_items_on_exit(void* context) {
+    App* app = context;
+    ActionMenu* menu = app->action_menu;
+    action_menu_reset(menu);
+}

+ 9 - 0
quac/scenes/scene_items.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include <gui/scene_manager.h>
+
+void scene_items_item_callback(void* context, int32_t index, InputType type);
+// For each scene, implement handler callbacks
+void scene_items_on_enter(void* context);
+bool scene_items_on_event(void* context, SceneManagerEvent event);
+void scene_items_on_exit(void* context);

+ 221 - 0
quac/scenes/scene_settings.c

@@ -0,0 +1,221 @@
+#include <furi.h>
+
+#include <gui/view_dispatcher.h>
+#include <gui/scene_manager.h>
+#include <gui/modules/variable_item_list.h>
+#include <toolbox/value_index.h>
+
+#include "quac.h"
+#include "scenes.h"
+#include "scene_settings.h"
+#include "../actions/action.h"
+#include "../views/action_menu.h"
+#include "../quac_settings.h"
+
+#include <lib/toolbox/path.h>
+
+typedef enum {
+    SceneSettingsLayout,
+    SceneSettingsIcons,
+    SceneSettingsHeaders,
+    SceneSettingsRFIDDuration,
+    SceneSettingsNFCDuration,
+    SceneSettingsSubGHzRepeat,
+    SceneSettingsSubGHzExtAnt,
+    SceneSettingsHidden,
+    SceneSettingsAbout
+} SceneSettingsIndex;
+
+static const char* const layout_text[2] = {"Vert", "Horiz"};
+static const uint32_t layout_value[2] = {QUAC_APP_PORTRAIT, QUAC_APP_LANDSCAPE};
+
+static const char* const show_offon_text[2] = {"OFF", "ON"};
+static const uint32_t show_offon_value[2] = {false, true};
+
+#define V_DURATION_COUNT 8
+static const char* const duration_text[V_DURATION_COUNT] = {
+    "500 ms",
+    "1 sec",
+    "1.5 sec",
+    "2 sec",
+    "2.5 sec",
+    "3 sec",
+    "5 sec",
+    "10 sec",
+};
+static const uint32_t duration_value[V_DURATION_COUNT] = {
+    500,
+    1000,
+    1500,
+    2000,
+    2500,
+    3000,
+    5000,
+    10000,
+};
+
+#define V_REPEAT_COUNT 9
+static const char* const repeat_text[V_REPEAT_COUNT] = {
+    "1",
+    "2",
+    "3",
+    "5",
+    "8",
+    "10", // default
+    "15",
+    "20",
+    "50"};
+static const uint32_t repeat_value[V_REPEAT_COUNT] = {1, 2, 3, 5, 8, 10, 15, 20, 50};
+
+static const char* const subghz_ext_text[2] = {"Disabled", "Enabled"};
+static const uint32_t subghz_ext_value[2] = {false, true};
+
+static void scene_settings_layout_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, layout_text[index]);
+    app->settings.layout = layout_value[index];
+}
+
+static void scene_settings_show_icons_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, show_offon_text[index]);
+    app->settings.show_icons = show_offon_value[index];
+}
+
+static void scene_settings_show_headers_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, show_offon_text[index]);
+    app->settings.show_headers = show_offon_value[index];
+}
+
+static void scene_settings_rfid_duration_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, duration_text[index]);
+    app->settings.rfid_duration = duration_value[index];
+}
+
+static void scene_settings_nfc_duration_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, duration_text[index]);
+    app->settings.nfc_duration = duration_value[index];
+}
+
+static void scene_settings_subghz_repeat_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, repeat_text[index]);
+    app->settings.subghz_repeat = repeat_value[index];
+}
+
+static void scene_settings_subghz_ext_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, subghz_ext_text[index]);
+    app->settings.subghz_use_ext_antenna = subghz_ext_value[index];
+}
+
+static void scene_settings_show_hidden_changed(VariableItem* item) {
+    App* app = variable_item_get_context(item);
+    uint8_t index = variable_item_get_current_value_index(item);
+    variable_item_set_current_value_text(item, show_offon_text[index]);
+    app->settings.show_hidden = show_offon_value[index];
+}
+
+static void scene_settings_enter_callback(void* context, uint32_t index) {
+    App* app = context;
+    view_dispatcher_send_custom_event(app->view_dispatcher, index);
+}
+
+// For each scene, implement handler callbacks
+void scene_settings_on_enter(void* context) {
+    App* app = context;
+
+    VariableItemList* vil = app->vil_settings;
+    variable_item_list_reset(vil);
+
+    VariableItem* item;
+    uint8_t value_index;
+
+    item = variable_item_list_add(vil, "Layout", 2, scene_settings_layout_changed, app);
+    value_index = value_index_uint32(app->settings.layout, layout_value, 2);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, layout_text[value_index]);
+
+    item = variable_item_list_add(vil, "Show Icons", 2, scene_settings_show_icons_changed, app);
+    value_index = value_index_uint32(app->settings.show_icons, show_offon_value, 2);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, show_offon_text[value_index]);
+
+    item =
+        variable_item_list_add(vil, "Show Headers", 2, scene_settings_show_headers_changed, app);
+    value_index = value_index_uint32(app->settings.show_headers, show_offon_value, 2);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, show_offon_text[value_index]);
+
+    item = variable_item_list_add(
+        vil, "RFID Duration", V_DURATION_COUNT, scene_settings_rfid_duration_changed, app);
+    value_index =
+        value_index_uint32(app->settings.rfid_duration, duration_value, V_DURATION_COUNT);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, duration_text[value_index]);
+
+    item = variable_item_list_add(
+        vil, "NFC Duration", V_DURATION_COUNT, scene_settings_nfc_duration_changed, app);
+    value_index = value_index_uint32(app->settings.nfc_duration, duration_value, V_DURATION_COUNT);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, duration_text[value_index]);
+
+    item = variable_item_list_add(
+        vil, "SubGHz Repeat", V_REPEAT_COUNT, scene_settings_subghz_repeat_changed, app);
+    value_index = value_index_uint32(app->settings.subghz_repeat, repeat_value, V_REPEAT_COUNT);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, repeat_text[value_index]);
+
+    item =
+        variable_item_list_add(vil, "SubGHz Ext Ant", 2, scene_settings_subghz_ext_changed, app);
+    value_index = value_index_uint32(app->settings.subghz_use_ext_antenna, subghz_ext_value, 2);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, subghz_ext_text[value_index]);
+
+    item = variable_item_list_add(vil, "Show Hidden", 2, scene_settings_show_hidden_changed, app);
+    value_index = value_index_uint32(app->settings.show_hidden, show_offon_value, 2);
+    variable_item_set_current_value_index(item, value_index);
+    variable_item_set_current_value_text(item, show_offon_text[value_index]);
+
+    // Last item is always "About"
+    item = variable_item_list_add(vil, "About", 1, NULL, NULL);
+    variable_item_list_set_enter_callback(vil, scene_settings_enter_callback, app);
+
+    view_dispatcher_switch_to_view(app->view_dispatcher, QView_Settings);
+}
+
+bool scene_settings_on_event(void* context, SceneManagerEvent event) {
+    App* app = context;
+    bool consumed = false;
+
+    if(event.type == SceneManagerEventTypeCustom) {
+        switch(event.event) {
+        case SceneSettingsAbout:
+            consumed = true;
+            scene_manager_next_scene(app->scene_manager, QScene_About);
+            break;
+        default:
+            break;
+        }
+    }
+
+    return consumed;
+}
+
+void scene_settings_on_exit(void* context) {
+    App* app = context;
+    VariableItemList* vil = app->vil_settings;
+    variable_item_list_reset(vil);
+
+    quac_save_settings(app);
+}

+ 10 - 0
quac/scenes/scene_settings.h

@@ -0,0 +1,10 @@
+#pragma once
+
+#include <gui/scene_manager.h>
+
+// void scene_settings_item_callback(void* context, int32_t index, InputType type);
+
+// For each scene, implement handler callbacks
+void scene_settings_on_enter(void* context);
+bool scene_settings_on_event(void* context, SceneManagerEvent event);
+void scene_settings_on_exit(void* context);

+ 55 - 0
quac/scenes/scenes.c

@@ -0,0 +1,55 @@
+
+
+#include "quac.h"
+#include "scenes.h"
+#include "scene_items.h"
+#include "scene_settings.h"
+#include "scene_action_settings.h"
+#include "scene_action_rename.h"
+#include "scene_action_create_group.h"
+#include "scene_action_ir_list.h"
+#include "scene_about.h"
+
+// define handler callbacks - order must match appScenes enum!
+void (*const app_on_enter_handlers[])(void* context) = {
+    scene_items_on_enter,
+    scene_settings_on_enter,
+    scene_action_settings_on_enter,
+    scene_action_rename_on_enter,
+    scene_action_create_group_on_enter,
+    scene_action_ir_list_on_enter,
+    scene_about_on_enter,
+};
+bool (*const app_on_event_handlers[])(void* context, SceneManagerEvent event) = {
+    scene_items_on_event,
+    scene_settings_on_event,
+    scene_action_settings_on_event,
+    scene_action_rename_on_event,
+    scene_action_create_group_on_event,
+    scene_action_ir_list_on_event,
+    scene_about_on_event,
+};
+void (*const app_on_exit_handlers[])(void* context) = {
+    scene_items_on_exit,
+    scene_settings_on_exit,
+    scene_action_settings_on_exit,
+    scene_action_rename_on_exit,
+    scene_action_create_group_on_exit,
+    scene_action_ir_list_on_exit,
+    scene_about_on_exit,
+};
+
+const SceneManagerHandlers app_scene_handlers = {
+    .on_enter_handlers = app_on_enter_handlers,
+    .on_event_handlers = app_on_event_handlers,
+    .on_exit_handlers = app_on_exit_handlers,
+    .scene_num = QScene_count};
+
+bool app_scene_custom_callback(void* context, uint32_t custom_event_id) {
+    App* app = context;
+    return scene_manager_handle_custom_event(app->scene_manager, custom_event_id);
+}
+bool app_back_event_callback(void* context) {
+    App* app = context;
+    return scene_manager_handle_back_event(app->scene_manager);
+}

+ 34 - 0
quac/scenes/scenes.h

@@ -0,0 +1,34 @@
+#pragma once
+
+typedef enum {
+    QScene_Items,
+    QScene_Settings,
+    QScene_ActionSettings,
+    QScene_ActionRename,
+    QScene_ActionCreateGroup,
+    QScene_ActionIRList,
+    QScene_About,
+    QScene_count
+} appScenes;
+
+typedef enum {
+    QView_ActionMenu, // main UI
+    QView_Settings, // Variable Item List for App Settings
+    QView_SubMenu, // Action: Rename, Delete, Import, IR List
+    QView_TextInput, // Action: Rename, Create Group
+    QView_Popup, // About screen
+} appView;
+
+typedef enum {
+    Event_DeviceSelected,
+    Event_ButtonPressed,
+    Event_ButtonPressedLong
+} AppCustomEvents;
+
+extern void (*const app_on_enter_handlers[])(void*);
+extern bool (*const app_on_event_handlers[])(void*, SceneManagerEvent);
+extern void (*const app_on_exit_handlers[])(void*);
+extern const SceneManagerHandlers app_scene_handlers;
+
+extern bool app_scene_custom_callback(void* context, uint32_t custom_event_id);
+extern bool app_back_event_callback(void* context);

BIN
quac/screenshots/screenshot_1.png


BIN
quac/screenshots/screenshot_1_orig.png


BIN
quac/screenshots/screenshot_2.png


BIN
quac/screenshots/screenshot_2_orig.png


BIN
quac/screenshots/screenshot_3.png


BIN
quac/screenshots/screenshot_3_orig.png


BIN
quac/screenshots/screenshot_4.png


BIN
quac/screenshots/screenshot_4_90.png


BIN
quac/screenshots/screenshot_4_orig.png


+ 528 - 0
quac/views/action_menu.c

@@ -0,0 +1,528 @@
+#include "action_menu.h"
+
+#include <gui/canvas.h>
+#include <gui/elements.h>
+#include <input/input.h>
+
+#include <furi.h>
+
+#include <stdint.h>
+#include <m-array.h>
+
+#include "quac_icons.h"
+
+#define ITEM_FIRST_OFFSET 17
+#define ITEM_NEXT_OFFSET 4
+#define ITEM_HEIGHT 14
+#define ITEM_WIDTH 64
+#define BUTTONS_PER_SCREEN 6
+
+#define ITEMS_PER_SCREEN_LANDSCAPE 3
+#define ITEMS_PER_SCREEN_PORTRAIT 6
+
+static const Icon* ActionMenuIcons[] = {
+    [ActionMenuItemTypeSubGHz] = &I_SubGHz_10px,
+    [ActionMenuItemTypeRFID] = &I_RFID_10px,
+    [ActionMenuItemTypeIR] = &I_IR_10px,
+    [ActionMenuItemTypeNFC] = &I_NFC_10px,
+    [ActionMenuItemTypePlaylist] = &I_Playlist_10px,
+    [ActionMenuItemTypeGroup] = &I_Directory_10px,
+    [ActionMenuItemTypeSettings] = &I_Settings_10px,
+    [ActionMenuItemTypeUnknown] = &I_Unknown_10px,
+};
+
+struct ActionMenuItem {
+    const char* label;
+    uint32_t index;
+    ActionMenuItemCallback callback;
+    ActionMenuItemType type;
+    void* callback_context;
+};
+
+ARRAY_DEF(ActionMenuItemArray, ActionMenuItem, M_POD_OPLIST);
+#define M_OPL_ActionMenuItemArray_t() ARRAY_OPLIST(ActionMenuItemArray, M_POD_OPLIST)
+
+struct ActionMenu {
+    View* view;
+};
+
+typedef struct {
+    ActionMenuItemArray_t items;
+    size_t position;
+    size_t window_position;
+    FuriString* header;
+    ActionMenuLayout layout;
+    bool show_icons;
+    bool show_headers;
+} ActionMenuModel;
+
+static void action_menu_draw_landscape(Canvas* canvas, ActionMenuModel* model) {
+    const uint8_t item_height = 16;
+    uint8_t item_width = canvas_width(canvas) - 5; // space for scrollbar
+
+    const bool have_header = furi_string_size(model->header) && model->show_headers;
+
+    canvas_clear(canvas);
+    if(have_header) {
+        canvas_set_font(canvas, FontPrimary);
+        canvas_draw_str(canvas, 4, 11, furi_string_get_cstr(model->header));
+    }
+    canvas_set_font(canvas, FontSecondary);
+
+    size_t position = 0;
+    const size_t items_on_screen = ITEMS_PER_SCREEN_LANDSCAPE + (have_header ? 0 : 1);
+    uint8_t y_offset = have_header ? 16 : 0;
+    const size_t x_txt_start = model->show_icons ? 18 : 4;
+
+    ActionMenuItemArray_it_t it;
+    for(ActionMenuItemArray_it(it, model->items); !ActionMenuItemArray_end_p(it);
+        ActionMenuItemArray_next(it)) {
+        const size_t item_position = position - model->window_position;
+
+        if(item_position < items_on_screen) {
+            if(position == model->position) {
+                canvas_set_color(canvas, ColorBlack);
+                elements_slightly_rounded_box(
+                    canvas,
+                    0,
+                    y_offset + (item_position * item_height) + 1,
+                    item_width,
+                    item_height - 2);
+                canvas_set_color(canvas, ColorWhite);
+            } else {
+                canvas_set_color(canvas, ColorBlack);
+            }
+
+            const ActionMenuItem* item = ActionMenuItemArray_cref(it);
+            if(model->show_icons) {
+                canvas_draw_icon(
+                    canvas,
+                    4,
+                    y_offset + (item_position * item_height) + 3,
+                    ActionMenuIcons[item->type]);
+            }
+
+            FuriString* disp_str;
+            disp_str = furi_string_alloc_set(item->label);
+            elements_string_fit_width(canvas, disp_str, item_width - (6 * 2));
+
+            canvas_draw_str(
+                canvas,
+                x_txt_start, // 6
+                y_offset + (item_position * item_height) + item_height - 4,
+                furi_string_get_cstr(disp_str));
+            furi_string_free(disp_str);
+        }
+        position++;
+    }
+
+    elements_scrollbar(canvas, model->position, ActionMenuItemArray_size(model->items));
+}
+
+static void action_menu_draw_portrait(Canvas* canvas, ActionMenuModel* model) {
+    const bool have_header = furi_string_size(model->header) && model->show_headers;
+    const size_t items_per_screen = have_header ? ITEMS_PER_SCREEN_PORTRAIT :
+                                                  ITEMS_PER_SCREEN_PORTRAIT + 1;
+    const size_t active_screen = model->position / items_per_screen;
+    const size_t items_size = ActionMenuItemArray_size(model->items);
+    const size_t max_screen = items_size ? (items_size - 1) / items_per_screen : 0;
+
+    canvas_clear(canvas);
+
+    // Draw up/down arrows, as needed
+    if(active_screen > 0) {
+        canvas_draw_icon(canvas, 28, 1, &I_ArrowUp_8x4);
+    }
+    if(max_screen > active_screen) {
+        canvas_draw_icon(canvas, 28, 123, &I_ArrowDown_8x4);
+    }
+
+    if(have_header) {
+        canvas_set_font(canvas, FontPrimary);
+        elements_string_fit_width(canvas, model->header, ITEM_WIDTH - 6);
+        canvas_draw_str_aligned(
+            canvas, 32, 10, AlignCenter, AlignCenter, furi_string_get_cstr(model->header));
+    }
+    canvas_set_font(canvas, FontSecondary);
+
+    size_t item_position = 0;
+    const size_t x_txt_start = model->show_icons ? 16 : 4;
+    const size_t y_offset = have_header ? ITEM_FIRST_OFFSET : 6;
+    const size_t item_next_offset = have_header ? ITEM_NEXT_OFFSET : ITEM_NEXT_OFFSET - 1;
+
+    ActionMenuItemArray_it_t it;
+    for(ActionMenuItemArray_it(it, model->items); !ActionMenuItemArray_end_p(it);
+        ActionMenuItemArray_next(it), ++item_position) {
+        if(active_screen == (item_position / items_per_screen)) {
+            uint8_t position_offset = item_position % items_per_screen;
+            bool selected = item_position == model->position;
+
+            // draw the item
+            uint8_t item_y = y_offset + (position_offset * (ITEM_HEIGHT + item_next_offset));
+
+            canvas_set_color(canvas, ColorBlack);
+
+            if(selected) {
+                // Same as elements_slightly_rounded_box with radius of 5
+                canvas_draw_rbox(canvas, 0, item_y, ITEM_WIDTH, ITEM_HEIGHT, 1);
+                canvas_set_color(canvas, ColorWhite);
+            } else {
+                canvas_draw_rframe(canvas, 0, item_y, ITEM_WIDTH, ITEM_HEIGHT, 1);
+            }
+
+            const ActionMenuItem* item = ActionMenuItemArray_cref(it);
+            if(model->show_icons) {
+                canvas_draw_icon(canvas, 3, item_y + 2, ActionMenuIcons[item->type]);
+            }
+
+            FuriString* disp_str;
+            disp_str = furi_string_alloc_set(item->label);
+            elements_string_fit_width(canvas, disp_str, ITEM_WIDTH - 6);
+
+            canvas_draw_str(
+                canvas,
+                x_txt_start,
+                item_y + (ITEM_HEIGHT / 2) + 3,
+                furi_string_get_cstr(disp_str));
+            furi_string_free(disp_str);
+        }
+    }
+}
+
+static void action_menu_view_draw_callback(Canvas* canvas, void* context) {
+    furi_assert(canvas);
+    ActionMenuModel* model = (ActionMenuModel*)context;
+
+    if(model->layout == ActionMenuLayoutLandscape) {
+        action_menu_draw_landscape(canvas, model);
+    } else {
+        action_menu_draw_portrait(canvas, model);
+    }
+}
+
+static void action_menu_process_up(ActionMenu* action_menu) {
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            const size_t items_size = ActionMenuItemArray_size(model->items);
+            if(model->layout == ActionMenuLayoutPortrait) {
+                if(model->position > 0) {
+                    model->position--;
+                } else {
+                    model->position = items_size - 1;
+                }
+            } else {
+                const size_t items_on_screen = furi_string_empty(model->header) ? 4 : 3;
+                if(model->position > 0) {
+                    model->position--;
+                    if((model->position == model->window_position) &&
+                       (model->window_position > 0)) {
+                        model->window_position--;
+                    }
+                } else {
+                    model->position = items_size - 1;
+                    if(model->position > items_on_screen - 1) {
+                        model->window_position = model->position - (items_on_screen - 1);
+                    }
+                }
+            }
+        },
+        true);
+}
+
+static void action_menu_process_down(ActionMenu* action_menu) {
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            const size_t items_size = ActionMenuItemArray_size(model->items);
+            if(model->layout == ActionMenuLayoutPortrait) {
+                if(model->position < items_size - 1) {
+                    model->position++;
+                } else {
+                    model->position = 0;
+                }
+            } else {
+                const size_t items_on_screen = furi_string_empty(model->header) ? 4 : 3;
+                if(model->position < items_size - 1) {
+                    model->position++;
+                    if((model->position - model->window_position > items_on_screen - 2) &&
+                       (model->window_position < items_size - items_on_screen)) {
+                        model->window_position++;
+                    }
+                } else {
+                    model->position = 0;
+                    model->window_position = 0;
+                }
+            }
+        },
+        true);
+}
+
+// Used for both the Short and Long presses of OK
+static void action_menu_process_ok(ActionMenu* action_menu, InputType type) {
+    furi_assert(action_menu);
+
+    // FURI_LOG_I("AM", "OK pressed! %d: %s", type, input_get_type_name(type));
+    ActionMenuItem* item = NULL;
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            if(model->position < (ActionMenuItemArray_size(model->items))) {
+                item = ActionMenuItemArray_get(model->items, model->position);
+                if(item->callback) {
+                    item->callback(item->callback_context, item->index, type);
+                }
+            }
+        },
+        false);
+}
+
+static bool action_menu_view_input_callback(InputEvent* event, void* context) {
+    furi_assert(event);
+
+    ActionMenu* action_menu = context;
+    bool consumed = false;
+
+    if(event->type == InputTypeShort) {
+        switch(event->key) {
+        case InputKeyOk:
+            consumed = true;
+            action_menu_process_ok(action_menu, event->type);
+            break;
+        case InputKeyUp:
+            consumed = true;
+            action_menu_process_up(action_menu);
+            break;
+        case InputKeyDown:
+            consumed = true;
+            action_menu_process_down(action_menu);
+            break;
+        case InputKeyLeft:
+            break;
+        case InputKeyRight:
+            break;
+        default:
+            // FURI_LOG_E("AM", "Unknown key!");
+            break;
+        }
+    } else if(event->type == InputTypeLong) {
+        if(event->key == InputKeyRight) {
+            consumed = true;
+            action_menu_process_ok(action_menu, event->type);
+        }
+    }
+
+    return consumed;
+}
+
+View* action_menu_get_view(ActionMenu* action_menu) {
+    furi_assert(action_menu);
+    return action_menu->view;
+}
+
+void action_menu_reset(ActionMenu* action_menu) {
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            // for
+            //     M_EACH(item, model->items, ActionMenuItemArray_t) {
+            //         icon_animation_stop(item->icon);
+            //         icon_animation_free(item->icon);
+            //     }
+            ActionMenuItemArray_reset(model->items);
+            model->position = 0;
+            model->window_position = 0;
+            furi_string_reset(model->header);
+        },
+        true);
+}
+
+void action_menu_set_layout(ActionMenu* action_menu, ActionMenuLayout layout) {
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            model->layout = layout;
+            if(model->layout == ActionMenuLayoutLandscape) {
+                view_set_orientation(action_menu->view, ViewOrientationHorizontal);
+            } else {
+                view_set_orientation(action_menu->view, ViewOrientationVertical);
+            }
+        },
+        true);
+}
+
+void action_menu_set_header(ActionMenu* action_menu, const char* header) {
+    furi_assert(action_menu);
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            if(header == NULL) {
+                furi_string_reset(model->header);
+            } else {
+                furi_string_set_str(model->header, header);
+            }
+        },
+        true);
+}
+
+void action_menu_set_show_icons(ActionMenu* action_menu, bool show_icons) {
+    with_view_model(
+        action_menu->view, ActionMenuModel * model, { model->show_icons = show_icons; }, true);
+}
+
+void action_menu_set_show_headers(ActionMenu* action_menu, bool show_headers) {
+    with_view_model(
+        action_menu->view, ActionMenuModel * model, { model->show_headers = show_headers; }, true);
+}
+
+ActionMenuItem* action_menu_add_item(
+    ActionMenu* action_menu,
+    const char* label,
+    int32_t index,
+    ActionMenuItemCallback callback,
+    ActionMenuItemType type,
+    void* callback_context) {
+    ActionMenuItem* item = NULL;
+    furi_assert(label);
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            item = ActionMenuItemArray_push_new(model->items);
+            item->label = label;
+            // item->icon = icon ? icon_animation_alloc(icon) : NULL; // or default icon?
+            // view_tie_icon_animation(action_menu->view, item->icon);
+            item->index = index;
+            item->type = type;
+            item->callback = callback;
+            item->callback_context = callback_context;
+        },
+        true);
+
+    return item;
+}
+
+ActionMenu* action_menu_alloc(void) {
+    ActionMenu* action_menu = malloc(sizeof(ActionMenu));
+    action_menu->view = view_alloc();
+    view_set_orientation(action_menu->view, ViewOrientationHorizontal);
+    view_set_context(action_menu->view, action_menu);
+    view_allocate_model(action_menu->view, ViewModelTypeLocking, sizeof(ActionMenuModel));
+    view_set_draw_callback(action_menu->view, action_menu_view_draw_callback);
+    view_set_input_callback(action_menu->view, action_menu_view_input_callback);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            ActionMenuItemArray_init(model->items);
+            model->position = 0;
+            model->window_position = 0;
+            model->header = furi_string_alloc();
+            model->layout = ActionMenuLayoutLandscape; // TODO: ehhhhhhhhhhhhhhhhhhh
+            model->show_icons = true;
+            model->show_headers = true;
+        },
+        true);
+
+    return action_menu;
+}
+
+void action_menu_free(ActionMenu* action_menu) {
+    furi_assert(action_menu);
+
+    with_view_model(
+        action_menu->view,
+        ActionMenuModel * model,
+        {
+            // for
+            //     M_EACH(item, model->items, ActionMenuItemArray_t) {
+            //         icon_animation_stop(item->icon);
+            //         icon_animation_free(item->icon);
+            //     }
+            ActionMenuItemArray_clear(model->items);
+            furi_string_free(model->header);
+        },
+        true);
+    view_free(action_menu->view);
+    free(action_menu);
+}
+
+void action_menu_set_selected_item(ActionMenu* action_menu, uint32_t index) {
+    furi_assert(action_menu);
+
+    ActionMenuModel* m = view_get_model(action_menu->view);
+    if(m->layout == ActionMenuLayoutPortrait) {
+        with_view_model(
+            action_menu->view,
+            ActionMenuModel * model,
+            {
+                size_t item_position = 0;
+                ActionMenuItemArray_it_t it;
+                for(ActionMenuItemArray_it(it, model->items); !ActionMenuItemArray_end_p(it);
+                    ActionMenuItemArray_next(it), ++item_position) {
+                    if((uint32_t)ActionMenuItemArray_cref(it)->index == index) {
+                        model->position = item_position;
+                        break;
+                    }
+                }
+            },
+            true);
+    } else {
+        with_view_model(
+            action_menu->view,
+            ActionMenuModel * model,
+            {
+                size_t position = 0;
+                ActionMenuItemArray_it_t it;
+                for(ActionMenuItemArray_it(it, model->items); !ActionMenuItemArray_end_p(it);
+                    ActionMenuItemArray_next(it)) {
+                    if(index == ActionMenuItemArray_cref(it)->index) {
+                        break;
+                    }
+                    position++;
+                }
+                const size_t items_size = ActionMenuItemArray_size(model->items);
+
+                if(position >= items_size) {
+                    position = 0;
+                }
+
+                model->position = position;
+                model->window_position = position;
+
+                if(model->window_position > 0) {
+                    model->window_position -= 1;
+                }
+
+                const size_t items_on_screen = furi_string_empty(model->header) ? 4 : 3;
+
+                if(items_size <= items_on_screen) {
+                    model->window_position = 0;
+                } else {
+                    const size_t pos = items_size - items_on_screen;
+                    if(model->window_position > pos) {
+                        model->window_position = pos;
+                    }
+                }
+            },
+            true);
+    }
+}

+ 120 - 0
quac/views/action_menu.h

@@ -0,0 +1,120 @@
+#pragma once
+
+#include <stdint.h>
+#include <gui/view.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** ActionMenu anonymous structure */
+typedef struct ActionMenu ActionMenu;
+
+/** ActionMenuItem anonymous structure */
+typedef struct ActionMenuItem ActionMenuItem;
+
+/** Callback for any button menu actions */
+typedef void (*ActionMenuItemCallback)(void* context, int32_t index, InputType type);
+
+/** Type of UI element */
+typedef enum {
+    ActionMenuItemTypeSubGHz,
+    ActionMenuItemTypeRFID,
+    ActionMenuItemTypeIR,
+    ActionMenuItemTypeNFC,
+    ActionMenuItemTypePlaylist,
+    ActionMenuItemTypeGroup,
+    ActionMenuItemTypeSettings,
+    ActionMenuItemTypeUnknown,
+    ActionMenuItemType_count
+} ActionMenuItemType;
+
+typedef enum {
+    ActionMenuLayoutPortrait,
+    ActionMenuLayoutLandscape,
+} ActionMenuLayout;
+
+/** Get button menu view
+ *
+ * @param      action_menu  ActionMenu instance
+ *
+ * @return     View instance that can be used for embedding
+ */
+View* action_menu_get_view(ActionMenu* action_menu);
+
+/** Clean button menu
+ *
+ * @param      action_menu  ActionMenu instance
+ */
+void action_menu_reset(ActionMenu* action_menu);
+
+/** Set the layout
+ * 
+ * @param      layout       Portrait or Landscape
+*/
+void action_menu_set_layout(ActionMenu* menu, ActionMenuLayout layout);
+
+/** Show/Hide icons in UI
+ * 
+ * @param      show_icons   Show or Hide icons
+*/
+void action_menu_set_show_icons(ActionMenu* menu, bool show_icons);
+
+/** Show/Hide header labels in UI
+ * 
+ * @param      show_headers   Show or Hide header labels
+*/
+void action_menu_set_show_headers(ActionMenu* menu, bool show_headers);
+
+/** Add item to button menu instance
+ *
+ * @param      action_menu       ActionMenu instance
+ * @param      label             text inside new button
+ * @param      icon              IconAnimation instance
+ * @param      index             value to distinct between buttons inside
+ *                               ActionMenuItemCallback
+ * @param      callback          The callback
+ * @param      type              type of button to create. Differ by button
+ *                               drawing. Control buttons have no frames, and
+ *                               have more squared borders.
+ * @param      callback_context  The callback context
+ *
+ * @return     pointer to just-created item
+ */
+ActionMenuItem* action_menu_add_item(
+    ActionMenu* action_menu,
+    const char* label,
+    int32_t index,
+    ActionMenuItemCallback callback,
+    ActionMenuItemType type,
+    void* callback_context);
+
+/** Allocate and initialize new instance of ActionMenu model
+ *
+ * @return     just-created ActionMenu model
+ */
+ActionMenu* action_menu_alloc(void);
+
+/** Free ActionMenu element
+ *
+ * @param      action_menu  ActionMenu instance
+ */
+void action_menu_free(ActionMenu* action_menu);
+
+/** Set ActionMenu header on top of canvas
+ *
+ * @param      action_menu  ActionMenu instance
+ * @param      header       header on the top of button menu
+ */
+void action_menu_set_header(ActionMenu* action_menu, const char* header);
+
+/** Set selected item
+ *
+ * @param      action_menu  ActionMenu instance
+ * @param      index        index of ActionMenu to be selected
+ */
+void action_menu_set_selected_item(ActionMenu* action_menu, uint32_t index);
+
+#ifdef __cplusplus
+}
+#endif