Compare commits

...
Author SHA1 Message Date
Edison Jwa 609c4cee8c docs(security): regenerate license inventories
Cargo inventory: pick up chanora_resolver bump from 0.1.0 to
0.2.0-beta.1 so it matches the workspace; also adds a trailing newline
so 'cargo about generate' is idempotent in CI license-drift checks.

Flutter inventory: pick up flutter_local_notifications (+ platform
interfaces) and timezone pulled in by the prior notification
permission work.
2026-06-09 19:29:04 +09:00
Edison Jwa c7e51c2e48 fix(ios-audio): keep session active when already-in-channel
The 'already in channel' server response (code 0x0302) is treated as a
successful join by _onJoinChannel: the user stays in the channel and
local state is updated to reflect the joined target. But the underlying
voiceJoin call still raises BridgeError_ServerRejected, which the
joinVoiceChannelWithIosAudioSession helper used to interpret as a join
failure and deactivate the iOS audio session. Result: the UI shows the
user as joined while the audio session is dead and capture/playback
remain silent.

Add an isJoinSuccess predicate to the ordering helper. When the
predicate matches, the helper rethrows (so the caller can still run its
success-on-already-joined branch) without deactivating the session.
Wire _onJoinChannel to pass _isAlreadyInChannel as the predicate so the
0x0302 path keeps the session active.

