From fe6a0d1a1007b78f979ae66548eb664e10feb83b Mon Sep 17 00:00:00 2001 From: adgohar <114059470+adgohar@users.noreply.github.com> Date: Wed, 9 Apr 2025 00:33:32 +0200 Subject: [PATCH 1/5] Add MiniApps to main code --- .../app/src/main/assets/web/eventemitter.js | 48 ++ .../app/src/main/assets/web/img/miniapps.svg | 39 ++ .../main/assets/web/miniapps/miniAppHelper.js | 35 ++ .../src/main/assets/web/miniapps/mini_apps.js | 179 ++++++++ .../app/src/main/assets/web/tremola.css | 50 ++- .../app/src/main/assets/web/tremola.html | 24 +- .../app/src/main/assets/web/tremola.js | 18 +- .../app/src/main/assets/web/tremola_ui.js | 104 ++++- .../tremolavossbol/MainActivity.kt | 53 ++- .../tremolavossbol/WebAppInterface.kt | 146 +++++- .../tremolavossbol/miniapps/KanbanPlugin.kt | 117 +++++ .../tremolavossbol/miniapps/MiniAppPlugin.kt | 422 ++++++++++++++++++ .../tremolavossbol/miniapps/PluginLoader.kt | 66 +++ .../tremolavossbol/miniapps/SketchPlugin.kt | 22 + .../tremolavossbol/utils/Constants.kt | 1 + 15 files changed, 1285 insertions(+), 39 deletions(-) create mode 100644 android/tinySSB/app/src/main/assets/web/eventemitter.js create mode 100644 android/tinySSB/app/src/main/assets/web/img/miniapps.svg create mode 100644 android/tinySSB/app/src/main/assets/web/miniapps/miniAppHelper.js create mode 100644 android/tinySSB/app/src/main/assets/web/miniapps/mini_apps.js create mode 100644 android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/KanbanPlugin.kt create mode 100644 android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/MiniAppPlugin.kt create mode 100644 android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/PluginLoader.kt create mode 100644 android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/SketchPlugin.kt diff --git a/android/tinySSB/app/src/main/assets/web/eventemitter.js b/android/tinySSB/app/src/main/assets/web/eventemitter.js new file mode 100644 index 00000000..c4c19275 --- /dev/null +++ b/android/tinySSB/app/src/main/assets/web/eventemitter.js @@ -0,0 +1,48 @@ +// eventEmitter.js + +/** + * Class representing an EventEmitter. + * This class allows different parts of an application to communicate with each other + * by emitting and listening to events. + */ +class EventEmitter { + + /** + * Create an EventEmitter. + * Initializes an empty events object to store event listeners. + */ + constructor() { + this.events = {}; + } + + /** + * Register an event listener for a specific event. + * If the event does not exist, it is created. + * + * @param {string} event - The name of the event. + * @param {function} listener - The callback function to execute when the event is emitted. + */ + on(event, listener) { + if (!this.events[event]) { + this.events[event] = []; + } + this.events[event].push(listener); + } + + /** + * Emit a specific event, executing all registered listeners for that event. + * Additional arguments are passed to the listener functions. + * + * @param {string} event - The name of the event to emit. + * @param {...*} args - The arguments to pass to the listener functions. + * @returns {Array} - The results of the listener functions. + */ + emit(event, ...args) { + if (!this.events[event]) return []; + + return this.events[event].map(listener => listener(...args)); + } +} + +// Create a global instance of EventEmitter and assign it to window object +window.eventEmitter = new EventEmitter(); diff --git a/android/tinySSB/app/src/main/assets/web/img/miniapps.svg b/android/tinySSB/app/src/main/assets/web/img/miniapps.svg new file mode 100644 index 00000000..892646bd --- /dev/null +++ b/android/tinySSB/app/src/main/assets/web/img/miniapps.svg @@ -0,0 +1,39 @@ + + + + diff --git a/android/tinySSB/app/src/main/assets/web/miniapps/miniAppHelper.js b/android/tinySSB/app/src/main/assets/web/miniapps/miniAppHelper.js new file mode 100644 index 00000000..be21bfbe --- /dev/null +++ b/android/tinySSB/app/src/main/assets/web/miniapps/miniAppHelper.js @@ -0,0 +1,35 @@ +function getContactsList() { + // Get the contacts list from the native code + backend("getContactsList"); +} + +function writeLogEntry(entry) { + // Write a log entry to the native code, use currentMiniAppID to identify the app + // entry is a JSONString + backend("customApp:writeEntry " + currentMiniAppID + " " + entry); +} + +function readLogEntries(numberOfEntries) { + // Read the log entries from the native code associated with the currentMiniAppID + backend("customApp:readEntries " + currentMiniAppID + " " + numberOfEntries); +} + +function quitApp() { + // Quit the app + setScenario('miniapps'); +} + +function launchContactsMenu(heading, subheading) { + closeOverlay() + fill_members(true); + prev_scenario = 'customApp'; + setScenario("members"); + + document.getElementById("div:textarea").style.display = 'none'; + document.getElementById("div:confirm-members").style.display = 'flex'; + document.getElementById("tremolaTitle").style.display = 'none'; + var c = document.getElementById("conversationTitle"); + c.style.display = null; + c.innerHTML = "" + heading + "
" + subheading; + document.getElementById('plus').style.display = 'none'; +} \ No newline at end of file diff --git a/android/tinySSB/app/src/main/assets/web/miniapps/mini_apps.js b/android/tinySSB/app/src/main/assets/web/miniapps/mini_apps.js new file mode 100644 index 00000000..806894e4 --- /dev/null +++ b/android/tinySSB/app/src/main/assets/web/miniapps/mini_apps.js @@ -0,0 +1,179 @@ +/** + * mini_apps.js + * + * This file handles the dynamic loading and initialization of mini applications and chat extensions + * within the main application. It retrieves the manifest files for each mini app, processes the + * manifest data, and creates corresponding UI elements such as buttons. These buttons allow users + * to launch the mini apps and chat extensions from the mini app menu. + * + */ + +"use strict"; + +/** + * Handles the paths to manifest files. + * + * This function gets the paths to the manifest files of every app from the backend as input and + * forwards each path to the backend to retrieve the data of each manifest file. + * + * @param {string} manifestPathsJson - JSON string containing an array of paths to manifest files. + */ +function handleManifestPaths(manifestPathsJson) { + + const manifestPaths = JSON.parse(manifestPathsJson); + const listElement = document.getElementById('lst:miniapps'); + + manifestPaths.forEach(path => { + backend("getManifestData " + path); + //fetchManifestFile(path); + }); + +} + +/** + * Handles the content of a manifest file. + * + * This function gets the data of a manifest file, creates a button containing that data and appends + * that button to the list that contains all the buttons that initiate each mini app. + * + * @param {string} content - JSON string containing the manifest data. + */ +function handleManifestContent(content) { + + setTimeout(() => { + const manifest = JSON.parse(content); + // Process the manifest data (e.g., create buttons) + const listElement = document.getElementById('lst:miniapps'); + const miniAppButton = createMiniAppButton(manifest); + listElement.appendChild(miniAppButton); + console.log(`Added button after delay: ${miniAppButton.id}`); + }, 100); + +} + +/** + * Creates a button to initiate a Mini App. + * + * This function gets the manifest data as input and uses it to create the button that initiates + * the mini app in the mini app menu. If the mini app is also a chat extension, a button will + * be added in the attach-menu. + * + * @param {Object} manifest - The manifest data of the mini app. + * @param {string} manifest.id - The ID of the mini app. + * @param {string} manifest.icon - The path to the icon of the mini app. + * @param {string} manifest.name - The name of the mini app. + * @param {string} manifest.description - The description of the mini app. + * @param {string} manifest.init - The initialization function as a string. + * @param {string} [manifest.extension] - Indicates if the app is also a chat extension. + * @returns {HTMLElement} The created button element. + */ +function createMiniAppButton(manifest) { + const item = document.createElement('div'); + item.className = 'miniapp_item_div'; + + const button = document.createElement('button'); + button.id = 'btn:' + manifest.id; + button.className = 'miniapp_item_button w100'; + button.style.display = 'flex'; + button.style.alignItems = 'center'; + button.style.padding = '10px'; + button.style.border = '1px solid #ccc'; + button.style.borderRadius = '5px'; + button.style.backgroundColor = '#f9f9f9'; + + console.log("Init function: " + manifest.init); + + //button.onclick = manifest.init(); // This didn't work as intended + button.addEventListener('click', () => { + try { + // Dynamically evaluate the init function + console.log("Init function: " + manifest.init); + //Set currentMiniAppID to the manifest.id + currentMiniAppID = manifest.id + setScenario("customApp:" + manifest.id); + console.log(curr_scenario); + eval(manifest.init); + } catch (error) { + console.error(`Error executing init function: ${manifest.init}`, error + " " + error.stack); + } + }); + + const icon = document.createElement('img'); + console.log("App Icon: " + manifest.icon); + let iconSrc = manifest.icon; + // Remove the incorrect asset prefix if present + if (iconSrc.startsWith("file:///android_asset/")) { + iconSrc = iconSrc.replace("file:///android_asset/", "file://"); + } + icon.src = iconSrc; + console.log("Icon source: " + icon.src); + icon.alt = `${manifest.name} icon`; + icon.className = 'miniapp_icon'; + icon.style.width = '50px'; + icon.style.height = '50px'; + icon.style.marginRight = '10px'; + + + const textContainer = document.createElement('div'); + textContainer.className = 'miniapp_text_container'; + + const nameElement = document.createElement('div'); + nameElement.className = 'miniapp_name'; + nameElement.textContent = manifest.name; + + const descriptionElement = document.createElement('div'); + descriptionElement.className = 'miniapp_description'; + descriptionElement.textContent = manifest.description; + + textContainer.appendChild(nameElement); + textContainer.appendChild(descriptionElement); + + button.appendChild(icon); + button.appendChild(textContainer); + item.appendChild(button); + + if (manifest.extension === "True") { + createExtensionButton(manifest); + } + + return item; +} + +/** + * Creates a button for a chat extension. + * + * This function gets the manifest data and uses it to create a button for the chat extension, + * which is then appended to the attach-menu. + * + * @param {Object} manifest - The manifest data of the chat extension. + * @param {string} manifest.extensionText - The text to display on the extension button. + * @param {string} manifest.extensionInit - The initialization function for the extension as a string. + */ +function createExtensionButton(manifest) { + //console.log("Extension entered") + const attachMenu = document.getElementById('attach-menu'); + + // Create a new button element + const newButton = document.createElement('button'); + + // Set the button's class + newButton.className = 'attach-menu-item-button'; + + // Set the button's text content + newButton.textContent = manifest.extensionText; + + // Set the button's onclick event + newButton.addEventListener('click', () => { + try { + // Dynamically evaluate the init function + eval(manifest.extensionInit); + } catch (error) { + console.error(`Error executing extensionInit function: ${manifest.extensionInit}`, error); + } + }); + + // Append the new button to the target div + attachMenu.appendChild(newButton); +} + + diff --git a/android/tinySSB/app/src/main/assets/web/tremola.css b/android/tinySSB/app/src/main/assets/web/tremola.css index 607e8270..c3a9434c 100644 --- a/android/tinySSB/app/src/main/assets/web/tremola.css +++ b/android/tinySSB/app/src/main/assets/web/tremola.css @@ -115,9 +115,13 @@ width: 100%; } .buttontext { - height: 45pt; - padding: 4px 10px 10px 10px; - } + font-size: small; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + height: 50px; + padding: 3px; + } .item { border: none; text-align: left; @@ -646,6 +650,46 @@ input:checked + .slider:before { background-color: red; } +/* --------------------------------------------------------------------------- */ +/* Mini App Menu */ +/* --------------------------------------------------------------------------- */ + +.miniapp_item_div { + padding: 0px 5px 10px 5px; + margin: 3px 3px 6px 3px; +} + +.miniapp_item_button { + overflow: hidden; + position: relative; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #f9f9f9; + padding: 10px; + width: 100%; + text-align: left; + display: flex; + align-items: center; +} + +.miniapp_icon { + width: 50px; + height: 50px; + margin-right: 10px; +} + +.miniapp_text_container { + display: flex; + flex-direction: column; +} + +.miniapp_name { + font-weight: bold; +} + +.miniapp_description { + font-size: small; +} /* eof */ diff --git a/android/tinySSB/app/src/main/assets/web/tremola.html b/android/tinySSB/app/src/main/assets/web/tremola.html index f81aa2fe..c78aedff 100644 --- a/android/tinySSB/app/src/main/assets/web/tremola.html +++ b/android/tinySSB/app/src/main/assets/web/tremola.html @@ -12,6 +12,8 @@ + + @@ -62,7 +64,7 @@
t i n y S S B
@@ -141,6 +143,9 @@

