feat(ptt): close P0 unit-boundary gaps (SDD-088 / SDD-090 / SDD-091)
The P0 audit on v0.9.4-docs found three SDD items whose specified
software units were inlined into other types rather than packaged as
named units at the SDD-defined boundary:
* SDD-088 PttController — backend ownership + binding mutex +
capability watch lived split between AudioEngine and
ChanoraSession. Extracted into chanora_core::ptt::PttController.
AudioEngine now owns only the cpal streams and the missed-key-up
watchdog (SDD-092); the platform input backend, the active
PttBinding, and the capability watch::Sender live in the
controller. ChanoraSession::start_audio constructs the controller
against the engine's gate; disconnect/reconnect/restart paths
tear it down through stop().await before the engine.
* SDD-090 PttSanitizer — banned-field check was inlined as
PttBanCheckVisitor inside RedactingLogLayer::on_event. Extracted
into a generic PttSanitizer<L> tracing_subscriber::Layer that
decorates an inner Layer (canonical pairing:
RedactingLogLayer::with_sanitizer). The inner layer keeps its
own structural ban check as defence-in-depth for bare-install
callers.
* SDD-091 PttCapabilityBadge — Voice Bar badge was anonymous
Padding/Tooltip/Row inside _AudioControlsState.build. Extracted
into a public PttCapabilityBadge widget and added the
SDD-091-specified per-platform explanation sheet that opens on
the info-icon tap when the resolved capability is L0Focused.
New l10n strings (en + zh) cover the sheet copy.
Tests:
* 2 new unit tests for PttController (arm + descriptor watch)
* 1 new unit test for PttSanitizer (end-to-end through a real
tracing subscriber proving banned drop + safe forward)
cargo test --workspace: 55 passed / 0 failed / 3 ignored
cargo deny check: advisories ok, bans ok, licenses ok, sources ok
flutter analyze: no issues
tools/validate_docs.py: zero undefined refs, zero direct-layer
violations (pre-existing 35 old-package-name warning unchanged)
No SDD/SAD/SRS doc changes — the contracts already named these
units; this commit aligns code unit boundaries with those contracts.
This commit is contained in:
@@ -40,6 +40,13 @@
|
||||
"backend": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"pttCapabilityExplainTitle": "Push-to-Talk capability",
|
||||
"pttCapabilityExplainFocusedHeading": "Focused PTT",
|
||||
"pttCapabilityExplainFocusedBody": "Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.",
|
||||
"pttCapabilityExplainGoGlobalWindows": "On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.",
|
||||
"pttCapabilityExplainGoGlobalMacos": "On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.",
|
||||
"pttCapabilityExplainGoGlobalLinux": "On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop's shortcut dialog when it appears.",
|
||||
"pttCapabilityExplainGoGlobalGeneric": "Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.",
|
||||
"pttConfigureAction": "Configure",
|
||||
"pttConfigureTitle": "Configure Push-to-Talk binding",
|
||||
"pttConfigurePrompt": "Press the key or mouse side button you want to use for Push-to-Talk.",
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
"pttTransmitting": "正在发送…",
|
||||
"pttHoldToTalkSemanticsHint": "按住进行语音发送,松开停止。",
|
||||
"pttCapabilityBadge": "对讲能力:{level}({backend})",
|
||||
"pttCapabilityExplainTitle": "对讲能力说明",
|
||||
"pttCapabilityExplainFocusedHeading": "聚焦对讲",
|
||||
"pttCapabilityExplainFocusedBody": "Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。",
|
||||
"pttCapabilityExplainGoGlobalWindows": "在 Windows 上,绑定按键后会自动启用全局对讲,无需额外权限。",
|
||||
"pttCapabilityExplainGoGlobalMacos": "在 macOS 上,启用全局对讲需要「输入监视」权限。请打开「系统设置 → 隐私与安全 → 输入监视」,授权 Chanora 后重新绑定按键。",
|
||||
"pttCapabilityExplainGoGlobalLinux": "在 Linux 上,启用全局对讲需要带 GlobalShortcuts 门户的 GNOME-Wayland 桌面。请重新绑定按键,并在桌面弹出快捷键对话框时接受。",
|
||||
"pttCapabilityExplainGoGlobalGeneric": "当前环境暂不支持全局对讲。聚焦对讲在 Chanora 窗口获得焦点时仍可正常使用。",
|
||||
"pttConfigureAction": "配置",
|
||||
"pttConfigureTitle": "配置对讲按键",
|
||||
"pttConfigurePrompt": "按下您希望用于对讲的按键或鼠标侧键。",
|
||||
|
||||
@@ -253,6 +253,48 @@ abstract class AppL10n {
|
||||
/// **'PTT: {level} ({backend})'**
|
||||
String pttCapabilityBadge(String level, String backend);
|
||||
|
||||
/// No description provided for @pttCapabilityExplainTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Push-to-Talk capability'**
|
||||
String get pttCapabilityExplainTitle;
|
||||
|
||||
/// No description provided for @pttCapabilityExplainFocusedHeading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Focused PTT'**
|
||||
String get pttCapabilityExplainFocusedHeading;
|
||||
|
||||
/// No description provided for @pttCapabilityExplainFocusedBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.'**
|
||||
String get pttCapabilityExplainFocusedBody;
|
||||
|
||||
/// No description provided for @pttCapabilityExplainGoGlobalWindows.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.'**
|
||||
String get pttCapabilityExplainGoGlobalWindows;
|
||||
|
||||
/// No description provided for @pttCapabilityExplainGoGlobalMacos.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.'**
|
||||
String get pttCapabilityExplainGoGlobalMacos;
|
||||
|
||||
/// No description provided for @pttCapabilityExplainGoGlobalLinux.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop\'s shortcut dialog when it appears.'**
|
||||
String get pttCapabilityExplainGoGlobalLinux;
|
||||
|
||||
/// No description provided for @pttCapabilityExplainGoGlobalGeneric.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.'**
|
||||
String get pttCapabilityExplainGoGlobalGeneric;
|
||||
|
||||
/// No description provided for @pttConfigureAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -96,6 +96,32 @@ class AppL10nEn extends AppL10n {
|
||||
return 'PTT: $level ($backend)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainTitle => 'Push-to-Talk capability';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainFocusedHeading => 'Focused PTT';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainFocusedBody =>
|
||||
'Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainGoGlobalWindows =>
|
||||
'On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainGoGlobalMacos =>
|
||||
'On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainGoGlobalLinux =>
|
||||
'On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop\'s shortcut dialog when it appears.';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainGoGlobalGeneric =>
|
||||
'Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.';
|
||||
|
||||
@override
|
||||
String get pttConfigureAction => 'Configure';
|
||||
|
||||
|
||||
@@ -93,6 +93,32 @@ class AppL10nZh extends AppL10n {
|
||||
return '对讲能力:$level($backend)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainTitle => '对讲能力说明';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainFocusedHeading => '聚焦对讲';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainFocusedBody =>
|
||||
'Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainGoGlobalWindows =>
|
||||
'在 Windows 上,绑定按键后会自动启用全局对讲,无需额外权限。';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainGoGlobalMacos =>
|
||||
'在 macOS 上,启用全局对讲需要「输入监视」权限。请打开「系统设置 → 隐私与安全 → 输入监视」,授权 Chanora 后重新绑定按键。';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainGoGlobalLinux =>
|
||||
'在 Linux 上,启用全局对讲需要带 GlobalShortcuts 门户的 GNOME-Wayland 桌面。请重新绑定按键,并在桌面弹出快捷键对话框时接受。';
|
||||
|
||||
@override
|
||||
String get pttCapabilityExplainGoGlobalGeneric =>
|
||||
'当前环境暂不支持全局对讲。聚焦对讲在 Chanora 窗口获得焦点时仍可正常使用。';
|
||||
|
||||
@override
|
||||
String get pttConfigureAction => '配置';
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -922,48 +923,19 @@ class _AudioControlsState extends State<_AudioControls> {
|
||||
stats.pttActive ? 'on' : 'off',
|
||||
);
|
||||
|
||||
final isGlobal = widget.pttLevel != 'L0Focused';
|
||||
final badgeLabel = l10n.pttCapabilityBadge(
|
||||
widget.pttLevel,
|
||||
widget.pttBackendId,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Capability badge (gen2 v0.9.3 / SDD-091). Renders the
|
||||
// active PTT level + backend so the user understands when
|
||||
// Global PTT has fallen back to Focused PTT.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Tooltip(
|
||||
message: widget.pttBoundInputClass.isEmpty
|
||||
? badgeLabel
|
||||
: '$badgeLabel\n(${widget.pttBoundInputClass})',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isGlobal ? Icons.public : Icons.crop_free,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.tune, size: 14),
|
||||
label: Text(l10n.pttConfigureAction),
|
||||
onPressed: widget.onConfigurePtt,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Global PTT has fallen back to Focused PTT, and surfaces
|
||||
// a per-platform explanation sheet when the resolved
|
||||
// capability is `L0Focused`.
|
||||
PttCapabilityBadge(
|
||||
level: widget.pttLevel,
|
||||
backendId: widget.pttBackendId,
|
||||
boundInputClass: widget.pttBoundInputClass,
|
||||
onConfigure: widget.onConfigurePtt,
|
||||
),
|
||||
// Accessibility (SysRS-262 + SysRS-282 + SysRS-263):
|
||||
// wrap the custom Listener-based PTT control in a
|
||||
@@ -1079,6 +1051,157 @@ class _AudioControlsState extends State<_AudioControls> {
|
||||
}
|
||||
}
|
||||
|
||||
/// PTT capability badge (gen2 v0.9.3 / SDD-091).
|
||||
///
|
||||
/// Renders the active PTT level + backend in the Voice Bar so the
|
||||
/// user understands which input path is in effect. When the
|
||||
/// resolved capability is `L0Focused` an info icon appears that
|
||||
/// opens a per-platform explanation sheet describing why Global
|
||||
/// PTT is not active and what the user can do to engage it.
|
||||
///
|
||||
/// Driven by the `BridgeEvent::PttCapability` stream published by
|
||||
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
|
||||
/// updates the props on each transition.
|
||||
class PttCapabilityBadge extends StatelessWidget {
|
||||
/// Construct a badge.
|
||||
const PttCapabilityBadge({
|
||||
super.key,
|
||||
required this.level,
|
||||
required this.backendId,
|
||||
required this.boundInputClass,
|
||||
required this.onConfigure,
|
||||
});
|
||||
|
||||
/// Resolved capability level as the bridge emits it
|
||||
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
|
||||
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
|
||||
final String level;
|
||||
|
||||
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
|
||||
final String backendId;
|
||||
|
||||
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
|
||||
/// or empty when no binding is set).
|
||||
final String boundInputClass;
|
||||
|
||||
/// Open the configure-binding dialog. Wired by the caller.
|
||||
final VoidCallback onConfigure;
|
||||
|
||||
bool get _isFocused => level == 'L0Focused';
|
||||
|
||||
String _explainBodyForPlatform(AppL10n l10n) {
|
||||
// Use `defaultTargetPlatform` rather than `Theme.of(context).platform`
|
||||
// because the latter is influenced by debug platform overrides
|
||||
// that callers may toggle in dev mode. We want the badge's
|
||||
// explanation to match the actual host OS.
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.windows:
|
||||
return l10n.pttCapabilityExplainGoGlobalWindows;
|
||||
case TargetPlatform.macOS:
|
||||
return l10n.pttCapabilityExplainGoGlobalMacos;
|
||||
case TargetPlatform.linux:
|
||||
return l10n.pttCapabilityExplainGoGlobalLinux;
|
||||
default:
|
||||
return l10n.pttCapabilityExplainGoGlobalGeneric;
|
||||
}
|
||||
}
|
||||
|
||||
void _openExplanationSheet(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final theme = Theme.of(sheetContext);
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttCapabilityExplainTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_explainBodyForPlatform(l10n),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
|
||||
final tooltipMessage = boundInputClass.isEmpty
|
||||
? badgeLabel
|
||||
: '$badgeLabel\n($boundInputClass)';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Tooltip(
|
||||
message: tooltipMessage,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_isFocused ? Icons.crop_free : Icons.public,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
badgeLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isFocused)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, size: 16),
|
||||
tooltip: l10n.pttCapabilityExplainTitle,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _openExplanationSheet(context),
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.tune, size: 14),
|
||||
label: Text(l10n.pttConfigureAction),
|
||||
onPressed: onConfigure,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SnapshotView extends StatelessWidget {
|
||||
const _SnapshotView({
|
||||
required this.snapshot,
|
||||
|
||||
Reference in New Issue
Block a user