diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..ee8b7c5 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..7262eb1 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "customColor": "", + "associatedIndex": 5 +} + + + + { + "keyToString": { + "ModuleVcsDetector.initialDetectionPerformed": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.cidr.known.project.marker": "true", + "RunOnceActivity.git.unshallow": "true", + "RunOnceActivity.readMode.enableVisualFormatting": "true", + "cf.first.check.clang-format": "false", + "cidr.known.project.marker": "true", + "git-widget-placeholder": "ui" + } +} + + + + 1787618710223 + + + + \ No newline at end of file diff --git a/hyperkey_file_opener/pyproject.toml b/hyperkey_file_opener/pyproject.toml new file mode 100644 index 0000000..45cb7e4 --- /dev/null +++ b/hyperkey_file_opener/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "hyperkey-file-opener" +version = "0.1.0" +description = "Flet service used by Hyperkey to open local files through Android FileProvider-backed intents." +requires-python = ">=3.10" +dependencies = ["flet>=0.80.0"] + +[tool.flet.extensions.hyperkey_file_opener] +path = "flutter/hyperkey_file_opener" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"flutter.hyperkey_file_opener" = ["**/*"] diff --git a/hyperkey_file_opener/src/flutter/hyperkey_file_opener/lib/hyperkey_file_opener.dart b/hyperkey_file_opener/src/flutter/hyperkey_file_opener/lib/hyperkey_file_opener.dart new file mode 100644 index 0000000..83e9cec --- /dev/null +++ b/hyperkey_file_opener/src/flutter/hyperkey_file_opener/lib/hyperkey_file_opener.dart @@ -0,0 +1,3 @@ +library hyperkey_file_opener; + +export 'src/extension.dart'; diff --git a/hyperkey_file_opener/src/flutter/hyperkey_file_opener/lib/src/extension.dart b/hyperkey_file_opener/src/flutter/hyperkey_file_opener/lib/src/extension.dart new file mode 100644 index 0000000..7899448 --- /dev/null +++ b/hyperkey_file_opener/src/flutter/hyperkey_file_opener/lib/src/extension.dart @@ -0,0 +1,22 @@ +import 'package:flet/flet.dart'; +import 'package:flutter/widgets.dart'; + +import 'file_opener_service.dart'; + +class Extension extends FletExtension { + @override + void ensureInitialized() {} + + @override + FletService? createService(Control control) { + switch (control.type) { + case 'HyperkeyFileOpener': + return HyperkeyFileOpenerService(control: control); + default: + return null; + } + } + + @override + Widget? createWidget(Key? key, Control control) => null; +} diff --git a/hyperkey_file_opener/src/flutter/hyperkey_file_opener/lib/src/file_opener_service.dart b/hyperkey_file_opener/src/flutter/hyperkey_file_opener/lib/src/file_opener_service.dart new file mode 100644 index 0000000..15b0fd8 --- /dev/null +++ b/hyperkey_file_opener/src/flutter/hyperkey_file_opener/lib/src/file_opener_service.dart @@ -0,0 +1,42 @@ +import 'package:flet/flet.dart'; +import 'package:open_filex/open_filex.dart'; + +class HyperkeyFileOpenerService extends FletService { + HyperkeyFileOpenerService({required super.control}); + + @override + void init() { + super.init(); + control.addInvokeMethodListener(_invokeMethod); + } + + Future _invokeMethod(String name, dynamic args) async { + if (name != 'open_file') { + throw Exception('Unknown HyperkeyFileOpener method: $name'); + } + + final map = Map.from(args as Map); + final path = map['path']?.toString(); + final mimeType = map['mime_type']?.toString(); + + if (path == null || path.trim().isEmpty) { + throw Exception('No file path was provided.'); + } + + final result = await OpenFilex.open( + path, + type: (mimeType == null || mimeType.isEmpty) ? null : mimeType, + ); + + return { + 'result_type': result.type.toString(), + 'message': result.message, + }; + } + + @override + void dispose() { + control.removeInvokeMethodListener(_invokeMethod); + super.dispose(); + } +} diff --git a/hyperkey_file_opener/src/flutter/hyperkey_file_opener/pubspec.yaml b/hyperkey_file_opener/src/flutter/hyperkey_file_opener/pubspec.yaml new file mode 100644 index 0000000..4d3f066 --- /dev/null +++ b/hyperkey_file_opener/src/flutter/hyperkey_file_opener/pubspec.yaml @@ -0,0 +1,14 @@ +name: hyperkey_file_opener +description: FileProvider-backed external file opener for the Hyperkey Flet app. +version: 0.1.0 +publish_to: none + +environment: + sdk: '>=3.4.0 <4.0.0' + flutter: '>=3.22.0' + +dependencies: + flutter: + sdk: flutter + flet: any + open_filex: ^4.7.0 diff --git a/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/PKG-INFO b/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/PKG-INFO new file mode 100644 index 0000000..24f140b --- /dev/null +++ b/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/PKG-INFO @@ -0,0 +1,6 @@ +Metadata-Version: 2.4 +Name: hyperkey-file-opener +Version: 0.1.0 +Summary: Flet service used by Hyperkey to open local files through Android FileProvider-backed intents. +Requires-Python: >=3.10 +Requires-Dist: flet>=0.80.0 diff --git a/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/SOURCES.txt b/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/SOURCES.txt new file mode 100644 index 0000000..9e6de83 --- /dev/null +++ b/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/SOURCES.txt @@ -0,0 +1,12 @@ +pyproject.toml +src/flutter/hyperkey_file_opener/pubspec.yaml +src/flutter/hyperkey_file_opener/lib/hyperkey_file_opener.dart +src/flutter/hyperkey_file_opener/lib/src/extension.dart +src/flutter/hyperkey_file_opener/lib/src/file_opener_service.dart +src/hyperkey_file_opener/__init__.py +src/hyperkey_file_opener/file_opener.py +src/hyperkey_file_opener.egg-info/PKG-INFO +src/hyperkey_file_opener.egg-info/SOURCES.txt +src/hyperkey_file_opener.egg-info/dependency_links.txt +src/hyperkey_file_opener.egg-info/requires.txt +src/hyperkey_file_opener.egg-info/top_level.txt \ No newline at end of file diff --git a/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/dependency_links.txt b/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/requires.txt b/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/requires.txt new file mode 100644 index 0000000..3a0e960 --- /dev/null +++ b/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/requires.txt @@ -0,0 +1 @@ +flet>=0.80.0 diff --git a/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/top_level.txt b/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/top_level.txt new file mode 100644 index 0000000..a48b2ef --- /dev/null +++ b/hyperkey_file_opener/src/hyperkey_file_opener.egg-info/top_level.txt @@ -0,0 +1,2 @@ +flutter +hyperkey_file_opener diff --git a/hyperkey_file_opener/src/hyperkey_file_opener/__init__.py b/hyperkey_file_opener/src/hyperkey_file_opener/__init__.py new file mode 100644 index 0000000..9975469 --- /dev/null +++ b/hyperkey_file_opener/src/hyperkey_file_opener/__init__.py @@ -0,0 +1,3 @@ +from .file_opener import HyperkeyFileOpener + +__all__ = ["HyperkeyFileOpener"] diff --git a/hyperkey_file_opener/src/hyperkey_file_opener/file_opener.py b/hyperkey_file_opener/src/hyperkey_file_opener/file_opener.py new file mode 100644 index 0000000..9ded3c1 --- /dev/null +++ b/hyperkey_file_opener/src/hyperkey_file_opener/file_opener.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import flet as ft + + +@ft.control("HyperkeyFileOpener") +class HyperkeyFileOpener(ft.Service): + """Open local files using the host platform's native file-opening mechanism.""" + + async def open_file(self, path: str, mime_type: str | None = None): + return await self._invoke_method( + "open_file", + { + "path": path, + "mime_type": mime_type, + }, + ) diff --git a/pyproject.toml b/pyproject.toml index 0336601..d960631 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ dependencies = [ "cycler==0.12.1", "fonttools==4.63.0", "flet==0.86.5", + "flet-permission-handler==0.86.5", + "hyperkey-file-opener @ file:///D:/Masters%20of%20computing/Tech%20Launcher/hyperkey/hyperkey-file-opener", "greenlet==3.5.1", "kiwisolver==1.5.0", "Markdown==3.10.3", @@ -28,4 +30,7 @@ dependencies = [ ] [tool.flet] -module = "hyperkey" \ No newline at end of file +module = "hyperkey" + +[tool.flet.android.permission] +"android.permission.MANAGE_EXTERNAL_STORAGE" = true \ No newline at end of file diff --git a/scripts/workflow.py b/scripts/workflow.py index ff94866..aa3533a 100644 --- a/scripts/workflow.py +++ b/scripts/workflow.py @@ -129,7 +129,7 @@ def run_pipeline( heatmap_arguments = { "input_path": output_csv, "output_name": heatmap_output_name, - "dark_mode": dark_mode, + "dark_mode": dark_mode } if raw_location_path is not None: @@ -144,11 +144,10 @@ def run_pipeline( # --------------------------- print("\nRunning visualise_measurement.py ...") from visualise_measurement import main as measurement_main - measurement_main( input_path=output_csv, output_name=spectral_graph_output_name, - dark_mode=dark_mode, + dark_mode=dark_mode ) completed_stages.append("spectral_graph") print("visualise_measurement.py completed successfully.") @@ -172,10 +171,7 @@ def run_pipeline( completed_stages.append("outlier_analysis") print("outlier_analysis.py completed successfully.") else: - print( - "\nOutlier analysis not requested. " - "Skipping outlier_analysis.py." - ) + print("\nOutlier analysis not requested. Skipping outlier_analysis.py.") # --------------------------- # 4. Report @@ -183,19 +179,7 @@ def run_pipeline( print("\nRunning report.py ...") from report import main as report_main - report_main( - dark_mode=dark_mode, - summary_path=summary_path, - report_markdown_path=report_markdown_path, - report_html_path=report_html_path, - report_pdf_path=report_pdf_path, - heatmap_output_name=heatmap_output_name, - spectral_graph_output_name=spectral_graph_output_name, - outlier_output_name=( - outlier_output_name if outlier_analysis else None - ), - ) - + report_main(dark_mode=dark_mode) completed_stages.append("report") print("report.py completed successfully.") diff --git a/ui/app.py b/ui/app.py index 2e30941..101ce95 100644 --- a/ui/app.py +++ b/ui/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import mimetypes import os import re import tempfile @@ -8,6 +9,12 @@ from uuid import uuid4 import flet as ft +import flet_permission_handler as fph + +try: + from hyperkey_file_opener import HyperkeyFileOpener +except ImportError: + HyperkeyFileOpener = None from .components import browse_field, help_item, section_card, stat_card from .models import HyperkeyRunConfig, RunResult @@ -243,6 +250,7 @@ def _create_controls(self) -> None: self.output_directory_field = self._style_input_field(ft.TextField( label="Output directory (optional)", hint_text="Default: Documents/Hyperkey (Windows), Downloads/Hyperkey (Android)", + hint_text="Default: Documents/Hyperkey (Windows), Downloads/Hyperkey (Android)", on_change=self._refresh_command_preview, )) @@ -302,6 +310,11 @@ def _create_controls(self) -> None: tooltip="Copy command", on_click=self._copy_command, ) + self.copy_command_button = ft.IconButton( + icon=ft.Icons.CONTENT_COPY, + tooltip="Copy command", + on_click=self._copy_command, + ) # Advanced CLI fallback. Keep this deliberately large because commands # can be long, especially when Android returns longer document paths. @@ -310,6 +323,8 @@ def _create_controls(self) -> None: hint_text=( 'metadata.csv -r raw_data -n result OR ' 'metadata.csv -r raw_data -o output_folder -n result' + 'metadata.csv -r raw_data -n result OR ' + 'metadata.csv -r raw_data -o output_folder -n result' ), multiline=True, min_lines=8, @@ -339,6 +354,10 @@ def _create_controls(self) -> None: self.output_status = ft.Text() self.url_launcher = ft.UrlLauncher() self.share_service = ft.Share() + self.android_file_opener = ( + HyperkeyFileOpener() if HyperkeyFileOpener is not None else None + ) + self.permission_handler = fph.PermissionHandler() self.logs_field = self._style_input_field(ft.TextField( label="Run log", @@ -539,6 +558,168 @@ def _arguments_have_output_directory(arguments: list[str]) -> bool: return True return False + # ------------------------------------------------------------------ + # Android storage permission + # ------------------------------------------------------------------ + async def _android_all_files_access_granted(self) -> bool: + """Return True when Android has granted Hyperkey All files access.""" + if not self._is_android(): + return True + + try: + status = await self.permission_handler.get_status( + fph.Permission.MANAGE_EXTERNAL_STORAGE + ) + return status == fph.PermissionStatus.GRANTED + except Exception: + return False + + async def _request_android_all_files_access(self, _e=None) -> None: + """ + Send the user to Android's special All files access permission screen. + + MANAGE_EXTERNAL_STORAGE is a special Android permission. The Flet + permission handler opens the appropriate system settings screen rather + than displaying a normal runtime-permission popup. + """ + if not self._is_android(): + return + + try: + self.page.pop_dialog() + except Exception: + pass + + try: + status = await self.permission_handler.request( + fph.Permission.MANAGE_EXTERNAL_STORAGE + ) + + # On Android this request can leave Hyperkey while the user toggles + # "Allow access to manage all files" in system settings. Re-check + # when control returns to the app instead of trusting the first + # status value alone. + granted = await self._android_all_files_access_granted() + + if granted: + self.form_status.value = ( + "File access granted. Hyperkey can now use direct custom " + "paths in shared storage." + ) + self.form_status.color = ft.Colors.GREEN + else: + status_name = getattr(status, "name", "not granted") + self.form_status.value = ( + "All files access is not enabled. File/folder pickers will " + "still work, but manually entered Android paths may be " + f"inaccessible. Status: {status_name}." + ) + self.form_status.color = ft.Colors.ORANGE + + except Exception as exc: + self.form_status.value = f"Unable to request file access: {exc}" + self.form_status.color = ft.Colors.RED + + self.page.update() + + async def _open_android_permission_settings(self, _e=None) -> None: + """Open Hyperkey's Android app settings as a fallback.""" + if not self._is_android(): + return + + try: + self.page.pop_dialog() + except Exception: + pass + + try: + opened = await self.permission_handler.open_app_settings() + if not opened: + raise RuntimeError("Android app settings could not be opened.") + except Exception as exc: + self.form_status.value = f"Unable to open Android settings: {exc}" + self.form_status.color = ft.Colors.RED + self.page.update() + + def _show_android_storage_permission_dialog(self) -> None: + """Explain All files access before sending the user to Android settings.""" + if not self._is_android(): + return + + dialog = ft.AlertDialog( + modal=True, + title=ft.Text("Allow Hyperkey file access"), + content=ft.Column( + tight=True, + spacing=12, + controls=[ + ft.Text( + "Hyperkey can work with files selected through Android's " + "pickers without this permission." + ), + ft.Text( + "All files access is requested so Hyperkey can also read " + "and write direct custom paths that you enter manually, " + "including folders in shared internal storage." + ), + ft.Container( + padding=12, + border=ft.Border.all(1, ft.Colors.GREY_700), + border_radius=10, + content=ft.Column( + tight=True, + spacing=6, + controls=[ + ft.Text( + "Why Hyperkey needs it", + weight=ft.FontWeight.BOLD, + ), + ft.Text("• Read metadata CSV and spectral files from custom paths."), + ft.Text("• Read raw-data folders supplied as direct paths."), + ft.Text("• Save generated outputs to custom shared-storage folders."), + ft.Text("• Keep direct filesystem paths usable without copying files into app cache."), + ], + ), + ), + ft.Text( + "Android will open a system settings screen. Enable " + "“Allow access to manage all files” for Hyperkey, then " + "return to the app." + ), + ft.Text( + "This permission does not replace Hyperkey's secure " + "FileProvider-based Open action. Files handed to Excel, " + "PDF viewers, Gallery, and other apps still receive only " + "temporary access to the specific file being opened.", + theme_style=ft.TextThemeStyle.BODY_SMALL, + ), + ], + ), + actions=[ + ft.TextButton("Not now", on_click=lambda _e: self.page.pop_dialog()), + ft.Button( + content="Grant file access", + icon=ft.Icons.FOLDER_OPEN, + on_click=self._request_android_all_files_access, + ), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + self.page.show_dialog(dialog) + + async def ensure_android_storage_permission_on_startup(self) -> None: + """ + On Android first launch (and later launches while permission is absent), + explain why Hyperkey requests All files access before opening settings. + """ + if not self._is_android(): + return + + if await self._android_all_files_access_granted(): + return + + self._show_android_storage_permission_dialog() + # ------------------------------------------------------------------ # Pickers # ------------------------------------------------------------------ @@ -650,6 +831,16 @@ def _run_screen(self) -> ft.Control: ], ) ], + controls=[ + ft.Row( + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.START, + controls=[ + self.command_preview, + self.copy_command_button, + ], + ) + ], ) actions = ft.ResponsiveRow( @@ -695,6 +886,8 @@ def _cli_screen(self) -> ft.Control: controls=[ self.cli_field, ft.Text( + "Example: metadata.csv -r raw_data " + "-o output_folder -n sydneyAPPN " "Example: metadata.csv -r raw_data " "-o output_folder -n sydneyAPPN " "-l species_locations.csv --outlier-analysis", @@ -862,17 +1055,13 @@ def _markdown_report_path(self) -> Path | None: return None async def _open_output_path(self, path: Path) -> None: - """ - Open/share a generated output while keeping Hyperkey running. + """Open a generated file with a compatible external application. - Windows and other desktop platforms hand the file URI to the operating - system, which opens the associated/default application. - - Android uses the platform share/open sheet for the real file path. - This avoids relying on a raw file:// URI across Android application - boundaries and lets the user choose a compatible viewer such as - Excel/Sheets for CSV, a browser for HTML, a PDF viewer for PDF, or - Photos/Gallery for images. + Android must not expose a raw file:// URI to another application. + Hyperkey therefore delegates Android opening to the bundled + HyperkeyFileOpener extension, which uses an Android FileProvider-backed + ACTION_VIEW intent and grants the chosen viewer temporary read access. + Desktop platforms continue using Flet's UrlLauncher. """ try: resolved = path.resolve() @@ -881,19 +1070,24 @@ async def _open_output_path(self, path: Path) -> None: raise FileNotFoundError(f"Generated file not found: {resolved}") if self._is_android(): - await self.share_service.share_files( - [ft.ShareFile.from_path(str(resolved))], - title=f"Open {resolved.name}", - ) - self.output_status.value = ( - f"Choose an app to open: {resolved.name}" + if self.android_file_opener is None: + raise RuntimeError( + "Android file opener extension is not installed. " + "Add hyperkey-file-opener to the app dependencies and rebuild the APK." + ) + + mime_type, _encoding = mimetypes.guess_type(str(resolved)) + await self.android_file_opener.open_file( + str(resolved), + mime_type=mime_type, ) else: await self.url_launcher.launch_url( resolved.as_uri(), mode=ft.LaunchMode.EXTERNAL_APPLICATION, ) - self.output_status.value = f"Opened: {resolved.name}" + + self.output_status.value = f"Opening: {resolved.name}" except Exception as exc: self.output_status.value = ( @@ -902,10 +1096,34 @@ async def _open_output_path(self, path: Path) -> None: self.page.update() + async def _share_output_path(self, path: Path) -> None: + """Share a generated file using the platform share sheet.""" + try: + resolved = path.resolve() + + if not resolved.exists() or not resolved.is_file(): + raise FileNotFoundError(f"Generated file not found: {resolved}") + + await self.share_service.share_files( + [ft.ShareFile.from_path(str(resolved))], + title=f"Share {resolved.name}", + ) + self.output_status.value = f"Sharing: {resolved.name}" + + except Exception as exc: + self.output_status.value = ( + f"Unable to share '{path.name}': {exc}" + ) + + self.page.update() + def _output_file_card(self, label: str, path: Path) -> ft.Card: async def open_file(_e) -> None: await self._open_output_path(path) + async def share_file(_e) -> None: + await self._share_output_path(path) + size_text = "" try: size = path.stat().st_size @@ -923,12 +1141,52 @@ async def open_file(_e) -> None: subtitle += f"\n{size_text}" return ft.Card( - content=ft.ListTile( - leading=ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED), - title=ft.Text(label, weight=ft.FontWeight.W_600), - subtitle=ft.Text(subtitle, max_lines=3), - trailing=ft.Icon(ft.Icons.OPEN_IN_NEW), - on_click=open_file, + content=ft.Container( + padding=12, + content=ft.Column( + spacing=10, + controls=[ + ft.Row( + vertical_alignment=ft.CrossAxisAlignment.START, + controls=[ + ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED), + ft.Column( + expand=True, + spacing=2, + controls=[ + ft.Text( + label, + weight=ft.FontWeight.W_600, + ), + ft.Text( + subtitle, + max_lines=3, + size=12, + selectable=True, + ), + ], + ), + ], + ), + ft.Row( + alignment=ft.MainAxisAlignment.END, + spacing=8, + wrap=True, + controls=[ + ft.Button( + content="Open", + icon=ft.Icons.OPEN_IN_NEW, + on_click=open_file, + ), + ft.OutlinedButton( + content="Share", + icon=ft.Icons.SHARE_OUTLINED, + on_click=share_file, + ), + ], + ), + ], + ), ) ) @@ -1207,7 +1465,7 @@ def _outputs_screen(self) -> ft.Control: controls=[ self._screen_title( "Outputs", - "Generated files from the latest run. Tap a file to open it with a compatible app without closing Hyperkey.", + "Generated files from the latest run. Open them with a compatible app or share them without closing Hyperkey.", ), self.output_status, self.outputs_content, @@ -1320,7 +1578,7 @@ async def open_report(_e) -> None: self.outputs_content.controls.append( section_card( "Generated files", - subtitle="Tap any file to open it with a compatible application.", + subtitle="Open a file with a compatible application or share it.", controls=[ self._output_file_card(label, path) for label, path in generated_files @@ -1447,6 +1705,25 @@ async def _copy_command(self, _e) -> None: self.page.update() + async def _copy_command(self, _e) -> None: + """Copy the generated CLI command to the system clipboard.""" + command = (self.command_preview.value or "").strip() + + if not command: + self.form_status.value = "No command available to copy." + self.page.update() + return + + try: + await ft.Clipboard().set(command) + self.form_status.value = "CLI command copied to clipboard." + self.form_status.color = ft.Colors.GREEN + except Exception as exc: + self.form_status.value = f"Unable to copy command: {exc}" + self.form_status.color = ft.Colors.RED + + self.page.update() + def _refresh_command_preview(self, _e) -> None: config = self._config_from_form() args = self.service.build_arguments(config) @@ -1471,6 +1748,12 @@ async def _run_form(self, _e) -> None: # A user-selected output directory still overrides either default. await self._ensure_form_default_output_directory() + # Default output policy: + # Windows -> Documents/Hyperkey (resolved by pipeline.py) + # Android -> Downloads/Hyperkey (resolved here through Flet) + # A user-selected output directory still overrides either default. + await self._ensure_form_default_output_directory() + config = self._config_from_form() # Hyperkey's processing stack is synchronous and report generation @@ -1502,6 +1785,18 @@ async def _run_cli(self, _e) -> None: try: arguments = self.service.parse_cli_text(self.cli_field.value or "") + # Keep Advanced CLI mode consistent with the normal form: + # Android defaults to Downloads/Hyperkey only when the command + # does not already contain -o/--output. + if ( + self._is_android() + and not self._arguments_have_output_directory(arguments) + ): + android_output = ( + await self._get_android_default_output_directory() + ) + arguments.extend(["-o", str(android_output)]) + # Keep Advanced CLI mode consistent with the normal form: # Android defaults to Downloads/Hyperkey only when the command # does not already contain -o/--output. @@ -1604,10 +1899,12 @@ def _show_help(self, _e) -> None: help_item( "Output name", "Optional output-name prefix. It is passed separately as -n/--name. Existing Hyperkey dated naming is preserved by the backend.", + "Optional output-name prefix. It is passed separately as -n/--name. Existing Hyperkey dated naming is preserved by the backend.", ), help_item( "Output directory", "Optional destination directory for generated outputs. If empty, Windows uses Documents/Hyperkey and Android uses Downloads/Hyperkey. A selected folder is passed separately as -o/--output and overrides the default.", + "Optional destination directory for generated outputs. If empty, Windows uses Documents/Hyperkey and Android uses Downloads/Hyperkey. A selected folder is passed separately as -o/--output and overrides the default.", ), help_item( "Dark mode", @@ -1627,7 +1924,7 @@ def _show_help(self, _e) -> None: ), help_item( "Outputs", - "Shows the files generated by the latest successful run. Tap a file to open it in the default application while Hyperkey remains open. The Markdown report is also previewed directly inside the app.", + "Shows the files generated by the latest successful run. Use Open to launch a compatible application or Share to send the file through Android/Windows sharing. The Markdown report is also previewed directly inside the app.", ), help_item( "Results and Logs", @@ -1640,8 +1937,9 @@ def _show_help(self, _e) -> None: self.page.show_dialog(help_dialog) -def main(page: ft.Page) -> None: - HyperkeyUI(page) +async def main(page: ft.Page) -> None: + ui = HyperkeyUI(page) + await ui.ensure_android_storage_permission_on_startup() if __name__ == "__main__":