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
+
+
+ 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__":