Other Contacts

+ + +
@@ -686,17 +691,12 @@

Other Contacts

-
- - -
- - - +
+ + +
+ +
diff --git a/android/tinySSB/app/src/main/assets/web/tremola.js b/android/tinySSB/app/src/main/assets/web/tremola.js index 786b9eb3..da0d9a02 100644 --- a/android/tinySSB/app/src/main/assets/web/tremola.js +++ b/android/tinySSB/app/src/main/assets/web/tremola.js @@ -152,16 +152,24 @@ function edit_confirmed() { renameItem(curr_board, curr_rename_item, val) } item_menu(curr_rename_item) - } + } else { + console.log(currentMiniAppID); + if (curr_scenario == 'customApp') { + backend(currentMiniAppID + ":" + "edit_confirmed") + } else { + backend("edit_confirmed") // AVR + } + } } function members_confirmed() { if (prev_scenario == 'chats') { new_conversation() - } else if (prev_scenario == 'kanban') { - menu_new_board_name() - } else if (prev_scenario == 'tictactoe-list') { - ttt_new_game_confirmed() + } else { + if (prev_scenario == 'customApp') { + backend(currentMiniAppID + ":members_confirmed") + } + backend(prev_scenario + ":members_confirmed") // AVR } } diff --git a/android/tinySSB/app/src/main/assets/web/tremola_ui.js b/android/tinySSB/app/src/main/assets/web/tremola_ui.js index bfbaabeb..d4d2e74a 100644 --- a/android/tinySSB/app/src/main/assets/web/tremola_ui.js +++ b/android/tinySSB/app/src/main/assets/web/tremola_ui.js @@ -4,12 +4,15 @@ var overlayIsActive = false; +var currentMiniAppID = null; // Keeps track of the active MiniApp +var miniApps = {}; // Stores dynamically loaded MiniApps + var display_or_not = [ 'div:qr', 'div:back', 'core', 'plus', 'lst:chats', 'lst:prod', 'lst:games', 'lst:contacts', 'lst:members', 'div:posts', 'lst:kanban', 'div:board', 'div:footer', 'div:textarea', 'div:confirm-members', 'div:settings', - 'div:tictactoe_list', 'div:tictactoe_board' + 'div:tictactoe_list', 'div:tictactoe_board', 'lst:miniapps' ]; var prev_scenario = 'chats'; @@ -26,6 +29,7 @@ var scenarioDisplay = { 'settings': ['div:back', 'core', 'div:settings'], 'kanban': ['div:back', 'core', 'lst:kanban', 'plus'], // KANBAN 'board': ['div:back', 'core', 'div:board'], // KANBAN + 'miniapps': ['div:qr', 'core', 'lst:miniapps', 'div:footer'], 'tictactoe-list': ['div:back', 'core', 'div:tictactoe_list', 'plus'], 'tictactoe-board': ['div:back', 'core', 'div:tictactoe_board'], } @@ -57,6 +61,8 @@ var scenarioMenu = { 'settings': [], + 'miniapps': [], + 'kanban': [ // ['New Kanban board', 'menu_new_board'], // no redundant functionality ['Invitations', 'menu_board_invitations'], @@ -89,10 +95,19 @@ var curr_qr_scan_target = QR_SCAN_TARGET.ADD_CONTACT var FEED_CNT, ENTRY_CNT, CHUNK_CNT, NOCHUNK_CNT; function onBackPressed() { + console.log('curr_scenario: ' + curr_scenario) + console.log('prev_scenario: ' + prev_scenario) if (overlayIsActive) { closeOverlay(); return; } + console.log("Back button pressed"); + console.log("Current MiniApp ID:", currentMiniAppID); + if (curr_scenario === 'customApp') { + console.log("Back button pressed inside MiniApp:", currentMiniAppID); + backend(currentMiniAppID + ":onBackPressed"); // Send MiniApp ID to Kotlin + return; + } if (curr_scenario == 'settings') { document.getElementById('div:settings').style.display = 'none'; document.getElementById('core').style.display = null; @@ -105,28 +120,82 @@ function onBackPressed() { backend("onBackPressed"); else if (curr_scenario == 'members') setScenario(prev_scenario) - else if (['productivity', 'games', 'contacts'].indexOf(curr_scenario) >= 0) + else if (['chats', 'contacts', 'connex', 'miniapps'].indexOf(curr_scenario) >= 0) setScenario('chats') else if (['kanban'].indexOf(curr_scenario) >= 0) { setScenario('productivity') prev_scenario = 'chats' - } else if (curr_scenario == 'posts') - setScenario('chats') - else if (curr_scenario == 'board') - setScenario('kanban') - else if (curr_scenario == 'tictactoe-list') - setScenario('games') - else if (curr_scenario == 'tictactoe-board') - setScenario('tictactoe-list') + } else if (curr_scenario == 'posts' && prev_scenario == 'chats') { + setScenario('chats'); + } else if (curr_scenario == 'posts' && prev_scenario == 'miniapps') { + setScenario('miniapps'); + } else if (curr_scenario == 'members') { + console.log("prev: " + prev_scenario); + setScenario(prev_scenario); + } else if (curr_scenario == 'settings') { + document.getElementById('div:settings').style.display = 'none'; + document.getElementById('core').style.display = null; + document.getElementById('div:footer').style.display = null; + setScenario(prev_scenario); + } else { + backend("backPressed"); + } } function setScenario(s) { // console.log('setScenario ' + s) closeOverlay(); + if (s.startsWith('customApp')) { + //check if s contains a : + if (s.includes(":")) { + //split s by : + var split = s.split(":"); + s = split[0]; + currentMiniAppID = split[1]; + } + curr_scenario = s; + } + console.log(curr_scenario); + + if (curr_scenario === 'customApp' && ['chats', 'contacts', 'connex'].indexOf(s) >= 0) { + console.log("Leaving MiniApps section"); + currentMiniAppID = null; // Reset only if exiting the entire miniapps section + } + + if (s == 'customApp') { + console.log(currentMiniAppID); + //remove every display_or_not element except for the ones associated with the current miniApp + let filtered = display_or_not.filter(function (element) { + return element.startsWith('div:' + currentMiniAppID) || element.startsWith('lst:' + currentMiniAppID) || element.startsWith('the:' + currentMiniAppID); + }); + console.log("display: " + display_or_not); + + display_or_not.forEach(function (d) { + let found = false; // Flag to track if a match was found + + for (let key in scenarioDisplay) { + console.log("key: " + key); + if (filtered.includes('div:' + key)) { + if (scenarioDisplay[key].includes(d)) { + console.log("found: " + d); + found = true; // Mark as found + break; // Exit inner loop + } + } + } + + if (!found) { + console.log("not found: " + d); + document.getElementById(d).style.display = 'none'; + } + }); + + + } var lst = scenarioDisplay[s]; if (lst) { // if (s != 'posts' && curr_scenario != "members" && curr_scenario != 'posts') { - if (['chats', 'productivity', 'games', 'contacts'].indexOf(curr_scenario) >= 0) { + if (['chats', 'productivity', 'games', 'contacts', 'miniapps'].indexOf(curr_scenario) >= 0) { var cl = document.getElementById('btn:' + curr_scenario).classList; cl.toggle('active', false); cl.toggle('passive', true); @@ -162,7 +231,7 @@ function setScenario(s) { } curr_scenario = s; - if (['chats', 'productivity', 'games', 'contacts'].indexOf(curr_scenario) >= 0) { + if (['chats', 'productivity', 'games', 'contacts', 'miniapps'].indexOf(curr_scenario) >= 0) { var cl = document.getElementById('btn:' + curr_scenario).classList; cl.toggle('active', true); cl.toggle('passive', false); @@ -239,7 +308,7 @@ function setScenario(s) { function btnBridge(e) { var e = e.id, m = ''; if (['btn:chats', 'btn:contacts', 'btn:games', - 'btn:posts', 'btn:productivity'].indexOf(e) >= 0) { + 'btn:posts', 'btn:productivity', 'btn:miniapps'].indexOf(e) >= 0) { // console.log('btn', e) setScenario(e.substring(4)); } @@ -365,7 +434,14 @@ function plus_button() { menu_new_board(); } else if (curr_scenario == 'tictactoe-list') { ttt_new_game(); - } + } else { + console.log(currentMiniAppID); + if (currentMiniAppID) { + backend(currentMiniAppID + ":plus_button"); + } else { + backend(curr_scenario + ":plus_button"); + } + } } function launch_snackbar(txt) { diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/MainActivity.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/MainActivity.kt index e08e9535..8bc05e47 100644 --- a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/MainActivity.kt +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/MainActivity.kt @@ -24,6 +24,7 @@ import android.view.Window import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView +import android.webkit.WebViewClient import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatDelegate import androidx.core.view.ViewCompat @@ -37,6 +38,8 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import com.google.zxing.integration.android.IntentIntegrator import nz.scuttlebutt.tremolavossbol.crypto.IdStore +import nz.scuttlebutt.tremolavossbol.miniapps.MiniAppPlugin +import nz.scuttlebutt.tremolavossbol.miniapps.PluginLoader import nz.scuttlebutt.tremolavossbol.tssb.Demux import nz.scuttlebutt.tremolavossbol.tssb.GOset import nz.scuttlebutt.tremolavossbol.tssb.IO @@ -81,7 +84,8 @@ class MainActivity : Activity() { var broadcastReceiver: BroadcastReceiver? = null var isWifiConnected = false var ble_event_listener: BluetoothEventListener? = null - + val plugins = mutableListOf() // Mutable list to hold MiniAppPlugin instances. + var miniAppDirectory: File? = null /* var broadcast_socket: DatagramSocket? = null var server_socket: ServerSocket? = null @@ -151,6 +155,7 @@ class MainActivity : Activity() { wai = WebAppInterface(this, webView) // upgrades repo filesystem if necessary tinyRepo.upgrade_repo() + createMiniAppsDirectory() tinyIO = IO(this, wai) tinyGoset._include_key(idStore.identity.verifyKey) // make sure our local key is in tinyRepo.load() @@ -165,7 +170,43 @@ class MainActivity : Activity() { webView.settings.javaScriptEnabled = true webView.settings.domStorageEnabled = true - webView.loadUrl("https://appassets.androidplatform.net/assets/web/tremola.html") + // Create an instance of PluginLoader, passing the current activity and WebView + val pluginLoader = PluginLoader(this, webView) + + // Load plugins using PluginLoader and add them to the plugins list + plugins.addAll(pluginLoader.loadPlugins()) + + // Set a WebViewClient to ensure the HTML scripts and content are injected only + // after the tremola.html file is loaded + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + + // Initialize each plugin after the HTML page has fully loaded + //plugins.forEach { it.initialize() } + //check all miniapps in the folder + Log.d("miniAppCheck", "Checking for miniApps") + + //Define miniApp directory from root + val assetManager = assets + //list all files in the miniAppsDirectory + val miniApps = miniAppDirectory?.listFiles() + miniApps?.forEach { miniApp -> + val relativeName = miniApp.name + Log.d("miniAppCheck", "Found miniApp: $relativeName") + val manifestFile = File(miniApp, "manifest.json") + if (manifestFile.exists()) { + val manifest = manifestFile.bufferedReader().use { it.readText() } + MiniAppPlugin(this@MainActivity, webView).initialize(manifest) + } else { + Log.e("miniAppCheck", "manifest.json not found for miniApp: $relativeName") + } + } + + } + } + + webView.loadUrl("file:///android_asset/web/tremola.html") broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -273,6 +314,14 @@ class MainActivity : Activity() { } + fun createMiniAppsDirectory() { + var miniAppsDir = File(dataDir, "miniApps") + if (!miniAppsDir.exists()) { + miniAppsDir.mkdirs() + } + miniAppDirectory = miniAppsDir + } + fun scheduleWorker(context: Context) { val workRequest = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) .build() diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt index f87890c3..30fb06b0 100644 --- a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt @@ -41,6 +41,10 @@ import nz.scuttlebutt.tremolavossbol.utils.Constants.Companion.TINYSSB_APP_DLV import nz.scuttlebutt.tremolavossbol.utils.Constants.Companion.TINYSSB_APP_NEWTRUSTED import nz.scuttlebutt.tremolavossbol.utils.Constants.Companion.TINYSSB_APP_TICTACTOE import okio.ByteString.Companion.decodeHex +import nz.scuttlebutt.tremolavossbol.miniapps.MiniAppPlugin +import nz.scuttlebutt.tremolavossbol.utils.Constants.Companion.TINYSSB_APP_CUSTOM_APP +import java.io.File +import java.io.IOException // pt 3 in https://betterprogramming.pub/5-android-webview-secrets-you-probably-didnt-know-b23f8a8b5a0c @@ -99,6 +103,10 @@ class WebAppInterface(val act: MainActivity, val webView: WebView) { //handle the data captured from webview} Log.d("FrontendRequest", s) val args = s.split(" ") + + // Allow plugins to handle the frontend requests use MiniAppPlugin + MiniAppPlugin(act, webView).handleRequest(args) + when (args[0]) { "onBackPressed" -> { (act as MainActivity)._onBackPressed() @@ -316,6 +324,18 @@ class WebAppInterface(val act: MainActivity, val webView: WebView) { i.setData(Uri.parse("https://plus.codes/" + args[1])) act.startActivity(i); } + // Not needed anymore but kept for reference + "writeManifestPaths" -> { + //Log.d("HERE", "i am here!!!!") + sendManifestPathsToFrontend() + } + // Not needed anymore but kept for reference + "getManifestData" -> { + if (args.size > 1) { + val path = args[1] + readManifestFile(path) + } + } "settings:set" -> { act.settings!!.set(args[1], args[2]) } @@ -356,6 +376,61 @@ class WebAppInterface(val act: MainActivity, val webView: WebView) { } } + "customApp:writeEntry" -> { + val lst = Bipf.mkList() + Bipf.list_append(lst, TINYSSB_APP_CUSTOM_APP) + Bipf.list_append(lst, Bipf.mkString(args[1])) + Bipf.list_append(lst, Bipf.mkString(args[2])) //TODO: bipfify the JSON object + val body = Bipf.encode(lst) + if (body != null) { + act.tinyNode.publish_public_content(body) + } + Log.d("wai", "customApp:entry " + args[2]) + } + + "customApp:readEntries" -> { + // define the structure to keep the entries + val entries = mutableListOf() + var count = 0 + // read the content of my own feed + val replica = act.tinyRepo.fid2replica(act.idStore.identity.verifyKey) + if (replica != null) { + val customAppID = args[1] + val numEntries = args[2].toInt() + // check if entry starts with CUS identifier + var seq = replica.state.max_seq + Log.d("wai", "customApp:seq " + seq.toString()) + while (count < numEntries && seq > 0) { + var entry = replica?.read_content(seq) + val entryType = entry?.let { getFromEntry(it, 0) } + Log.d("wai", "customApp:entryType " + entryType.toString()) + if (entryType == "CUS") { + // read the content of the entry + val entryApp = entry?.let { getFromEntry(it, 1) } + Log.d("wai", "customApp:entryApp " + entryApp.toString()) + if (entryApp == customAppID) { + val entryData = entry?.let { getFromEntry(it, 2) } + if (entryData != null) { + val entryDataString = entryData.toString() + Log.d("wai", "customApp:entryData " + entryDataString) + entries.add(entryDataString) + count++ + } + } + } + seq-- + } + + // convert the list of entries to a JSON array + val jsonArray = JSONArray(entries) + Log.d("wai", "customApp:entries" + jsonArray.toString()) + // send the JSON array to the frontend + val arguments: MutableList = ArrayList() + arguments.add("$customAppID:incoming_notification") + arguments.add(jsonArray.toString()) + MiniAppPlugin(act, webView).handleRequest(arguments) + } + } else -> { Log.d("onFrontendRequest", "unknown") } @@ -460,9 +535,9 @@ class WebAppInterface(val act: MainActivity, val webView: WebView) { if (entry != null) { val entry = Bipf.bipf_loads(entry) if (entry != null) { - if (entry.get() is ArrayList<*>) { - val first = (entry.get() as ArrayList<*>).get(index) - return first + val entryList = Bipf.bipf_list2JSON(entry) + if (entryList != null) { + return entryList.get(index) } } } @@ -514,6 +589,71 @@ class WebAppInterface(val act: MainActivity, val webView: WebView) { Bipf.encode(Bipf.mkBytes(encrypted))?.let { act.tinyNode.publish_public_content(it) } } + /** + * The following function was part of the initial approach for retrieving the manifest + * paths and sending them to the frontend and ultimately help creating a button for + * the mini App menu. It has been replaced by a more efficient method but is kept here + * for reference. If not needed, consider removing this section entirely to clean up the + * codebase. + */ + fun sendManifestPathsToFrontend() { + + //Log.d("INSIDE", "i am inside the function") + // Access the assets directory (only read) + val assetManager = act.assets + val manifestFilePaths = mutableListOf() + + try { + // List all directories in the "miniApps" directory + val filesDir = act.filesDir + val miniAppFolders = act.miniAppDirectory?.list() + Log.d("miniApps", miniAppFolders.toString()) + if (miniAppFolders != null) { + for (folder in miniAppFolders) { + Log.d("Folder", folder) + + val manifestPath = "file://${act.miniAppDirectory}/$folder/manifest.json" + // Construct the path to each manifest.jon file + try { + val file = File(act.miniAppDirectory, "$folder/manifest.json") + manifestFilePaths.add(manifestPath) + } catch (e: IOException) { + Log.e("Manifest", "Manifest file not found: $manifestPath") + } + } + } + } catch (e: IOException) { + Log.e("Manifest", "Error accessing assets", e) + return + } + + // Convert list of paths to a JSON array and pass to JavaScript frontend + val jsonArray = JSONArray(manifestFilePaths) + Log.d("Path", jsonArray.toString()) + + eval("handleManifestPaths('${jsonArray.toString()}')") + } + + /** + * The following function was also part of the initial approach for passing the content + * of the manifest file to the frontend and ultimately help creating a button for + * the mini App menu. It has been replaced by a more efficient method but is kept here + * for reference. If not needed, consider removing this section entirely to clean up the + * codebase. + */ + fun readManifestFile(path: String) { + try { + val manifestFile = File(act.miniAppDirectory, path) + val content = manifestFile.readText() + + val quotedContent = JSONObject.quote(content) + // Send the content to the frontend + eval("handleManifestContent($quotedContent)") + } catch (e: IOException) { + Log.e("AssetError", "Error reading asset file", e) + } + } + fun public_post_with_voice(tips: ArrayList, text: String?, voice: ByteArray?) { /* if (text != null) diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/KanbanPlugin.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/KanbanPlugin.kt new file mode 100644 index 00000000..96a4c48e --- /dev/null +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/KanbanPlugin.kt @@ -0,0 +1,117 @@ +package nz.scuttlebutt.tremolavossbol.miniapps + +import android.util.Base64 +import android.util.Log +import android.webkit.WebView +import nz.scuttlebutt.tremolavossbol.MainActivity +import nz.scuttlebutt.tremolavossbol.miniapps.MiniAppPlugin +import nz.scuttlebutt.tremolavossbol.utils.Bipf +import java.io.File + +// Define a constant for the Kanban app within the plugin +val TINYSSB_APP_KANBAN = Bipf.mkString("KAN") // ... + +/** + * KanbanPlugin is a subclass of MiniAppPlugin that provides functionality specific to the Kanban board mini-app. + * This class handles the initialization of Kanban-related UI elements, manages requests from the web view, + * and interacts with the Kanban board by publishing updates and handling various operations. + */ +class KanbanPlugin(act: MainActivity, webView: WebView) : MiniAppPlugin(act, webView) { + + /** + * Handles requests from the web view. Manages different types of actions based on the request type. + */ + fun handleRequest2(args: List) { + Log.d("Inside the Kanban handleRequest", args[0]) + when(args[0]) { + "kanban" -> { + // Kanban-specific logic + val bid: String? = if (args[1] != "null") args[1] else null + val prev: List? = if (args[2] != "null") Base64.decode(args[2], Base64.NO_WRAP).decodeToString().split(",").map{ Base64.decode(it, Base64.NO_WRAP).decodeToString()} else null + val op: String = args[3] + val argsList: List? = if(args[4] != "null") Base64.decode(args[4], Base64.NO_WRAP).decodeToString().split(",").map{ Base64.decode(it, Base64.NO_WRAP).decodeToString()} else null + + kanban(bid, prev , op, argsList) + } + "backPressed" -> { + val jsCode = """ + if (curr_scenario == 'board') { + setKanbanScenario('kanban'); + } else if (curr_scenario == 'kanban'){ + setScenario('miniapps'); + } + """.trimIndent() + + eval(jsCode) + } + "kanban:members_confirmed" -> { + eval("menu_new_board_name()") + } + "kanban:plus_button" -> { + eval("menu_new_board()") + } + "edit_confirmed" -> { + eval("kanban_edit_confirmed()") + } + "b2f_initialize" -> { + eval("load_board_list()") + } + "b2f_new_event" -> { + eval("load_board_list()") + } + } + } + + /** + * Executes Kanban-specific operations based on the provided arguments. This includes + * publishing content to the Kanban board with operations and arguments. + * + * @param bid The board ID. + * @param prev The list of previous board states. + * @param operation The operation to perform (e.g., create, update). + * @param args Additional arguments for the operation. + */ + fun kanban(bid: String?, prev: List?, operation: String, args: List?) { + val lst = Bipf.mkList() + Bipf.list_append(lst, TINYSSB_APP_KANBAN) + if (bid != null) + Bipf.list_append(lst, Bipf.mkBytes(Base64.decode(bid, Base64.NO_WRAP))) + else + Bipf.list_append(lst, Bipf.mkNone()) + + if(prev != null) { + val prevList = Bipf.mkList() + for(p in prev) { + Bipf.list_append(prevList, Bipf.mkBytes(Base64.decode(p, Base64.NO_WRAP))) + } + Bipf.list_append(lst, prevList) + } else { + Bipf.list_append(lst, Bipf.mkString("null")) // TODO: Change to Bipf.mkNone(), but would be incompatible with the old format + } + + Bipf.list_append(lst, Bipf.mkString(operation)) + + if(args != null) { + for(arg in args) { + if (Regex("^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?\$").matches(arg)) { + Bipf.list_append(lst, Bipf.mkBytes(Base64.decode(arg, Base64.NO_WRAP))) + } else { // arg is not a b64 string + Bipf.list_append(lst, Bipf.mkString(arg)) + } + } + } + + val body = Bipf.encode(lst) + + if (body != null) { + Log.d("kanban", "published bytes: " + Bipf.decode(body)) + act.tinyNode.publish_public_content(body) + } + //val body = Bipf.encode(lst) + //Log.d("KANBAN BIPF ENCODE", Bipf.bipf_list2JSON(Bipf.decode(body!!)!!).toString()) + //if (body != null) + //act.tinyNode.publish_public_content(body) + + } + +} \ No newline at end of file diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/MiniAppPlugin.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/MiniAppPlugin.kt new file mode 100644 index 00000000..618c9e60 --- /dev/null +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/MiniAppPlugin.kt @@ -0,0 +1,422 @@ +package nz.scuttlebutt.tremolavossbol.miniapps + +import android.util.Log +import android.webkit.WebView +import nz.scuttlebutt.tremolavossbol.MainActivity +import org.json.JSONObject +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import kotlin.system.measureNanoTime + +/** + * Abstract class representing a Mini App Plugin. + * + * This class provides methods to inject CSS, JavaScript, HTML content, and various + * display configurations into a WebView. Each Mini App should extend this class and + * provide specific implementations for the `initialize` and `handleRequest` methods. + */ +open class MiniAppPlugin(val act: MainActivity, val webView: WebView) { + + /** + * Initializes the Mini App Plugin. + * + * This function is called by the PluginLoader when loading all the Plugins and has to be + * overridden for every MiniApp specific Plugin. It is used to inject all the needed content. + */ + fun initialize(jsonString: String) { + Log.d("MiniAppPlugin", "Initializing Mini App Plugin") + + try { + //Convert the JSON string to a JSONObject + val jsonObj = JSONObject(jsonString) + + val miniAppID = jsonObj.getString("id") + + // Construct directories in the internal storage ("data" folder) + val miniAppsDir = act.miniAppDirectory + // Get the CSS file name from JSON and remove any leading '/' + val cssFileName = jsonObj.getString("cssFile").removePrefix("/") + + // Build the full path to the CSS file in the internal storage + val cssFile = File(miniAppsDir, miniAppID + "/$cssFileName") + val cssPath = cssFile.absolutePath + + var htmlPath = jsonObj.getString("htmlFile") + //add miniAppID to the htmlPath + htmlPath = "$miniAppID/$htmlPath" + val html = File(miniAppsDir, htmlPath).absolutePath + val css = File(miniAppsDir, cssPath).absolutePath + + val scriptsPaths = jsonObj.getJSONArray("scripts") + val scriptFiles = mutableListOf() + for (i in 0 until scriptsPaths.length()) { + val scriptPath = File(miniAppsDir, miniAppID + "/" + scriptsPaths.getString(i)).path + // add scriptPath to the scriptFiles list + scriptFiles.add(scriptPath) + } + + // Intialize String array to store the script files in scriptFiles + val scriptFilesArray = scriptFiles.toTypedArray() + Log.d("Script Files", scriptFilesArray.contentToString()) + + val scenarioJson = jsonObj.getJSONObject("scenario") + + // Create a displayOrNotList by using each scenario key (e.g., "div:" + key) + val displayOrNotList = mutableListOf() + // Create a map for scenario display arrays + val scenarioDisplayMap = mutableMapOf>() + // Create a map for scenario menus (list of key-value pairs) + val scenarioMenu = mutableMapOf>>() + + for (key in scenarioJson.keys()) { + val scenarioEntry = scenarioJson.getJSONObject(key) + + // Build displayOrNotList using the scenario key. + displayOrNotList.add("div:$key") + + Log.d("Scenario Key", scenarioEntry.toString()) + + // Extract the display array from the scenario entry. + val displayArray = scenarioEntry.getJSONArray("display") + val displayList = mutableListOf() + for (i in 0 until displayArray.length()) { + displayList.add(displayArray.getString(i)) + } + scenarioDisplayMap[key] = displayList + + // Extract the menu object and convert it into a list of key-value pairs. + val menuJson = scenarioEntry.getJSONObject("menu") + val menuList = mutableListOf>() + for (menuKey in menuJson.keys()) { + menuList.add(Pair(menuKey, menuJson.getString(menuKey))) + } + scenarioMenu[key] = menuList + } + + Log.d("DisplayOrNotList", displayOrNotList.toString()) + Log.d("Scenario Display Map", scenarioDisplayMap.toString()) + Log.d("Scenario Menu", scenarioMenu.toString()) + + val manifestPath = File(miniAppsDir, "$miniAppID/manifest.json").absolutePath + + injectAll( + miniAppID, + cssPath, + scriptFilesArray, + html, + displayOrNotList, + scenarioDisplayMap, + scenarioMenu, + manifestPath + ) + } catch (e: Exception) { + Log.e("MiniAppPlugin", "Error initializing Mini App Plugin", e) + } + } + + /** + * Handles requests with the given arguments. + * + * @param args List of arguments to handle the request. + */ + fun handleRequest(args: List) { + if (args.isEmpty()) { + Log.e("MiniAppPlugin", "handleRequest called with empty args") + return + } + + Log.d("MiniAppPluginRequest", "Handling request: ${args[0]} with args: ${args.drop(1)}") + + val firstArg = args[0] + Log.d("MiniAppPluginRequest", "First arg: $firstArg") + + if (!firstArg.contains(":")) { + val miniAppID = firstArg + val jsonArgs = args.drop(1) + + val argsJson = JSONObject().apply { + put("args", jsonArgs) + }.toString() + + val jsCode = """ + (function() { + if (window.miniApps && window.miniApps['$miniAppID'] && typeof window.miniApps['$miniAppID'].handleRequest === 'function') { + let response = window.miniApps['$miniAppID'].handleRequest($argsJson); + console.log("Response from $miniAppID:", response); + } else { + console.error("handleRequest function not found for MiniApp: $miniAppID"); + } + })(); + """.trimIndent() + + eval(jsCode) + return + } + + val parts = firstArg.split(":", limit = 2) + if (parts.size < 2) { + Log.e("MiniAppPlugin", "Invalid MiniApp request format: $firstArg") + return + } + + val miniAppID = parts[0] + val command = parts[1] // The command (e.g., "onBackPressed") + val jsonArgs = JSONObject().apply { + put("args", args.drop(1)) // Remaining arguments + }.toString() + + val jsCode = """ + (function() { + if (window.miniApps && window.miniApps['$miniAppID'] && typeof window.miniApps['$miniAppID'].handleRequest === 'function') { + let response = window.miniApps['$miniAppID'].handleRequest("$command", $jsonArgs); + console.log("Response from $miniAppID:", response); + } else { + console.error("handleRequest function not found for MiniApp: $miniAppID"); + } + })(); + """.trimIndent() + + eval(jsCode) + } + + + /** + * Injects all specified resources into the WebView. To be used in the overridden initialize() + * function for every mini App Plugin + * + * @param cssPath CSS code to be injected. + * @param scriptPath Array of JavaScript file paths to be injected. + * @param htmlPath Array of HTML content to be injected into the core div. + * @param displayOrNot List of display configurations to be injected. + * @param scenarioDisplay Map of scenario displays to be injected. + * @param scenarioMenu Map of scenario menus to be injected. + */ + open fun injectAll(miniAppID: String, + cssPath: String? = null, + scriptPath: Array? = null, + htmlPath: String? = null, + displayOrNot: List? = null, + scenarioDisplay: Map>? = null, + scenarioMenu: Map>>? = null, + manifestPath: String? = null) { + cssPath?.let { injectCSS(it) } + scriptPath?.let { injectScript(miniAppID, *it) } + htmlPath?.let { injectContent(it) } + displayOrNot?.let { injectDisplayOrNot(it) } + scenarioDisplay?.let { injectScenarioDisplay(it) } + scenarioMenu?.let { injectScenarioMenu(it) } + manifestPath?.let { addMiniAppToList(it) } + } + + /** + * Sends a JavaScript string to the WebView frontend for execution. + * + * This function posts a Runnable to the WebView, which then evaluates the given + * JavaScript string within the context of the web page loaded in the WebView. + * + * @param js The JavaScript code to be executed in the WebView. + */ + open fun eval(js: String) { // send JS string to webkit frontend for execution + webView.post(Runnable { + webView.evaluateJavascript(js, null) + }) + } + + /** + * Injects the given CSS code into the header of the HTML file. + * + * @param css CSS code to be injected. + */ + private fun injectCSS(cssPath: String) { + Log.d("InjectCSS", "Attempting to inject CSS from: $cssPath") + + val script = """ + (function() { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = '$cssPath'; + document.head.appendChild(link); + })(); + """.trimIndent() + + webView.evaluateJavascript(script, null) + } + + /** + * Injects the given JavaScript scripts into the header of the HTML file. + * + * @param scriptPaths Variable number of JavaScript file paths to be injected. + */ + private fun injectScript(miniAppID: String, vararg scriptPaths: String) { + scriptPaths.forEach { scriptPath -> + Log.d("MiniAppPlugin", "Injecting script: $scriptPath") + + val scriptInjection = """ + (function() { + window.miniApps = window.miniApps || {}; // Ensure namespace exists + + let scriptTag = document.createElement('script'); + scriptTag.type = 'text/javascript'; + scriptTag.src = '$scriptPath'; + + scriptTag.onload = function() { + console.log("Loaded script: $scriptPath"); + + if (typeof window.miniApp === 'object' && typeof window.miniApp.handleRequest === 'function') { + console.log("Registering MiniApp: $miniAppID"); + window.miniApps['$miniAppID'] = window.miniApp; // Assign it dynamically + window.miniApp = null; // Prevent global conflicts + } else { + console.error("MiniApp $miniAppID does not define handleRequest correctly."); + } + }; + + scriptTag.onerror = function() { + console.error("Failed to load script: $scriptPath"); + }; + + document.head.appendChild(scriptTag); + })(); + """.trimIndent() + + webView.evaluateJavascript(scriptInjection, null) + } + } + + /** + * Injects the given HTML content into the div with id 'core'. + * + * @param htmlPath Variable number of HTML content strings to be injected. + */ + private fun injectContent(htmlPath: String) { + + try { + Log.d("MiniAppPlugin", "Asset Path: $htmlPath") + val htmlFile = File(htmlPath) + if (htmlFile.exists()) { + val htmlContent = htmlFile.readText(Charsets.UTF_8) + val content = """ + (function() { + var coreDiv = document.getElementById('core'); + if (coreDiv) { + coreDiv.innerHTML += `$htmlContent`; + console.log('HTML content added to core div'); + } else { + console.log('Core div not found'); + } + })(); + """ + webView.evaluateJavascript(content, null) + } else { + Log.e("MiniAppPlugin", "File does not exist: ${htmlFile.absolutePath}") + } + } catch (e: Exception) { + Log.e("InjectContent", "Error loading HTML from assets: $htmlPath", e) + } + } + + /** + * Injects display configurations into the WebView. + * + * @param displayOrNot List of display configurations to be injected. + */ + private fun injectDisplayOrNot(displayOrNot: List) { + + val jsCodeBuilder = StringBuilder() + + displayOrNot.forEach { + jsCodeBuilder.append("display_or_not.push('$it');") + } + + // Evaluate the generated JavaScript code in the WebView + webView.evaluateJavascript(jsCodeBuilder.toString(), null) + } + + /** + * Injects scenario display configurations into the WebView. + * + * @param scenarioDisplay Map of scenario displays to be injected. + */ + private fun injectScenarioDisplay(scenarioDisplay: Map>) { + + val jsCodeBuilder = StringBuilder() + + scenarioDisplay.forEach { (key, values) -> + val valuesJs = values.joinToString(prefix = "[", postfix = "]") { "'$it'" } + jsCodeBuilder.append("scenarioDisplay['$key'] = $valuesJs;") + } + + // Evaluate the generated JavaScript code in the WebView + webView.evaluateJavascript(jsCodeBuilder.toString(), null) + + } + + /** + * Injects scenario menu configurations into the WebView. + * + * @param scenarioMenu Map of scenario menus to be injected. + */ + private fun injectScenarioMenu(scenarioMenu: Map>>) { + + val jsCodeBuilder = StringBuilder() + + scenarioMenu.forEach { (key, values) -> + val valuesJs = values.joinToString(prefix = "[", postfix = "]") { (name, func) -> + "['$name', '$func']" + } + jsCodeBuilder.append("scenarioMenu['$key'] = $valuesJs;") + } + + // Evaluate the generated JavaScript code in the WebView + webView.evaluateJavascript(jsCodeBuilder.toString(), null) + + } + + /** + * Adds a Mini App to the list by loading its manifest data. + * + * @param manifestPath Path to the manifest file. + */ + private fun addMiniAppToList(manifestPath: String) { + try { + val manifestFile = File(manifestPath) + val content = manifestFile.readText(Charsets.UTF_8) + + val quotedContent = JSONObject.quote(content) + + val miniAppID = JSONObject(content).getString("id") + + // Get the icon file path + val iconPath = JSONObject(content).getString("icon") + val fullIconPath = (act.miniAppDirectory?.absolutePath) + "/" + miniAppID + "/" + iconPath + + // Generate absolute path for the icon (inside assets) + val iconAbsolutePath = "file:///android_asset/$fullIconPath" + + // Update the icon path in the manifest content + val updatedContent = JSONObject(content).put("icon", iconAbsolutePath).toString() + + // Quote the updated content for JavaScript processing + val quotedUpdatedContent = JSONObject.quote(updatedContent) + + Log.d("Manifest content", updatedContent) + Log.d("Manifest content (quoted)", quotedUpdatedContent) + + webView.evaluateJavascript("handleManifestContent($quotedUpdatedContent)", null) + } catch (e: Exception) { + Log.e("InjectContent", "Error reading manifest from assets: $manifestPath", e) + } + } + + fun parseNestedJson(obj: JSONObject): Any { + val result = mutableMapOf() + for (key in obj.keys()) { + val value = obj.get(key) + result[key] = if (value is JSONObject) parseNestedJson(value) else value + } + return result + } + +} \ No newline at end of file diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/PluginLoader.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/PluginLoader.kt new file mode 100644 index 00000000..a854184f --- /dev/null +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/PluginLoader.kt @@ -0,0 +1,66 @@ +package nz.scuttlebutt.tremolavossbol.miniapps + +import android.webkit.WebView +import nz.scuttlebutt.tremolavossbol.MainActivity +import java.io.File +import java.io.InputStreamReader + +/** + * PluginLoader class is responsible for loading and initializing MiniApp plugins. + * It searches for manifest files of each MiniApp in the specified directory, + * extracts the class name of the MiniApp plugin, and returns instances of these plugins. + */ +class PluginLoader(val act: MainActivity, val webView: WebView) { + + /** + * Searches in the miniApps folder for all the manifest files of every MiniApp, + * extracts the class name of each MiniApp plugin, and returns a list of plugin instances. + * + * @return List of MiniAppPlugin instances. + */ + fun loadPlugins(): List { + val plugins = mutableListOf() + + webView.settings.allowFileAccess = true + webView.settings.allowFileAccessFromFileURLs = true + webView.settings.allowUniversalAccessFromFileURLs = true + + val miniAppsDir = act.miniAppDirectory?.absolutePath + // Get the list of MiniApp directories using miniAppsDir + val miniApps = miniAppsDir?.let { File(it).list() } + + miniApps?.forEach { miniApp -> + val manifestPath = "$miniAppsDir/$miniApp/manifest.json" + val manifest = manifestPath.let { File(it).readText() } + val pluginClass = extractPluginClass(manifest) + if (pluginClass != null) { + plugins.add(loadPlugin(pluginClass)) + } + } + return plugins + } + + /** + * Extracts the class name of the MiniApp plugin from the manifest content. + * + * @param manifest The manifest content as a JSON string. + * @return The class name of the MiniApp plugin, or null if not found. + */ + private fun extractPluginClass(manifest: String): String? { + // Extract the plugin class name from the manifest JSON + val regex = """"pluginClass"\s*:\s*"([^"]+)"""".toRegex() + return regex.find(manifest)?.groupValues?.get(1) + } + + /** + * Loads and creates an instance of the MiniApp plugin class. + * + * @param pluginClass The class name of the MiniApp plugin. + * @return An instance of the MiniAppPlugin. + */ + private fun loadPlugin(pluginClass: String): MiniAppPlugin { + val clazz = Class.forName(pluginClass) + val constructor = clazz.getConstructor(MainActivity::class.java, WebView::class.java) + return constructor.newInstance(act, webView) as MiniAppPlugin + } +} \ No newline at end of file diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/SketchPlugin.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/SketchPlugin.kt new file mode 100644 index 00000000..da46201e --- /dev/null +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/miniapps/SketchPlugin.kt @@ -0,0 +1,22 @@ +package nz.scuttlebutt.tremolavossbol.miniapps + +import android.util.Log +import android.webkit.WebView +import nz.scuttlebutt.tremolavossbol.MainActivity +import nz.scuttlebutt.tremolavossbol.miniapps.MiniAppPlugin +import org.json.JSONObject +import java.io.File + +/** + * SketchPlugin is a subclass of MiniAppPlugin that provides functionality specific to the Sketch board mini-app. + * This class handles the initialization of Sketch-related UI elements, manages requests from the web view, + * and interacts with the Sketch interface by publishing updates and handling various operations. + */ +class SketchPlugin(act: MainActivity, webView: WebView) : MiniAppPlugin(act, webView) { + + /** + * Initializes the Sketch plugin by injecting JavaScript, and HTML content into the WebView. + * Also sets up the UI elements and configurations specific to the Sketch mini App. + */ + +} \ No newline at end of file diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/utils/Constants.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/utils/Constants.kt index 94db1d41..d8e5ece2 100644 --- a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/utils/Constants.kt +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/utils/Constants.kt @@ -36,6 +36,7 @@ class Constants{ val TINYSSB_APP_IAM = Bipf.mkString("IAM") // str val TINYSSB_APP_NEWTRUSTED = Bipf.mkString("TRT") val TINYSSB_APP_DELETED = Bipf.mkString("DEL") + val TINYSSB_APP_CUSTOM_APP = Bipf.mkString("CUS") // str val TINYSSB_BLE_REPL_SERVICE_2022 = UUID.fromString("6e400001-7646-4b5b-9a50-71becce51558") val TINYSSB_BLE_RX_CHARACTERISTIC = UUID.fromString("6e400002-7646-4b5b-9a50-71becce51558") // for writing to the remote device From 280f43c302a1a21188d95660a71c9c4b6d48c046 Mon Sep 17 00:00:00 2001 From: adgohar <114059470+adgohar@users.noreply.github.com> Date: Fri, 11 Apr 2025 19:27:56 +0200 Subject: [PATCH 2/5] Fix footer UI problem --- .../app/src/main/assets/web/img/miniapps.svg | 6 +- .../app/src/main/assets/web/tremola.html | 8 +- .../app/src/main/assets/web/tremola_ui.js | 174 +++++------------- 3 files changed, 56 insertions(+), 132 deletions(-) diff --git a/android/tinySSB/app/src/main/assets/web/img/miniapps.svg b/android/tinySSB/app/src/main/assets/web/img/miniapps.svg index 892646bd..2e4f104e 100644 --- a/android/tinySSB/app/src/main/assets/web/img/miniapps.svg +++ b/android/tinySSB/app/src/main/assets/web/img/miniapps.svg @@ -2,8 +2,8 @@ -
+
@@ -694,9 +694,9 @@

Other Contacts

-
- - + + +
diff --git a/android/tinySSB/app/src/main/assets/web/tremola_ui.js b/android/tinySSB/app/src/main/assets/web/tremola_ui.js index d4d2e74a..e6e35de2 100644 --- a/android/tinySSB/app/src/main/assets/web/tremola_ui.js +++ b/android/tinySSB/app/src/main/assets/web/tremola_ui.js @@ -104,47 +104,39 @@ function onBackPressed() { console.log("Back button pressed"); console.log("Current MiniApp ID:", currentMiniAppID); if (curr_scenario === 'customApp') { - console.log("Back button pressed inside MiniApp:", currentMiniAppID); - backend(currentMiniAppID + ":onBackPressed"); // Send MiniApp ID to Kotlin - return; + console.log("Back button pressed inside MiniApp:", currentMiniAppID); + backend(currentMiniAppID + ":onBackPressed"); // Send MiniApp ID to Kotlin + return; } - if (curr_scenario == 'settings') { - document.getElementById('div:settings').style.display = 'none'; - document.getElementById('core').style.display = null; - document.getElementById('div:footer').style.display = null; - setScenario(prev_scenario); - return; + if (['chats', 'contacts', 'miniapps'].indexOf(curr_scenario) >= 0) { + if (curr_scenario == 'chats') + backend("onBackPressed"); + else + setScenario('chats') + } else { + if (curr_scenario == 'posts' && prev_scenario == 'chats') { + setScenario('chats'); + } else if (curr_scenario == 'posts' && prev_scenario == 'miniapps') { + setScenario('miniapps'); + } else if (curr_scenario == 'members') { + console.log("prev: " + prev_scenario); + setScenario(prev_scenario); + } else if (curr_scenario == 'settings') { + document.getElementById('div:settings').style.display = 'none'; + document.getElementById('core').style.display = null; + document.getElementById('div:footer').style.display = null; + setScenario(prev_scenario); + } else { + backend("backPressed"); + } + //setScenario(prev_scenario); + } - // console.log('back ' + curr_scenario); - if (curr_scenario == 'chats') - backend("onBackPressed"); - else if (curr_scenario == 'members') - setScenario(prev_scenario) - else if (['chats', 'contacts', 'connex', 'miniapps'].indexOf(curr_scenario) >= 0) - setScenario('chats') - else if (['kanban'].indexOf(curr_scenario) >= 0) { - setScenario('productivity') - prev_scenario = 'chats' - } else if (curr_scenario == 'posts' && prev_scenario == 'chats') { - setScenario('chats'); - } else if (curr_scenario == 'posts' && prev_scenario == 'miniapps') { - setScenario('miniapps'); - } else if (curr_scenario == 'members') { - console.log("prev: " + prev_scenario); - setScenario(prev_scenario); - } else if (curr_scenario == 'settings') { - document.getElementById('div:settings').style.display = 'none'; - document.getElementById('core').style.display = null; - document.getElementById('div:footer').style.display = null; - setScenario(prev_scenario); - } else { - backend("backPressed"); - } } function setScenario(s) { - // console.log('setScenario ' + s) closeOverlay(); + if (s.startsWith('customApp')) { //check if s contains a : if (s.includes(":")) { @@ -157,7 +149,7 @@ function setScenario(s) { } console.log(curr_scenario); - if (curr_scenario === 'customApp' && ['chats', 'contacts', 'connex'].indexOf(s) >= 0) { + if (curr_scenario === 'customApp' && ['chats', 'contacts'].indexOf(s) >= 0) { console.log("Leaving MiniApps section"); currentMiniAppID = null; // Reset only if exiting the entire miniapps section } @@ -192,33 +184,37 @@ function setScenario(s) { } + var lst = scenarioDisplay[s]; + console.log("lst: " + lst); if (lst) { - // if (s != 'posts' && curr_scenario != "members" && curr_scenario != 'posts') { - if (['chats', 'productivity', 'games', 'contacts', 'miniapps'].indexOf(curr_scenario) >= 0) { - var cl = document.getElementById('btn:' + curr_scenario).classList; - cl.toggle('active', false); - cl.toggle('passive', true); + if (['posts', 'chats', 'miniapps', 'contacts'].indexOf(curr_scenario) >= 0) { + const cl = document.getElementById('btn:' + curr_scenario)?.classList; + if (cl) { + console.log("cl scenario: " + curr_scenario); + console.log("cl: " + cl); + cl.remove('active'); + cl.add('passive'); + } } - // console.log(' l: ' + lst) + + display_or_not.forEach(function (d) { - // console.log(' l+' + d); if (lst.indexOf(d) < 0) { document.getElementById(d).style.display = 'none'; } else { document.getElementById(d).style.display = null; - // console.log(' l=' + d); } - }) - // console.log('s: ' + s) - if (s != "board" && s != '') { // show "tinySSB" by default - document.getElementById('tremolaTitle').style.position = null; - } + }); - if (s == "posts" || s == "settings" || s == "board") { + document.getElementById('tremolaTitle').style.position = null; + + if (s == "posts" || s == "settings") { document.getElementById('tremolaTitle').style.display = 'none'; document.getElementById('conversationTitle').style.display = null; - document.getElementById('plus').style.display = 'none'; + if (s == "posts" && curr_scenario != 'posts') { + prev_scenario = curr_scenario; + } } else { document.getElementById('tremolaTitle').style.display = null; document.getElementById('conversationTitle').style.display = 'none'; @@ -226,90 +222,18 @@ function setScenario(s) { if (lst.indexOf('div:qr') >= 0) { prev_scenario = s; } - if (lst.indexOf('div:back') >= 0) { // remember where we came from) - prev_scenario = curr_scenario; - } curr_scenario = s; - - if (['chats', 'productivity', 'games', 'contacts', 'miniapps'].indexOf(curr_scenario) >= 0) { + if (['posts', 'chats', 'miniapps', 'contacts'].indexOf(curr_scenario) >= 0) { var cl = document.getElementById('btn:' + curr_scenario).classList; cl.toggle('active', true); cl.toggle('passive', false); } - if (s == 'chats') { - document.getElementById('tremolaTitle').style.display = null; - document.getElementById('conversationTitle').style.display = 'none'; - /* - c.style.display = null; - c.innerHTML = "List of Chat Channels
Pick or create a channel"; - */ - } - - if (['productivity', 'games', 'contacts'].indexOf(s) >= 0) { - document.getElementById("tremolaTitle").style.display = 'none'; - var c = document.getElementById("conversationTitle"); - c.style.display = null; - var t = s.slice(0,1).toUpperCase() + s.slice(1); - c.innerHTML = `${t}`; - } - - if (['board','kanban'].indexOf(s) >= 0) // a specific Kanban board: use all space (beyond the footer) - document.getElementById('core').style.height = 'calc(100% - 45pt)'; - else - document.getElementById('core').style.height = 'calc(100% - 110pt)'; - - if (s == 'kanban') { - load_kanban_list(); - - document.getElementById("tremolaTitle").style.display = 'none'; - var c = document.getElementById("conversationTitle"); - c.style.display = null; - c.innerHTML = "List of Kanban Boards
Pick or create a board"; - - var personalBoardAlreadyExists = false - for (var b in tremola.board) { - var board = tremola.board[b] - if (board.flags.indexOf(FLAG.PERSONAL) >= 0 && board.members.length == 1 && board.members[0] == myId) { - personalBoardAlreadyExists = true - break - } - } - if(!personalBoardAlreadyExists && display_create_personal_board) { - menu_create_personal_board() - } - } - - if (s == 'posts') { - setTimeout(function () { // let image rendering (fetching size) take place before we scroll - let c = document.getElementById('core'); - c.scrollTop = c.scrollHeight; - let p = document.getElementById('div:posts'); - p.scrollTop = p.scrollHeight; - }, 100); - } - - if (s == 'tictactoe-list') { - document.getElementById("tremolaTitle").style.display = 'none'; - var c = document.getElementById("conversationTitle"); - c.style.display = null; - c.innerHTML = "Tic Tac Toe
Pick or create a new game"; - ttt_load_list(); - } - if (s == 'tictactoe-board') { - document.getElementById("tremolaTitle").style.display = 'none'; - var c = document.getElementById("conversationTitle"); - c.style.display = null; - let fed = tremola.tictactoe.active[tremola.tictactoe.current].peer - c.innerHTML = `TTT with ${fid2display(fed)}`; - } } } function btnBridge(e) { var e = e.id, m = ''; - if (['btn:chats', 'btn:contacts', 'btn:games', - 'btn:posts', 'btn:productivity', 'btn:miniapps'].indexOf(e) >= 0) { - // console.log('btn', e) + if (['btn:posts', 'btn:chats', 'btn:miniapps', 'btn:contacts'].indexOf(e) >= 0) { setScenario(e.substring(4)); } if (e == 'btn:menu') { From d6676a7c507eaefcc6a5b5b08bd5efc0016c5db3 Mon Sep 17 00:00:00 2001 From: adgohar <114059470+adgohar@users.noreply.github.com> Date: Tue, 22 Apr 2025 23:27:10 +0200 Subject: [PATCH 3/5] Add Wuff MiniApp to tremola4chrome environment + Fix bugs for miniapps --- .../main/assets/web/miniapps/miniAppHelper.js | 1 + .../app/src/main/assets/web/tremola_ui.js | 19 ++-------- .../tremolavossbol/WebAppInterface.kt | 16 +++++++++ .../miniApps/wuff/assets/wuff.svg | 1 + .../miniApps/wuff/manifest.json | 24 +++++++++++++ .../miniApps/wuff/resources/wuff.css | 27 ++++++++++++++ .../miniApps/wuff/resources/wuff.html | 9 +++++ .../tremola4chrome/miniApps/wuff/src/wuff.js | 35 +++++++++++++++++++ android/tremola4chrome/src/miniAppHelper.js | 1 + android/tremola4chrome/src/tremola_ui.js | 1 + 10 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 android/tremola4chrome/miniApps/wuff/assets/wuff.svg create mode 100644 android/tremola4chrome/miniApps/wuff/manifest.json create mode 100644 android/tremola4chrome/miniApps/wuff/resources/wuff.css create mode 100644 android/tremola4chrome/miniApps/wuff/resources/wuff.html create mode 100644 android/tremola4chrome/miniApps/wuff/src/wuff.js diff --git a/android/tinySSB/app/src/main/assets/web/miniapps/miniAppHelper.js b/android/tinySSB/app/src/main/assets/web/miniapps/miniAppHelper.js index be21bfbe..3a3712a4 100644 --- a/android/tinySSB/app/src/main/assets/web/miniapps/miniAppHelper.js +++ b/android/tinySSB/app/src/main/assets/web/miniapps/miniAppHelper.js @@ -6,6 +6,7 @@ function getContactsList() { function writeLogEntry(entry) { // Write a log entry to the native code, use currentMiniAppID to identify the app // entry is a JSONString + console.log("writeLogEntry " + currentMiniAppID + " " + entry); backend("customApp:writeEntry " + currentMiniAppID + " " + entry); } diff --git a/android/tinySSB/app/src/main/assets/web/tremola_ui.js b/android/tinySSB/app/src/main/assets/web/tremola_ui.js index e6e35de2..0177f61c 100644 --- a/android/tinySSB/app/src/main/assets/web/tremola_ui.js +++ b/android/tinySSB/app/src/main/assets/web/tremola_ui.js @@ -29,10 +29,8 @@ var scenarioDisplay = { 'settings': ['div:back', 'core', 'div:settings'], 'kanban': ['div:back', 'core', 'lst:kanban', 'plus'], // KANBAN 'board': ['div:back', 'core', 'div:board'], // KANBAN - 'miniapps': ['div:qr', 'core', 'lst:miniapps', 'div:footer'], - 'tictactoe-list': ['div:back', 'core', 'div:tictactoe_list', 'plus'], - 'tictactoe-board': ['div:back', 'core', 'div:tictactoe_board'], -} + 'miniapps': ['div:qr', 'core', 'lst:miniapps', 'div:footer'] + } var scenarioMenu = { 'chats': [ @@ -77,13 +75,6 @@ var scenarioMenu = { ['Leave', 'leave_curr_board'], ['(un)Forget', 'board_toggle_forget'], ['Debug', 'ui_debug']], - - 'tictactoe-list': [ - ['Settings', 'menu_settings'], - ['About', 'menu_about']], - 'tictactoe-board': [ - ['Settings', 'menu_settings'], - ['About', 'menu_about']], } const QR_SCAN_TARGET = { @@ -223,7 +214,7 @@ function setScenario(s) { prev_scenario = s; } curr_scenario = s; - if (['posts', 'chats', 'miniapps', 'contacts'].indexOf(curr_scenario) >= 0) { + if (['chats', 'miniapps', 'contacts'].indexOf(curr_scenario) >= 0) { var cl = document.getElementById('btn:' + curr_scenario).classList; cl.toggle('active', true); cl.toggle('passive', false); @@ -354,10 +345,6 @@ function plus_button() { menu_new_conversation(); } else if (curr_scenario == 'contacts') { menu_new_contact(); - } else if (curr_scenario == 'kanban') { - menu_new_board(); - } else if (curr_scenario == 'tictactoe-list') { - ttt_new_game(); } else { console.log(currentMiniAppID); if (currentMiniAppID) { diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt index 30fb06b0..5b464fed 100644 --- a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt @@ -930,6 +930,22 @@ class WebAppInterface(val act: MainActivity, val webView: WebView) { fun sendTinyEventToFrontend(fid: ByteArray, seq: Int, mid:ByteArray, body: ByteArray) { // Log.d("wai","sendTinyEvent ${body.toHex()}") + //Check if first entry is "CUS" + val entry = Bipf.bipf_loads(body) + if (entry != null) { + val entryList = Bipf.bipf_list2JSON(entry) + if (entryList != null) { + val entryType = entryList.get(0) + if (entryType == "CUS") { + val customAppID = entryList.get(1) + val customAppData = entryList.get(2) + val arguments: MutableList = ArrayList() + arguments.add("$customAppID:incoming_notification") + arguments.add(customAppData.toString()) + MiniAppPlugin(act, webView).handleRequest(arguments) + } + } + } var e = toFrontendObject(fid, seq, mid, body) if (e != null) { val trust = getContactTrust(fid.toHex()) diff --git a/android/tremola4chrome/miniApps/wuff/assets/wuff.svg b/android/tremola4chrome/miniApps/wuff/assets/wuff.svg new file mode 100644 index 00000000..18af2d09 --- /dev/null +++ b/android/tremola4chrome/miniApps/wuff/assets/wuff.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/tremola4chrome/miniApps/wuff/manifest.json b/android/tremola4chrome/miniApps/wuff/manifest.json new file mode 100644 index 00000000..19e8c110 --- /dev/null +++ b/android/tremola4chrome/miniApps/wuff/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Wuff", + "description": "Wuff is a simple application that allows users to express their emotions through a button click.", + "icon": "assets/wuff.svg", + "init": "initWuff()", + "id": "wuff", + "cssFile": "resources/wuff.css", + "scripts": [ + "src/wuff.js" + ], + "htmlFile": "resources/wuff.html", + "scenario": { + "wuff-screen": { + "menu": { + "Settings": "menu_settings", + "About": "menu_about" + }, + "display": [ + "div:back", + "div:wuff-screen" + ] + } + } + } \ No newline at end of file diff --git a/android/tremola4chrome/miniApps/wuff/resources/wuff.css b/android/tremola4chrome/miniApps/wuff/resources/wuff.css new file mode 100644 index 00000000..67dd4460 --- /dev/null +++ b/android/tremola4chrome/miniApps/wuff/resources/wuff.css @@ -0,0 +1,27 @@ +#div\:wuff-screen { + position: absolute; + top: 50%; + left: 50%; + background: rgba(255, 255, 255, 0.9); + border-radius: 12px; + padding: 24px; + text-align: center; + width: 30vw; + } + + #wuff-title { + font-size: 20px; + font-weight: 600; + margin-bottom: 12px; + } + + #div\:wuff-prompt { + font-size: 16px; + opacity: 0; + transition: opacity 0.5s ease; + margin-bottom: 16px; + } + + #div\:wuff-screen button { + color: black; + } diff --git a/android/tremola4chrome/miniApps/wuff/resources/wuff.html b/android/tremola4chrome/miniApps/wuff/resources/wuff.html new file mode 100644 index 00000000..727702e7 --- /dev/null +++ b/android/tremola4chrome/miniApps/wuff/resources/wuff.html @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/android/tremola4chrome/miniApps/wuff/src/wuff.js b/android/tremola4chrome/miniApps/wuff/src/wuff.js new file mode 100644 index 00000000..8d61cbcd --- /dev/null +++ b/android/tremola4chrome/miniApps/wuff/src/wuff.js @@ -0,0 +1,35 @@ +globalWindow.miniApps["wuff"] = { + handleRequest: function(command, args) { + console.log("Wuff handling request:", command); + switch (command) { + case "incoming_notification": + console.log("Wuff incoming_notification:", JSON.stringify(args, null, 2)); + handleWuff(args.args); + break; + } + return "Response from Wuff"; + } +}; + +function initWuff() { + console.log("Initializing Wuff app..."); +} + +function handleWuff(args) { + if (args && args[0].type === "Wuff") { + showWuffPrompt(); + } +} +function showWuffPrompt() { + const wuffPrompt = document.getElementById('div:wuff-prompt'); + wuffPrompt.style.opacity = 1; + + setTimeout(() => { + wuffPrompt.style.opacity = 0; + }, 1000); +} + +function registerWuff() { + let json = { type: 'Wuff'}; + writeLogEntry(JSON.stringify(json)); +} \ No newline at end of file diff --git a/android/tremola4chrome/src/miniAppHelper.js b/android/tremola4chrome/src/miniAppHelper.js index be21bfbe..3a3712a4 100644 --- a/android/tremola4chrome/src/miniAppHelper.js +++ b/android/tremola4chrome/src/miniAppHelper.js @@ -6,6 +6,7 @@ function getContactsList() { function writeLogEntry(entry) { // Write a log entry to the native code, use currentMiniAppID to identify the app // entry is a JSONString + console.log("writeLogEntry " + currentMiniAppID + " " + entry); backend("customApp:writeEntry " + currentMiniAppID + " " + entry); } diff --git a/android/tremola4chrome/src/tremola_ui.js b/android/tremola4chrome/src/tremola_ui.js index aa5c2efc..35b0b788 100644 --- a/android/tremola4chrome/src/tremola_ui.js +++ b/android/tremola4chrome/src/tremola_ui.js @@ -153,6 +153,7 @@ function setScenario(s) { if (filtered.includes('div:' + key)) { if (scenarioDisplay[key].includes(d)) { console.log("found: " + d); + document.getElementById(d).style.display = "initial"; // Show the element found = true; // Mark as found break; // Exit inner loop } From ba1f086c1b8ebfcbd599827b95a2cb876a90d961 Mon Sep 17 00:00:00 2001 From: adgohar <114059470+adgohar@users.noreply.github.com> Date: Wed, 23 Apr 2025 13:48:27 +0200 Subject: [PATCH 4/5] Allow automatic display of MiniApp's screen --- .../app/src/main/assets/web/tremola_ui.js | 1 + .../miniApps/tictactoe/src/ttt.js | 2 +- .../miniApps/wuff/manifest.json | 1 + .../tremola4chrome/miniApps/wuff/src/wuff.js | 24 ++++++++++++------- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/android/tinySSB/app/src/main/assets/web/tremola_ui.js b/android/tinySSB/app/src/main/assets/web/tremola_ui.js index 0177f61c..427ca983 100644 --- a/android/tinySSB/app/src/main/assets/web/tremola_ui.js +++ b/android/tinySSB/app/src/main/assets/web/tremola_ui.js @@ -161,6 +161,7 @@ function setScenario(s) { if (filtered.includes('div:' + key)) { if (scenarioDisplay[key].includes(d)) { console.log("found: " + d); + document.getElementById(d).style.display = "initial"; found = true; // Mark as found break; // Exit inner loop } diff --git a/android/tremola4chrome/miniApps/tictactoe/src/ttt.js b/android/tremola4chrome/miniApps/tictactoe/src/ttt.js index d4952f71..da70ebcc 100644 --- a/android/tremola4chrome/miniApps/tictactoe/src/ttt.js +++ b/android/tremola4chrome/miniApps/tictactoe/src/ttt.js @@ -170,7 +170,7 @@ function ttt_load_board(nm) { tab.appendChild(item); } tremola.tictactoe.current = nm; - setTTTScenario('tictactoe-board') + setTTTScenario('tictactoe-board'); } function ttt_new_game() { diff --git a/android/tremola4chrome/miniApps/wuff/manifest.json b/android/tremola4chrome/miniApps/wuff/manifest.json index 19e8c110..6677714a 100644 --- a/android/tremola4chrome/miniApps/wuff/manifest.json +++ b/android/tremola4chrome/miniApps/wuff/manifest.json @@ -17,6 +17,7 @@ }, "display": [ "div:back", + "core", "div:wuff-screen" ] } diff --git a/android/tremola4chrome/miniApps/wuff/src/wuff.js b/android/tremola4chrome/miniApps/wuff/src/wuff.js index 8d61cbcd..079547cd 100644 --- a/android/tremola4chrome/miniApps/wuff/src/wuff.js +++ b/android/tremola4chrome/miniApps/wuff/src/wuff.js @@ -1,11 +1,14 @@ -globalWindow.miniApps["wuff"] = { +window.miniApps["wuff"] = { handleRequest: function(command, args) { console.log("Wuff handling request:", command); switch (command) { - case "incoming_notification": - console.log("Wuff incoming_notification:", JSON.stringify(args, null, 2)); - handleWuff(args.args); - break; + case "onBackPressed": + quitApp(); + break; + case "incoming_notification": + console.log("Wuff incoming_notification:", JSON.stringify(args, null, 2)); + handleWuff(args.args); + break; } return "Response from Wuff"; } @@ -15,11 +18,14 @@ function initWuff() { console.log("Initializing Wuff app..."); } -function handleWuff(args) { - if (args && args[0].type === "Wuff") { - showWuffPrompt(); +function handleWuff(raw) { + const args = typeof raw === "string" ? JSON.parse(raw) : raw; + console.log("Handling Wuff args:", args); + console.log("Wuff Type: " + args[0].type); + if (args[0].type === "Wuff") { + showWuffPrompt(); } -} + } function showWuffPrompt() { const wuffPrompt = document.getElementById('div:wuff-prompt'); wuffPrompt.style.opacity = 1; From 4b6ef75b9238d9004b2a5821e0be12a972a3e1d0 Mon Sep 17 00:00:00 2001 From: adgohar <114059470+adgohar@users.noreply.github.com> Date: Wed, 25 Jun 2025 18:58:47 +0200 Subject: [PATCH 5/5] Fix Tic-Tac-Toe on Tremola4Chrome --- .../miniApps/tictactoe/src/ttt.js | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/android/tremola4chrome/miniApps/tictactoe/src/ttt.js b/android/tremola4chrome/miniApps/tictactoe/src/ttt.js index da70ebcc..c796387d 100644 --- a/android/tremola4chrome/miniApps/tictactoe/src/ttt.js +++ b/android/tremola4chrome/miniApps/tictactoe/src/ttt.js @@ -140,7 +140,7 @@ function ttt_load_board(nm) { } let t = document.getElementById('ttt_title'); if (g.state == 'open') { - let m = (g.cnt % 2) ? "my turn ..." : "... not my turn" ; + let m = (g.cnt % 2) ? "... not my turn" : "my turn ..."; t.innerHTML = `${m}`; } else { // must be in close state var m; @@ -217,18 +217,36 @@ function ttt_list_callback(nm,action) { } function ttt_cellclick(id) { - // console.log("clicked " + id + ' ' + id[3]); - let nm = tremola.tictactoe.current - let g = tremola.tictactoe.active[nm] - console.log("clicked " + id + ' ' + id[3] + ' ' + g.state + ' ' + g.cnt); - if (g.state != 'open' || (g.cnt % 2) != 1) + console.log("clicked", id, id[3]); + + const nm = tremola.tictactoe.current; + const g = tremola.tictactoe.active[nm]; + console.log("state:", g.state, "cnt:", g.cnt); + + if (g.state != 'open' || (g.cnt % 2) != 0) { return; - let i = parseInt(id[3], 10); - if (g.board[i] == 0) { - let json = { type: 'M', nm: nm, i: i, from: myId }; - writeLogEntry(JSON.stringify(json)); + } + + const i = parseInt(id[3], 10); + if (g.board[i] != 0) { + return; + } + + g.board[i] = 1; + g.cnt++; + if (ttt_winning(g.board)) { + g.state = 'closed'; + g.close_reason = ttt_iwon; + } + persist(); + + if (TTTScenario == 'tictactoe-board') { + ttt_load_board(nm); } + + const json = { type: 'M', nm: nm, i: i, from: myId }; + writeLogEntry(JSON.stringify(json)); } function ttt_on_rx(args, index=0) { @@ -274,7 +292,7 @@ function ttt_on_rx(args, index=0) { // TODO: check that the turn is made by the right party, raise violation error otherwise console.log("move " + args[index].i + " " + args[index].from); let ndx = parseInt(args[index].i,10); - g.board[ndx] = args[index].from == myId ? -1 : 1; + g.board[ndx] = args[index].from == myId ? 1 : -1; g.cnt++; if (ttt_winning(g.board)) { g.state = 'closed'