Adds two regression tests covering the success-on-rethrow and the
predicate-false-still-deactivates paths.
2026-06-09 19:28:54 +09:00
Edison Jwa 703f44d731 docs(ios-audio): align activation lifecycle comments 2026-06-09 02:16:20 +09:00
Edison Jwa 7be3934c86 fix(ios-audio): activate session before voice joins 2026-06-09 02:16:08 +09:00
Edison Jwa f224347836 fix(ios-audio): add voice join session coordinator 2026-06-09 02:15:55 +09:00
Edison Jwa eca77ece81 fix(chat): allow empty poke messages 2026-06-09 01:47:23 +09:00
Edison Jwa 3462de1eee fix(core): allow empty poke dispatch 2026-06-09 01:44:03 +09:00
Edison Jwa f66118f5bb docs: add poke-without-message design 2026-06-09 00:53:14 +09:00
Edison Jwa 3ef540ae37 Merge pull request #35 from EdisonJwa/feat/poke-notifications
feat: add poke notifications
2026-06-08 23:52:13 +09:00
Edison Jwa e0edcc89ac Merge pull request #34 from EdisonJwa/simplify-project-review
Maintainability continuation and Android smoke evidence
2026-06-08 23:46:37 +09:00
Edison Jwa dc52092654 docs: remove trailing whitespace from continuation design 2026-06-08 23:32:18 +09:00
12 changed files with 809 additions and 14 deletions
@@ -46,7 +46,8 @@ import AVFoundation
//
// VoIP configuration is engaged on voice-channel join via the
// `chanora/ios_audio_session` MethodChannel, driven from Dart
// by the BridgeEvent::AudioStarted / AudioStopped lifecycle.
// before `voiceJoin` starts VoiceProcessingIO and again as an
// idempotent guard on the AudioStarted lifecycle.
do {
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
logAudioSessionState(context: "launch-ambient")
@@ -79,8 +80,8 @@ import AVFoundation
}
/// Activate the VoIP audio session. Called from Dart via the
/// `chanora/ios_audio_session` channel when a voice channel join
/// reaches the `BridgeEvent::AudioStarted` stage. Configures
/// `chanora/ios_audio_session` channel before a voice channel join
/// starts VoiceProcessingIO. Configures
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
/// (Spotify, podcasts) can keep playing alongside the voice
/// channel matching the Telegram group-call UX. Idempotent:
+13 -1
View File
@@ -34,6 +34,7 @@ import 'services/prefetch_debouncer.dart';
import 'services/snapshot_state_mapper.dart';
import 'services/ts3_server_link.dart';
import 'services/ui_preferences_service.dart';
import 'services/voice_join_ordering.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/frb_generated.dart';
import 'src/rust/lib.dart' as rust_err;
@@ -1301,7 +1302,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
}
}
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
await joinVoiceChannelWithIosAudioSession(
channelId: ch.id,
password: password ?? '',
voiceJoin: rust.voiceJoin,
activateIosAudioSession: iosAudioSessionController.activate,
deactivateIosAudioSession: iosAudioSessionController.deactivate,
// Server says we are already in the target channel: the user is
// still joined to a voice channel, so the iOS audio session must
// stay active. The catch below converts this rethrow into the
// success-on-already-joined branch.
isJoinSuccess: _isAlreadyInChannel,
);
if (!mounted) return;
setState(() {
_currentVoiceChannelId = ch.id;
@@ -10,9 +10,9 @@ const iosAudioSessionChannelName = 'chanora/ios_audio_session';
/// launch and leaves it inactive. The session is only switched to
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
/// voice channel is actually active. This controller is the Dart
/// side of that contract — call [activate] when the Rust engine
/// emits `BridgeEvent::AudioStarted` and [deactivate] on
/// `BridgeEvent::AudioStopped`.
/// side of that contract — call [activate] before the Rust engine
/// starts VoiceProcessingIO and [deactivate] on
/// `BridgeEvent::AudioStopped` or failed joins.
///
/// On non-iOS platforms both methods are no-ops; the platforms
/// handle their own session lifecycle elsewhere (Android via
@@ -0,0 +1,36 @@
typedef VoiceJoinCallback = Future<void> Function({
required BigInt channelId,
required String password,
});
typedef IosVoiceSessionActivation = Future<void> Function();
typedef IosVoiceSessionDeactivation = Future<void> Function();
/// Predicate used to recognise `voiceJoin` errors that the caller treats as a
/// successful join outcome (e.g. the server replied "already in channel").
///
/// When this returns `true` for a thrown error, the iOS audio session is kept
/// active because the user is still considered joined to the channel. The
/// error is still rethrown so the caller can run its success-on-already-joined
/// branch and update local state.
typedef VoiceJoinSuccessPredicate = bool Function(Object error);
Future<void> joinVoiceChannelWithIosAudioSession({
required BigInt channelId,
required String password,
required VoiceJoinCallback voiceJoin,
required IosVoiceSessionActivation activateIosAudioSession,
required IosVoiceSessionDeactivation deactivateIosAudioSession,
VoiceJoinSuccessPredicate? isJoinSuccess,
}) async {
await activateIosAudioSession();
try {
await voiceJoin(channelId: channelId, password: password);
} catch (e) {
if (isJoinSuccess != null && isJoinSuccess(e)) {
rethrow;
}
await deactivateIosAudioSession();
rethrow;
}
}
@@ -15,6 +15,12 @@ const double _chatSidebarTileExtent = 92;
const double _chatSidebarCompactTileExtent = 76;
const double _chatSidebarCompactHeight = 84;
typedef ChatMessageSender =
Future<void> Function({
required String message,
required rust.BridgeMessageTarget target,
});
/// One chat/activity message shown in the chat hub.
class ChatEntry {
/// Construct a chat entry.
@@ -497,7 +503,7 @@ String chatInputPlaceholder(
case rust.BridgeMessageTarget_Client():
return 'Message $clientName...';
case rust.BridgeMessageTarget_Poke():
return 'Poke message...';
return 'Poke message optional...';
}
}
@@ -513,6 +519,16 @@ bool canSendToChatTarget(
}
}
bool canSendChatMessage(
rust.BridgeMessageTarget target,
BigInt? currentChannelId,
String text,
) {
if (!canSendToChatTarget(target, currentChannelId)) return false;
if (target is rust.BridgeMessageTarget_Poke) return true;
return text.trim().isNotEmpty;
}
String? chatSendBlockedReason(
rust.BridgeMessageTarget target,
BigInt? currentChannelId,
@@ -1066,6 +1082,7 @@ class ChatDetailView extends StatefulWidget {
this.messageMaxWidth,
this.restoredDraft,
this.onDraftChanged,
this.sendChatMessage,
});
/// Chat target displayed by this detail view.
@@ -1101,6 +1118,9 @@ class ChatDetailView extends StatefulWidget {
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
final ValueChanged<String>? onDraftChanged;
/// Sends a chat message. Defaults to the Rust bridge send path.
final ChatMessageSender? sendChatMessage;
@override
State<ChatDetailView> createState() => _ChatDetailViewState();
}
@@ -1168,9 +1188,12 @@ class _ChatDetailViewState extends State<ChatDetailView> {
void _send() {
final text = _textCtl.text.trim();
if (text.isEmpty || !_canSend) return;
if (!canSendChatMessage(widget.target, widget.currentChannelId, text)) {
return;
}
_textCtl.clear();
unawaited(rust.sendChatMessage(message: text, target: widget.target));
final sendChatMessage = widget.sendChatMessage ?? rust.sendChatMessage;
unawaited(sendChatMessage(message: text, target: widget.target));
final ownId = widget.snapshot.ownClientId;
setState(() {
widget.messages.add(
@@ -1223,6 +1246,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
channelName: widget.channelName,
clientName: widget.clientName,
);
final sendTooltip = widget.target is rust.BridgeMessageTarget_Poke
? 'Poke'
: 'Send';
return Column(
children: [
@@ -1332,7 +1358,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _send,
tooltip: 'Send',
tooltip: sendTooltip,
),
],
),
@@ -60,4 +60,22 @@ void main() {
expect(specifics['groupKey'], 'chanora.pokes');
},
);
test('show uses fallback body for empty poke messages', () async {
final service = PokeNotificationService();
await service.show(
senderName: 'Alice',
message: ' ',
senderId: BigInt.from(42),
strength: rust.BridgePokeStrength.strong,
);
final showCall = calls.singleWhere((call) => call.method == 'show');
final arguments = Map<Object?, Object?>.from(showCall.arguments as Map);
expect(arguments['title'], 'Poke from Alice');
expect(arguments['body'], 'Alice pokes you');
expect(arguments['payload'], 'poke:42');
});
}
@@ -0,0 +1,123 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/voice_join_ordering.dart';
void main() {
group('joinVoiceChannelWithIosAudioSession', () {
test('activates the iOS audio session before Rust voiceJoin', () async {
final calls = <String>[];
await joinVoiceChannelWithIosAudioSession(
channelId: BigInt.from(42),
password: 'secret',
activateIosAudioSession: () async {
calls.add('activateIosAudioSession');
},
deactivateIosAudioSession: () async {
calls.add('deactivateIosAudioSession');
},
voiceJoin: ({required channelId, required password}) async {
expect(channelId, BigInt.from(42));
expect(password, 'secret');
calls.add('voiceJoin');
},
);
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
});
test('deactivates the iOS audio session when Rust voiceJoin fails',
() async {
final calls = <String>[];
await expectLater(
joinVoiceChannelWithIosAudioSession(
channelId: BigInt.from(42),
password: '',
activateIosAudioSession: () async {
calls.add('activateIosAudioSession');
},
deactivateIosAudioSession: () async {
calls.add('deactivateIosAudioSession');
},
voiceJoin: ({required channelId, required password}) async {
calls.add('voiceJoin');
throw StateError('join rejected');
},
),
throwsStateError,
);
expect(calls, [
'activateIosAudioSession',
'voiceJoin',
'deactivateIosAudioSession',
]);
});
test(
'keeps the iOS audio session active when voiceJoin throws but the '
'error is recognised as already-in-channel (treated as success); '
'still rethrows so the caller runs its success-on-already-joined branch',
() async {
final calls = <String>[];
await expectLater(
joinVoiceChannelWithIosAudioSession(
channelId: BigInt.from(42),
password: '',
activateIosAudioSession: () async {
calls.add('activateIosAudioSession');
},
deactivateIosAudioSession: () async {
calls.add('deactivateIosAudioSession');
},
voiceJoin: ({required channelId, required password}) async {
calls.add('voiceJoin');
throw _FakeAlreadyInChannel();
},
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
),
throwsA(isA<_FakeAlreadyInChannel>()),
);
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
},
);
test(
'deactivates the iOS audio session when isJoinSuccess returns false '
'for a non-success error',
() async {
final calls = <String>[];
await expectLater(
joinVoiceChannelWithIosAudioSession(
channelId: BigInt.from(42),
password: '',
activateIosAudioSession: () async {
calls.add('activateIosAudioSession');
},
deactivateIosAudioSession: () async {
calls.add('deactivateIosAudioSession');
},
voiceJoin: ({required channelId, required password}) async {
calls.add('voiceJoin');
throw StateError('join rejected');
},
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
),
throwsStateError,
);
expect(calls, [
'activateIosAudioSession',
'voiceJoin',
'deactivateIosAudioSession',
]);
},
);
});
}
class _FakeAlreadyInChannel implements Exception {}
@@ -455,7 +455,7 @@ void main() {
channelName: '',
clientName: 'Alpha',
),
'Poke message...',
'Poke message optional...',
);
});
@@ -737,6 +737,179 @@ void main() {
refresh.dispose();
});
test('evaluates target-aware chat message send policy', () {
final clientTarget = rust.BridgeMessageTarget.client(BigInt.from(2));
final pokeTarget = rust.BridgeMessageTarget.poke(BigInt.from(2));
expect(canSendChatMessage(pokeTarget, null, ''), isTrue);
expect(canSendChatMessage(pokeTarget, null, ' '), isTrue);
expect(canSendChatMessage(pokeTarget, null, 'wake up'), isTrue);
expect(
canSendChatMessage(const rust.BridgeMessageTarget.server(), null, ''),
isFalse,
);
expect(
canSendChatMessage(
const rust.BridgeMessageTarget.channel(),
BigInt.from(10),
'',
),
isFalse,
);
expect(canSendChatMessage(clientTarget, null, ''), isFalse);
expect(
canSendChatMessage(
const rust.BridgeMessageTarget.channel(),
null,
'hello',
),
isFalse,
);
expect(
canSendChatMessage(
const rust.BridgeMessageTarget.channel(),
BigInt.from(10),
'hello',
),
isTrue,
);
});
testWidgets('poke detail sends an empty poke when the composer is empty', (
tester,
) async {
String? sentMessage;
rust.BridgeMessageTarget? sentTarget;
final messages = <ChatEntry>[];
final target = rust.BridgeMessageTarget.poke(BigInt.from(2));
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(
channels: const [],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.zero),
],
),
target: target,
clientName: 'Alpha',
currentChannelId: null,
channelName: '',
sendChatMessage: ({required message, required target}) async {
sentMessage = message;
sentTarget = target;
},
),
),
),
);
expect(find.byTooltip('Poke'), findsOneWidget);
expect(find.byTooltip('Send'), findsNothing);
await tester.tap(find.byTooltip('Poke'));
await tester.pump();
expect(sentMessage, '');
expect(sentTarget, target);
expect(messages, hasLength(1));
expect(messages.single.isPoke, isTrue);
expect(messages.single.message, '');
expect(find.textContaining('You poked "Alpha"'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
});
testWidgets('poke detail sends typed optional poke message', (tester) async {
String? sentMessage;
rust.BridgeMessageTarget? sentTarget;
final messages = <ChatEntry>[];
final target = rust.BridgeMessageTarget.poke(BigInt.from(2));
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(
channels: const [],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.zero),
],
),
target: target,
clientName: 'Alpha',
currentChannelId: null,
channelName: '',
sendChatMessage: ({required message, required target}) async {
sentMessage = message;
sentTarget = target;
},
),
),
),
);
await tester.enterText(find.byType(TextField), 'wake up');
await tester.tap(find.byTooltip('Poke'));
await tester.pump();
expect(sentMessage, 'wake up');
expect(sentTarget, target);
expect(messages.single.message, 'wake up');
expect(
find.textContaining('You poked "Alpha" with message: wake up'),
findsOneWidget,
);
});
testWidgets('channel detail blocks empty sends with a joined channel', (
tester,
) async {
var sendCount = 0;
final messages = <ChatEntry>[];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ChatDetailView(
messages: messages,
snapshot: snapshot(
channels: [channel(BigInt.from(10), 'Lobby')],
clients: [
client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)),
],
),
target: const rust.BridgeMessageTarget.channel(),
clientName: '',
currentChannelId: BigInt.from(10),
channelName: 'Lobby',
sendChatMessage: ({required message, required target}) async {
sendCount++;
},
),
),
),
);
expect(find.byTooltip('Send'), findsOneWidget);
await tester.tap(find.byTooltip('Send'));
await tester.pump();
expect(sendCount, 0);
expect(messages, isEmpty);
});
test('blocks channel chat when no voice channel is joined', () {
expect(
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
+37 -1
View File
@@ -174,6 +174,10 @@ fn normalize_channel_password(password: Option<String>) -> Option<String> {
.filter(|p| !p.is_empty())
}
fn should_dispatch_text_message(message: &str, target: &MessageTarget) -> bool {
!message.trim().is_empty() || matches!(target, MessageTarget::Poke(_))
}
/// The top-level Chanora session. Owns at most one active server
/// connection (DEC-006).
#[derive(Clone)]
@@ -673,7 +677,7 @@ impl ChanoraSession {
message: String,
target: MessageTarget,
) -> Result<(), CoreError> {
if message.trim().is_empty() {
if !should_dispatch_text_message(&message, &target) {
return Ok(());
}
let guard = self.inner.lock().await;
@@ -2535,6 +2539,38 @@ mod tests {
);
}
#[test]
fn empty_poke_messages_are_dispatchable() {
assert!(super::should_dispatch_text_message(
"",
&MessageTarget::Poke(42)
));
assert!(super::should_dispatch_text_message(
" \t ",
&MessageTarget::Poke(42)
));
}
#[test]
fn empty_non_poke_messages_remain_suppressed() {
assert!(!super::should_dispatch_text_message(
"",
&MessageTarget::Server
));
assert!(!super::should_dispatch_text_message(
" ",
&MessageTarget::Channel
));
assert!(!super::should_dispatch_text_message(
"",
&MessageTarget::Client(42)
));
assert!(super::should_dispatch_text_message(
"hello",
&MessageTarget::Channel
));
}
#[tokio::test]
async fn empty_address_is_rejected() {
let s = ChanoraSession::new();
+193
View File
@@ -49,6 +49,11 @@ terms.
| `flutter` | 0.0.0 | sdk | yes |
| `flutter_foreground_task` | 9.2.2 | hosted | yes |
| `flutter_lints` | 6.0.0 | hosted | yes |
| `flutter_local_notifications` | 22.0.0 | hosted | yes |
| `flutter_local_notifications_linux` | 8.0.1 | hosted | yes |
| `flutter_local_notifications_platform_interface` | 12.0.0 | hosted | yes |
| `flutter_local_notifications_web` | 1.0.0 | hosted | yes |
| `flutter_local_notifications_windows` | 3.1.0 | hosted | yes |
| `flutter_localizations` | 0.0.0 | sdk | yes |
| `flutter_rust_bridge` | 2.12.0 | hosted | yes |
| `flutter_test` | 0.0.0 | sdk | yes |
@@ -117,6 +122,7 @@ terms.
| `string_scanner` | 1.4.1 | hosted | yes |
| `term_glyph` | 1.2.2 | hosted | yes |
| `test_api` | 0.7.11 | hosted | yes |
| `timezone` | 0.11.0 | hosted | yes |
| `typed_data` | 1.4.0 | hosted | yes |
| `url_launcher` | 6.3.2 | hosted | yes |
| `url_launcher_android` | 6.3.30 | hosted | yes |
@@ -1869,6 +1875,166 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications 22.0.0
```
Copyright 2018 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_linux 8.0.1
```
Copyright 2018 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_platform_interface 12.0.0
```
Copyright 2020 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_web 1.0.0
```
Copyright 2020 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_local_notifications_windows 3.1.0
```
Copyright 2024 Michael Bui. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### flutter_localizations 0.0.0
```
@@ -4673,6 +4839,33 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### timezone 0.11.0
```
Copyright (c) 2014, timezone project authors.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### typed_data 1.4.0
```
+2 -1
View File
@@ -291,7 +291,7 @@ those terms.
| chanora_diagnostics | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_prefetch | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_protocol | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_resolver | 0.1.0 | `Apache License 2.0` | — |
| chanora_resolver | 0.2.0-beta.1 | `Apache License 2.0` | — |
| chanora_state | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| chanora_storage | 0.2.0-beta.1 | `Apache License 2.0` | <https://github.com/anomalyco/opencode> |
| alsa | 0.11.0 | `Apache License 2.0` | <https://github.com/diwic/alsa-rs> |
@@ -10967,3 +10967,4 @@ cargo about generate --output-file docs/security/license-inventory.html about.hb
This artefact supports the DEC-012 legal review handoff at
`docs/governance/legal-review-readiness.md`.
@@ -0,0 +1,176 @@
# Poke Without Message Design
**Date:** 2026-06-09
**Status:** Approved design for implementation
**Scope:** Allow intentional TeamSpeak-compatible pokes without message text while preserving empty-message blocking for normal chat targets.
## 1. Goal
Chanora should let a user poke another connected client without typing a message. A poke is an attention event, not an empty chat message. The UI should make that distinction explicit so the empty state is intentional, understandable, and safe from accidental spam.
The implementation target is narrow:
- Sending a poke with an empty message is allowed.
- Sending an empty normal chat message remains blocked.
- Incoming and historical empty pokes continue to render as poke events, not blank chat bubbles.
- Existing poke notification behavior remains compatible with message and no-message pokes.
## 2. Research Summary
TeamSpeak-compatible poke behavior is command-like: the ServerQuery shape is `clientpoke clid={clientID} msg={text}`, backed by poke permissions such as `i_client_poke_power` and `i_client_needed_poke_power`. The product semantics are closer to an attention nudge than to a private text message.
Client behavior and community expectations point to two UX risks:
- The action can be useful without text because the sender often only wants attention.
- The action can be abused as interruption spam, so the UI must keep the action deliberate and preserve existing receiver-side suppression and notification preferences.
The approved product direction is therefore to model no-message poke as a first-class attention event with optional text, rather than as an exception in the normal chat composer.
## 3. Recommended UX
Poke uses a poke-specific sending surface. The surface may reuse the current chat detail implementation internally, but the user-facing copy and validation must make the target type clear.
Required poke-target behavior:
| Element | Behavior |
|---|---|
| Header | Shows that the current surface is for poking the selected user. |
| Text field | Optional message input. Placeholder should communicate that the message is optional. |
| Primary action | Label is `Poke`, not `Send`. Enabled even when the trimmed message is empty. |
| Empty send | Sends an intentional poke with `message: ''`. |
| Non-empty send | Sends a poke with the typed message. |
| History row | Empty poke renders as an attention event such as `Alice poked you`, never as a blank message. |
Required non-poke chat behavior:
| Target | Empty text behavior |
|---|---|
| Channel chat | Block send. |
| Server chat | Block send. |
| Private chat | Block send. |
| Any future text-chat target | Block send unless it is explicitly modeled as a poke-like attention event. |
## 4. Architecture Boundaries
The change should stay inside the existing UI and bridge boundaries:
- Flutter owns presentation, composer validation, button enablement, localization copy, and widget tests.
- Flutter Rust Bridge continues to pass typed `BridgeMessageTarget` and message text across the bridge.
- Rust Core and Protocol continue to route `MessageTarget::Poke(client_id)` through the existing poke send path.
- Protocol remains the only layer that knows how `tsclientlib` sends a TeamSpeak-compatible poke.
No new protocol concept is required. The existing bridge/protocol model already has `BridgeMessageTarget.poke` / `MessageTarget::Poke(u64)` and `client.poke(message)`. The key design change is target-aware composer validation in Flutter.
## 5. Implementation Design
The implementation should use a target-aware send policy.
For `BridgeMessageTarget.poke`:
- Do not reject an empty trimmed input.
- Send the original or trimmed message according to the existing chat composer convention. If the current send path trims normal messages before sending, apply the same text normalization before passing the poke message.
- Clear the composer after successful send, including empty-poke sends.
- Preserve existing error and snackbar behavior for failed sends.
For all other `BridgeMessageTarget` variants:
- Keep the existing empty-trimmed-text guard.
- Keep current button enablement and keyboard submit behavior unless those paths need target-aware adjustment to preserve the same empty-message block.
A simple policy helper is preferred over scattered conditionals. Example shape:
```dart
bool canSendMessage({
required BridgeMessageTarget target,
required String text,
}) {
if (target is BridgeMessageTarget_Poke) {
return true;
}
return text.trim().isNotEmpty;
}
```
The exact Dart type checks should follow the generated bridge type names used in the current codebase.
## 6. Notification And History Behavior
Existing no-message receiving behavior should remain the reference behavior:
- Incoming empty poke notification body falls back to text equivalent to `Alice pokes you`.
- Incoming poke with message includes the message in the notification body.
- Active-chat suppression and muted-sender preferences continue to apply.
- Poke history rows distinguish poke events from normal chat rows.
The send-side change must not introduce a new blank message row shape. If the sender's local history records sent pokes, empty poke history should render as a poke action line with no empty bubble.
## 7. Abuse And Safety Rules
This slice does not add new anti-spam controls. It relies on existing TeamSpeak-compatible permissions, inbound poke strength/rate suppression, notification preferences, active-chat suppression, and muted sender handling.
The implementation must not weaken any existing receiver-side controls. If testing reveals that empty sent pokes bypass suppression, notification preferences, or history classification, that is a bug to fix in the same implementation pass.
Future follow-ups, not part of this slice:
- Per-sender or per-server outbound poke cooldown UI.
- Receiver-side "never show poke dialog" equivalent beyond current notification preferences.
- Dedicated poke inbox or grouped poke history.
## 8. Files Expected To Change
Expected implementation targets:
| File | Expected change |
|---|---|
| `apps/chanora_flutter/lib/widgets/chat_views.dart` | Make composer validation and action enablement target-aware for poke. Update poke placeholder/action copy if needed. |
| `apps/chanora_flutter/test/widgets/chat_views_test.dart` | Add widget coverage for empty poke send and normal empty chat blocking. |
Optional targets if the implementation exposes missing copy or routing seams:
| File | Possible change |
|---|---|
| `apps/chanora_flutter/lib/main.dart` | Only if opening a poke target needs a clearer poke-specific title or route configuration. |
| `apps/chanora_flutter/lib/l10n/*.arb` | Only if current copy cannot express optional poke messages without hard-coded strings. |
| `apps/chanora_flutter/test/services/poke_notification_service_test.dart` | Only if send-side changes affect notification payload assumptions. |
The Rust protocol path should not need behavior changes unless tests prove that empty strings are blocked below Flutter.
## 9. Test Design
Required tests:
- Poke target shows an enabled primary `Poke` action when the text field is empty.
- Tapping `Poke` on an empty poke target calls the send callback with `BridgeMessageTarget.poke` and an empty message.
- Poke target still sends a typed message when text is present.
- Normal channel/server/private chat targets keep blocking empty sends.
- Empty poke history renders as a poke event line, not an empty text bubble.
Useful regression checks if already easy to target:
- Keyboard submit follows the same target-aware validation as the button.
- Failed empty-poke send keeps existing error presentation.
- Incoming empty poke notification tests still pass unchanged.
## 10. Validation
For the implementation branch, run focused Flutter verification first:
```text
flutter test test/widgets/chat_views_test.dart
flutter test test/services/poke_notification_service_test.dart
flutter test test/services/poke_active_chat_test.dart
flutter analyze
```
If Rust or bridge files are touched, also run the matching Rust and bridge checks for the touched layer. Documentation-only changes require reading the affected spec and checking the diff; code tests are not required for this design commit.
## 11. Success Criteria
This design is implemented successfully when:
- A user can send a poke with no typed message.
- Normal chat targets still reject empty sends.
- The poke composer communicates that message text is optional.
- Empty pokes are represented as poke events in history and notifications.
- Existing poke notification preferences and suppression behavior remain intact.
- Focused widget/service tests and `flutter analyze` pass, or any unrelated pre-existing failure is named with evidence.