From 9dc177f5271cde91701752a8e584dc1aeb3e3aa0 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Thu, 9 Jul 2026 02:07:03 +1000 Subject: [PATCH] fix: update version code and name in build.gradle; register PluginStoragePlugin in MainActivity; enhance text duplication handling in Editor component --- android/app/build.gradle | 4 +- .../java/com/paarrot/app/MainActivity.java | 1 + .../com/paarrot/app/PluginStoragePlugin.kt | 250 ++++++++++++++++++ overlay/src/app/components/editor/Editor.tsx | 50 ++-- .../features/settings/plugins/PluginAPI.ts | 149 +++++++++++ .../settings/plugins/PluginLoader.tsx | 190 +++++++++++++ overlay/src/app/utils/pluginHost.ts | 72 +++++ 7 files changed, 698 insertions(+), 18 deletions(-) create mode 100644 android/app/src/main/java/com/paarrot/app/PluginStoragePlugin.kt create mode 100644 overlay/src/app/features/settings/plugins/PluginAPI.ts create mode 100644 overlay/src/app/features/settings/plugins/PluginLoader.tsx create mode 100644 overlay/src/app/utils/pluginHost.ts diff --git a/android/app/build.gradle b/android/app/build.gradle index f4401ef..14dcf07 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -27,8 +27,8 @@ android { applicationId "com.paarrot.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1778632862 - versionName "2026-05-13.004102.796" + versionCode 1783526741 + versionName "2026-07-08.160541.766" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/java/com/paarrot/app/MainActivity.java b/android/app/src/main/java/com/paarrot/app/MainActivity.java index 0ca8fdc..23d598d 100644 --- a/android/app/src/main/java/com/paarrot/app/MainActivity.java +++ b/android/app/src/main/java/com/paarrot/app/MainActivity.java @@ -8,6 +8,7 @@ public class MainActivity extends BridgeActivity { protected void onCreate(Bundle savedInstanceState) { registerPlugin(SyncServicePlugin.class); registerPlugin(ShareHandlerPlugin.class); + registerPlugin(PluginStoragePlugin.class); super.onCreate(savedInstanceState); } } \ No newline at end of file diff --git a/android/app/src/main/java/com/paarrot/app/PluginStoragePlugin.kt b/android/app/src/main/java/com/paarrot/app/PluginStoragePlugin.kt new file mode 100644 index 0000000..d7ab0f5 --- /dev/null +++ b/android/app/src/main/java/com/paarrot/app/PluginStoragePlugin.kt @@ -0,0 +1,250 @@ +package com.paarrot.app + +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipInputStream + +/** Capacitor plugin that stores and manages user extension plugins on Android. */ +@CapacitorPlugin(name = "PluginStorage") +class PluginStoragePlugin : Plugin() { + + private fun pluginsDir(): File { + val dir = File(context.filesDir, "plugins") + dir.mkdirs() + return dir + } + + @PluginMethod + fun getPath(call: PluginCall) { + val result = JSObject() + result.put("success", true) + result.put("data", pluginsDir().absolutePath) + call.resolve(result) + } + + @PluginMethod + fun list(call: PluginCall) { + try { + val plugins = pluginsDir().listFiles()?.filter { it.isDirectory } ?: emptyList() + val array = JSArray() + + for (dir in plugins) { + val metadata = readMetadata(dir) + val obj = JSObject() + obj.put("id", dir.name) + obj.put("name", metadata?.optString("name", dir.name) ?: dir.name) + obj.put("version", metadata?.optString("version", "") ?: "") + obj.put("description", metadata?.optString("description", "") ?: "") + obj.put("author", metadata?.optString("author", "") ?: "") + obj.put("repository", metadata?.optString("repository", "") ?: "") + obj.put("thumbnail", metadata?.optString("thumbnail", "") ?: "") + obj.put("homepage", metadata?.optString("homepage", "") ?: "") + obj.put("tags", metadataTagsToJsArray(metadata)) + obj.put("installedDate", formatInstalledDate(dir.lastModified())) + obj.put("path", dir.absolutePath) + array.put(obj) + } + + val result = JSObject() + result.put("success", true) + result.put("data", array) + call.resolve(result) + } catch (e: Exception) { + val result = JSObject() + result.put("success", false) + result.put("error", e.message ?: "Failed to list plugins") + call.resolve(result) + } + } + + @PluginMethod + fun download(call: PluginCall) { + val pluginId = call.getString("pluginId") + ?: return call.reject("pluginId required") + val downloadUrl = call.getString("downloadUrl") + ?: return call.reject("downloadUrl required") + val name = call.getString("name") ?: pluginId + + Thread { + try { + val destDir = File(pluginsDir(), pluginId) + if (destDir.exists()) { + destDir.deleteRecursively() + } + destDir.mkdirs() + + val tempZip = File(context.cacheDir, "plugin-download-$pluginId.zip") + downloadFile(downloadUrl, tempZip) + extractZip(tempZip, destDir) + tempZip.delete() + + normalisePluginLayout(destDir) + + if (!File(destDir, "index.js").exists()) { + val result = JSObject() + result.put("success", false) + result.put("error", "Downloaded plugin is missing index.js") + call.resolve(result) + return@Thread + } + + val result = JSObject() + result.put("success", true) + val data = JSObject() + data.put("path", destDir.absolutePath) + result.put("data", data) + call.resolve(result) + } catch (e: Exception) { + val result = JSObject() + result.put("success", false) + result.put("error", e.message ?: "Failed to download plugin: $name") + call.resolve(result) + } + }.start() + } + + @PluginMethod + fun uninstall(call: PluginCall) { + val pluginId = call.getString("pluginId") + ?: return call.reject("pluginId required") + + try { + val destDir = File(pluginsDir(), pluginId) + if (destDir.exists()) { + destDir.deleteRecursively() + } + + val result = JSObject() + result.put("success", true) + call.resolve(result) + } catch (e: Exception) { + val result = JSObject() + result.put("success", false) + result.put("error", e.message ?: "Failed to uninstall plugin") + call.resolve(result) + } + } + + @PluginMethod + fun readPluginCode(call: PluginCall) { + val pluginId = call.getString("pluginId") + ?: return call.reject("pluginId required") + + val indexFile = File(pluginsDir(), "$pluginId/index.js") + if (!indexFile.exists()) { + val result = JSObject() + result.put("success", false) + result.put("error", "Plugin not found: $pluginId") + call.resolve(result) + return + } + + val result = JSObject() + result.put("success", true) + result.put("data", indexFile.readText()) + call.resolve(result) + } + + private fun readMetadata(pluginDir: File): JSONObject? { + val metadataFile = File(pluginDir, "plugin-metadata.json") + if (!metadataFile.exists()) return null + + return try { + JSONObject(metadataFile.readText()) + } catch (_: Exception) { + null + } + } + + private fun metadataTagsToJsArray(metadata: JSONObject?): JSArray { + val tags = JSArray() + val jsonTags = metadata?.optJSONArray("tags") ?: return tags + for (i in 0 until jsonTags.length()) { + tags.put(jsonTags.optString(i)) + } + return tags + } + + private fun formatInstalledDate(timestamp: Long): String { + val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + return formatter.format(Date(timestamp)) + } + + private fun downloadFile(downloadUrl: String, destFile: File) { + val connection = (URL(downloadUrl).openConnection() as HttpURLConnection).apply { + connectTimeout = 30_000 + readTimeout = 60_000 + instanceFollowRedirects = true + requestMethod = "GET" + } + + try { + connection.connect() + if (connection.responseCode !in 200..299) { + throw IllegalStateException("HTTP ${connection.responseCode}") + } + + connection.inputStream.use { input -> + FileOutputStream(destFile).use { output -> + input.copyTo(output) + } + } + } finally { + connection.disconnect() + } + } + + private fun extractZip(zipFile: File, destDir: File) { + destDir.mkdirs() + val destPath = destDir.canonicalPath + File.separator + + ZipInputStream(zipFile.inputStream()).use { zipInput -> + var entry = zipInput.nextEntry + while (entry != null) { + val outFile = File(destDir, entry.name) + if (!outFile.canonicalPath.startsWith(destPath)) { + throw SecurityException("Zip entry outside target directory") + } + + if (entry.isDirectory) { + outFile.mkdirs() + } else { + outFile.parentFile?.mkdirs() + FileOutputStream(outFile).use { output -> + zipInput.copyTo(output) + } + } + + zipInput.closeEntry() + entry = zipInput.nextEntry + } + } + } + + /** Flatten single-root zip layouts so index.js ends up directly under the plugin dir. */ + private fun normalisePluginLayout(destDir: File) { + if (File(destDir, "index.js").exists()) return + + val children = destDir.listFiles()?.filter { it.isDirectory } ?: return + if (children.size != 1) return + + val nested = children[0] + nested.listFiles()?.forEach { child -> + child.renameTo(File(destDir, child.name)) + } + nested.deleteRecursively() + } +} diff --git a/overlay/src/app/components/editor/Editor.tsx b/overlay/src/app/components/editor/Editor.tsx index 1a3d96b..7cdb027 100644 --- a/overlay/src/app/components/editor/Editor.tsx +++ b/overlay/src/app/components/editor/Editor.tsx @@ -18,6 +18,7 @@ import { RenderLeafProps, RenderElementProps, RenderPlaceholderProps, + ReactEditor, } from 'slate-react'; import { withHistory } from 'slate-history'; import { BlockType } from './types'; @@ -172,27 +173,44 @@ export const CustomEditor = forwardRef( const handleBeforeInput = useCallback( (event: Event) => { const inputEvent = event as InputEvent; - - // Handle autocorrect replacement that causes text duplication - if (inputEvent.inputType === 'insertReplacementText' || - inputEvent.inputType === 'insertFromComposition') { + + // Handle mobile autocorrect replacement that causes text duplication in Slate + if (inputEvent.inputType === 'insertReplacementText') { + const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain'); + if (!data) return; + + event.preventDefault(); + + const domRanges = inputEvent.getTargetRanges?.() ?? []; + if (domRanges.length > 0) { + const slateRange = ReactEditor.toSlateRange(editor, domRanges[0], { + exactMatch: false, + suppressThrow: true, + }); + + if (slateRange) { + Transforms.select(editor, slateRange); + Transforms.delete(editor); + editor.insertText(data); + return; + } + } + const { selection } = editor; if (!selection) return; - // Get the data being inserted - const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain'); - - if (data) { - event.preventDefault(); - - // If there's selected text, delete it first - if (selection && !Range.isCollapsed(selection)) { - Transforms.delete(editor, { at: selection }); + if (!Range.isCollapsed(selection)) { + Transforms.delete(editor, { at: selection }); + } else { + const wordBefore = Editor.before(editor, selection.anchor, { unit: 'word' }); + if (wordBefore) { + const wordRange = { anchor: wordBefore, focus: selection.anchor }; + Transforms.select(editor, wordRange); + Transforms.delete(editor); } - - // Insert the replacement text - editor.insertText(data); } + + editor.insertText(data); } }, [editor] diff --git a/overlay/src/app/features/settings/plugins/PluginAPI.ts b/overlay/src/app/features/settings/plugins/PluginAPI.ts new file mode 100644 index 0000000..48372b9 --- /dev/null +++ b/overlay/src/app/features/settings/plugins/PluginAPI.ts @@ -0,0 +1,149 @@ +/** + * Re-exports the full @paarrot/plugin-manager API and creates the shared + * singleton PluginRegistry instance for this application. + * + * All other modules in this folder should import from here rather than + * directly from the package so the singleton is guaranteed to be shared. + */ +export { + PluginMarketplaceClient, + PluginMarketplaceManager, + PluginRegistry, + createPluginContext, + generateThemeCSS, + MemoryStorage, +} from '@paarrot/plugin-manager'; + +export type { + IPluginStorage, + IPluginEventClient, + PluginMarketplaceClientOptions, + PluginMarketplaceHostAdapter, + PluginMarketplaceInstalledRecord, + PluginMarketplaceManagerOptions, + PluginRegistryOptions, + PluginContextOptions, + PluginMetadata, + InstalledPlugin, + PluginIndex, + ThemeColorGroup, + ThemePaletteGroup, + PluginThemeColors, + PluginTheme, + CommandArg, + PluginCommand, + MessageInterceptor, + MessageContext, + CustomRenderer, + SettingDefinition, + SettingsSchema, + NotificationOptions, + PluginContext, + PluginSettingsSection, + UILocation, + UIButtonDefinition, + UIButtonPosition, + Plugin, + PluginLogEntry, +} from '@paarrot/plugin-manager'; + +export { PluginTab } from '@paarrot/plugin-manager'; + +import { + PluginMarketplaceClient, + PluginMarketplaceManager, + PluginRegistry, +} from '@paarrot/plugin-manager'; +import { getPluginHost } from '../../../utils/pluginHost'; + +const PLUGIN_INDEX_URL = + import.meta.env.VITE_PLUGIN_INDEX_URL || + `https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${(Math.random()*256).toString()}`; +const PLUGIN_BASE_URL = + import.meta.env.VITE_PLUGIN_BASE_URL || + 'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/'; + +function injectThemeStyle(styleId: string, css: string): void { + let el = document.getElementById(styleId) as HTMLStyleElement | null; + if (!el) { + el = document.createElement('style'); + el.id = styleId; + document.head.appendChild(el); + } + el.textContent = css; +} + +function removeThemeStyle(styleId: string): void { + document.getElementById(styleId)?.remove(); +} + +/** Shared singleton registry for the entire application. */ +export const pluginRegistry = new PluginRegistry({ + storage: localStorage, + onThemeRegistered: (themeId, _className, css) => + injectThemeStyle(`plugin-theme-${themeId}`, css), + onThemeUnregistered: (themeId) => removeThemeStyle(`plugin-theme-${themeId}`), +}); + +/** Shared marketplace client for remote plugin directory reads. */ +export const pluginMarketplaceClient = new PluginMarketplaceClient({ + indexUrl: PLUGIN_INDEX_URL, + baseUrl: PLUGIN_BASE_URL, +}); + +/** + * Shared marketplace manager for install state, directory fetch, and enable flags. + */ +export const pluginMarketplaceManager = new PluginMarketplaceManager({ + client: pluginMarketplaceClient, + storage: localStorage, + registry: pluginRegistry, + host: { + listInstalledPlugins: async () => { + const host = getPluginHost(); + if (!host) { + return []; + } + + const result = await host.list(); + if (!result.success || !result.data) { + throw new Error(result.error ?? 'Failed to list installed plugins'); + } + + return result.data.map((plugin) => ({ + id: plugin.id, + name: plugin.name, + version: plugin.version, + description: plugin.description, + author: plugin.author, + repository: plugin.repository, + thumbnail: plugin.thumbnail, + homepage: plugin.homepage, + tags: plugin.tags, + installedDate: plugin.installedDate, + })); + }, + installPlugin: async (plugin) => { + const host = getPluginHost(); + if (!host) { + return; + } + + const result = await host.download(plugin.id, plugin.downloadUrl, plugin.name); + if (!result.success) { + throw new Error(result.error ?? 'Failed to install plugin'); + } + }, + uninstallPlugin: async (pluginId) => { + const host = getPluginHost(); + if (!host) { + return; + } + + const result = await host.uninstall(pluginId); + if (!result.success) { + throw new Error(result.error ?? 'Failed to uninstall plugin'); + } + }, + }, +}); diff --git a/overlay/src/app/features/settings/plugins/PluginLoader.tsx b/overlay/src/app/features/settings/plugins/PluginLoader.tsx new file mode 100644 index 0000000..3f3f41b --- /dev/null +++ b/overlay/src/app/features/settings/plugins/PluginLoader.tsx @@ -0,0 +1,190 @@ +import React, { useEffect } from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import { Plugin, createPluginContext } from '@paarrot/plugin-manager'; +import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI'; +import { sendNotification } from '../../../utils/tauri'; +import { getPluginHost } from '../../../utils/pluginHost'; +import { dispatchPluginButtonsChanged } from './PluginButtonSlot'; + +interface PluginLoaderProps { + matrixClient: MatrixClient; + children: React.ReactNode; +} + +/** + * Component that loads and initializes enabled plugins. + * Should be placed high in the component tree after MatrixClient is available. + */ +export function PluginLoader({ matrixClient, children }: PluginLoaderProps) { + console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient); + + useEffect(() => { + const loadPlugins = async () => { + try { + const pluginHost = getPluginHost(); + console.log('[PluginLoader] Loading plugins...'); + console.log('[PluginLoader] plugin host available:', !!pluginHost); + + if (!pluginHost) { + console.log('[PluginLoader] Running in web mode, plugins not supported'); + return; + } + + const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins(); + + console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length); + console.log('[PluginLoader] Plugin list:', pluginsToLoad.map((p) => p.id)); + + for (const installedPlugin of pluginsToLoad) { + try { + console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`); + + const pluginModule = await loadPluginModule(installedPlugin.id); + const plugin = normalisePlugin(pluginModule); + + if (!plugin || typeof plugin !== 'object') { + console.error( + `[PluginLoader] Plugin ${installedPlugin.id} does not export a valid plugin object` + ); + continue; + } + + if (typeof plugin.onLoad !== 'function') { + console.error( + `[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function` + ); + continue; + } + + const context = createPluginContext( + { + pluginId: installedPlugin.id, + eventClient: matrixClient as any, + onNotify: async (opts) => { + await sendNotification({ + title: opts.title, + body: opts.body, + path: undefined, + }); + }, + }, + pluginRegistry + ); + + const compatContext = Object.assign(context as Record, { + matrixClient, + matrix: { + on: (eventType: string, handler: (...args: unknown[]) => void) => + matrixClient.on(eventType as any, handler as any), + off: (eventType: string, handler: (...args: unknown[]) => void) => + matrixClient.off(eventType as any, handler as any), + }, + React, + }); + + console.log('[PluginLoader] compatContext.React:', !!compatContext.React); + console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui); + console.log('[PluginLoader] About to register plugin and call onLoad...'); + + pluginRegistry.registerPlugin(installedPlugin.id, plugin, context); + await plugin.onLoad(compatContext as any); + console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`); + dispatchPluginButtonsChanged(); + } catch (error) { + console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error); + pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]); + } + } + } catch (error) { + console.error('[PluginLoader] Error loading plugins:', error); + } + }; + + loadPlugins(); + + return () => { + console.log('[PluginLoader] Cleaning up plugins'); + pluginRegistry.clear(); + dispatchPluginButtonsChanged(); + }; + }, [matrixClient]); + + return <>{children}; +} + +/** + * Reads and evaluates a plugin's JS module from the native filesystem host. + * Supports both CommonJS and ESM-style exports. + */ +async function loadPluginModule(pluginId: string): Promise { + const pluginHost = getPluginHost(); + if (!pluginHost) { + throw new Error('Plugins are only supported in the desktop or Android app'); + } + + const response = await pluginHost.readPluginCode(pluginId); + if (!response.success || !response.data) { + throw new Error(response.error ?? 'Failed to read plugin code'); + } + + const pluginExports: any = {}; + const mod = { exports: pluginExports }; + + try { + // eslint-disable-next-line no-new-func + const pluginFunction = new Function('module', 'exports', response.data); + pluginFunction(mod, pluginExports); + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error; + } + + const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`; + const importedModule = await import(/* @vite-ignore */ dataUrl); + return importedModule; + } + + return mod.exports; +} + +/** + * Normalizes raw plugin exports to current plugin interface. + * Supports native `onLoad` and legacy `activate` / `deactivate` plugins. + */ +function normalisePlugin(raw: Record): Plugin { + if (typeof raw.onLoad === 'function') { + return raw as unknown as Plugin; + } + + if (typeof raw.activate === 'function') { + const activate = raw.activate as (ctx: unknown) => void | Promise; + const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise) | undefined; + + return { + name: typeof raw.name === 'string' ? raw.name : undefined, + version: typeof raw.version === 'string' ? raw.version : undefined, + onLoad: async (ctx) => { + const legacyCtx = Object.assign(Object.create(ctx as object), { + registerHook: (name: string) => + ctx.log(`[compat] registerHook("${name}") - not supported`), + runHook: (name: string) => { + ctx.log(`[compat] runHook("${name}")`); + return Promise.resolve([]); + }, + getConfig: (key: string) => ctx.settings.get(key), + on: (event: string, handler: (...args: unknown[]) => void) => + ctx.events.on(event, handler), + }); + + await activate(legacyCtx); + }, + onUnload: deactivate + ? async () => { + await deactivate(); + } + : undefined, + }; + } + + return raw as unknown as Plugin; +} diff --git a/overlay/src/app/utils/pluginHost.ts b/overlay/src/app/utils/pluginHost.ts new file mode 100644 index 0000000..814fd94 --- /dev/null +++ b/overlay/src/app/utils/pluginHost.ts @@ -0,0 +1,72 @@ +import { Capacitor, registerPlugin } from '@capacitor/core'; + +export type PluginHostListItem = { + id: string; + name: string; + version?: string; + description?: string; + author?: string; + repository?: string; + thumbnail?: string; + homepage?: string; + tags?: string[]; + installedDate: string; + path: string; +}; + +export type PluginHostResult = { + success: boolean; + data?: T; + error?: string; +}; + +export interface PluginHostAPI { + getPath: () => Promise>; + download: ( + pluginId: string, + downloadUrl: string, + name: string + ) => Promise>; + list: () => Promise>; + uninstall: (pluginId: string) => Promise>; + readPluginCode: (pluginId: string) => Promise>; +} + +interface PluginStoragePlugin { + getPath(): Promise>; + download(options: { + pluginId: string; + downloadUrl: string; + name: string; + }): Promise>; + list(): Promise>; + uninstall(options: { pluginId: string }): Promise>; + readPluginCode(options: { pluginId: string }): Promise>; +} + +const PluginStorage = registerPlugin('PluginStorage'); + +const androidPluginHost: PluginHostAPI = { + getPath: () => PluginStorage.getPath(), + download: (pluginId, downloadUrl, name) => + PluginStorage.download({ pluginId, downloadUrl, name }), + list: () => PluginStorage.list(), + uninstall: (pluginId) => PluginStorage.uninstall({ pluginId }), + readPluginCode: (pluginId) => PluginStorage.readPluginCode({ pluginId }), +}; + +/** Returns true when a native plugin filesystem host is available. */ +export const isPluginHostSupported = (): boolean => getPluginHost() !== null; + +/** Returns the active plugin host for Electron or Android. */ +export const getPluginHost = (): PluginHostAPI | null => { + if (window.electron?.plugins) { + return window.electron.plugins; + } + + if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') { + return androidPluginHost; + } + + return null; +};