Release IKAR 1.0.8: fix incoming calls, cancellation and call history
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-10-08 21:34:15 +03:00
parent 317cfdae38
commit 0508b70732
31 changed files with 1082 additions and 62 deletions
+11
View File
@@ -99,6 +99,17 @@ class CallsModule {
CallsModule(this._api);
Future<void> endCall(String conversationId, {required String reason}) async {
try {
await _api.sendRequest(Opcode.ikarEndCall, {
'conversationId': conversationId,
'reason': reason,
}, silent: true);
} catch (error) {
logger.w('CallsModule.endCall: $error');
}
}
_CallerEndpoint _parseCallerEndpoint(
Map payload,
List<String> keys, {
+36 -5
View File
@@ -50,7 +50,8 @@ class CallBridge {
return;
}
if (action == 'ended') {
CallController.instance.dismissIncoming();
final id = event['conversationId']?.toString();
if (id != null) CallController.instance.cancelConversation(id);
return;
}
final dataStr = event['data'];
@@ -69,10 +70,38 @@ class CallBridge {
);
}
Future<void> notifyAccepted({String? caller}) async {
Future<void> showIncoming(Map<dynamic, dynamic> payload) async {
if (!_android) return;
try {
await _method.invokeMethod<void>('notifyAccepted', {'caller': caller});
await _method.invokeMethod<void>('showIncoming', {
for (final entry in payload.entries)
entry.key.toString(): entry.value?.toString() ?? '',
'type': 'InboundCall',
'iv': payload['type'] == 'VIDEO' ? 'true' : 'false',
});
} catch (error) {
logger.w('CallBridge.showIncoming: $error');
}
}
Future<void> cancelConversation(String id) async {
if (!_android) return;
try {
await _method.invokeMethod<void>('cancelConversation', {
'conversationId': id,
});
} catch (error) {
logger.w('CallBridge.cancelConversation: $error');
}
}
Future<void> notifyAccepted({String? caller, String? conversationId}) async {
if (!_android) return;
try {
await _method.invokeMethod<void>('notifyAccepted', {
'caller': caller,
'conversationId': conversationId,
});
} catch (e) {
logger.w('CallBridge.notifyAccepted: caller=$caller $e');
}
@@ -108,10 +137,12 @@ class CallBridge {
}
}
Future<void> notifyEnded() async {
Future<void> notifyEnded({String? conversationId}) async {
if (!_android) return;
try {
await _method.invokeMethod<void>('notifyEnded');
await _method.invokeMethod<void>('notifyEnded', {
'conversationId': conversationId,
});
} catch (e) {
logger.w('CallBridge.notifyEnded: $e');
}
+64 -9
View File
@@ -1,9 +1,9 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show visibleForTesting;
import '../../backend/api.dart';
import '../../backend/modules/calls.dart';
import '../protocol/opcode_map.dart';
import '../push/fkm_controller.dart';
import '../protocol/packet.dart';
import '../utils/parse.dart';
import 'call_bridge.dart';
@@ -38,6 +38,8 @@ class IncomingCall {
class CallController {
CallController._();
@visibleForTesting
CallController.forTesting();
static final CallController instance = CallController._();
Api? _api;
@@ -66,6 +68,7 @@ class CallController {
IncomingCall? _pending;
IncomingCall? get pendingIncoming => _pending;
final _finishedIncoming = <String>{};
bool get isBusy => _active != null || _starting;
@@ -80,16 +83,30 @@ class CallController {
if (packet.opcode != Opcode.notifCallStart) return;
final payload = packet.payload;
if (payload is! Map) return;
final id = payload['conversationId']?.toString();
if (payload['event'] == 'ended') {
if (id != null) cancelConversation(id);
return;
}
if (payload['event'] == 'history') {
_ended.add(null);
return;
}
final vcp = payload['vcp'] as String?;
final conversationId = payload['conversationId'] as String?;
final callerId = payload['callerId'] as int?;
if (vcp == null || conversationId == null || callerId == null) return;
if (_finishedIncoming.contains(conversationId)) return;
final expiresAt = parseIntOrNull(payload['expiresAt']);
if (expiresAt != null &&
expiresAt <= DateTime.now().millisecondsSinceEpoch) {
return;
}
// Приложение свёрнуто — звонок показывает FKM отдельным уведомлением,
// приём оттуда вернётся через injectFromNative.
// The native call screen works while Flutter is in the background.
if (!appResumed) {
unawaited(FkmController.instance.showIncomingCall(payload));
unawaited(CallBridge.instance.showIncoming(payload));
return;
}
@@ -104,6 +121,7 @@ class CallController {
params: params,
country: payload['country'] as String?,
isContact: payload['isContact'] as bool?,
callerName: payload['userName']?.toString(),
),
);
}
@@ -134,13 +152,15 @@ class CallController {
params: params,
country: data['country']?.toString(),
isContact: data['isContact'] is bool ? data['isContact'] as bool : null,
callerName: data['userName']?.toString(),
callerName: (data['userName'] ?? data['senderName'] ?? data['title'])
?.toString(),
autoAccept: autoAccept,
),
);
}
void _emitIncoming(IncomingCall incoming) {
if (_finishedIncoming.contains(incoming.conversationId)) return;
if (isBusy) return;
if (_pending?.conversationId == incoming.conversationId) return;
_pending = incoming;
@@ -153,6 +173,21 @@ class CallController {
_canceled.add(null);
}
void cancelConversation(String conversationId) {
_finishedIncoming.add(conversationId);
if (_finishedIncoming.length > 128) {
_finishedIncoming.remove(_finishedIncoming.first);
}
unawaited(CallBridge.instance.cancelConversation(conversationId));
if (_pending?.conversationId == conversationId) dismissIncoming();
if ((_active?.info.conversationId ??
_active?.ws2Config.uri.queryParameters['conversationId']) ==
conversationId) {
unawaited(endActive());
}
_ended.add(null);
}
Future<CallSession> startOutgoing(int calleeId, {bool isVideo = false}) =>
_startExclusive(() async {
final out = await _calls!.initiateCall(calleeId, isVideo: isVideo);
@@ -162,7 +197,12 @@ class CallController {
device: _api?.callsDevice,
osVersion: _api?.callsOsVersion,
);
final session = CallSession(ws2Config: config, role: CallRole.caller);
final session = CallSession(
ws2Config: config,
role: CallRole.caller,
endOnServer: (reason) =>
_calls!.endCall(out.conversationId, reason: reason),
);
return _launch(session, session.start);
});
@@ -206,6 +246,9 @@ class CallController {
}
Future<CallSession> acceptIncoming(IncomingCall call) async {
if (_finishedIncoming.contains(call.conversationId) || isBusy) {
throw StateError('Звонок уже завершён или занят');
}
_pending = null;
CallBridge.instance.cancelIncoming();
final config = Ws2Config.fromVcp(
@@ -218,6 +261,8 @@ class CallController {
ws2Config: config,
params: call.params,
role: CallRole.callee,
endOnServer: (reason) =>
_calls!.endCall(call.conversationId, reason: reason),
);
return _launch(session, () async {
await session.start();
@@ -227,7 +272,7 @@ class CallController {
Future<void> rejectIncoming(IncomingCall call) async {
_pending = null;
CallBridge.instance.notifyEnded();
CallBridge.instance.notifyEnded(conversationId: call.conversationId);
final config = Ws2Config.fromVcp(
call.params,
conversationId: call.conversationId,
@@ -235,12 +280,17 @@ class CallController {
osVersion: _api?.callsOsVersion,
);
final signaling = Ws2Signaling(config);
final endOnServer = _calls!.endCall(
call.conversationId,
reason: 'REJECTED',
);
try {
await signaling.connect();
await signaling.hangup(reason: 'REJECTED');
} catch (_) {
} finally {
await signaling.close();
await endOnServer;
}
}
@@ -268,7 +318,10 @@ class CallController {
} catch (_) {}
rethrow;
}
CallBridge.instance.notifyAccepted(caller: caller);
CallBridge.instance.notifyAccepted(
caller: caller,
conversationId: session.ws2Config.uri.queryParameters['conversationId'],
);
return session;
}
@@ -287,7 +340,9 @@ class CallController {
_activeJoinLink = null;
await _activeSub?.cancel();
_activeSub = null;
CallBridge.instance.notifyEnded();
CallBridge.instance.notifyEnded(
conversationId: session.ws2Config.uri.queryParameters['conversationId'],
);
_ended.add(null);
}
+13
View File
@@ -67,12 +67,14 @@ class CallSession {
final ConversationParams? params;
final CallRole role;
final bool isGroup;
final Future<void> Function(String reason)? endOnServer;
CallSession({
required this.ws2Config,
required this.role,
this.params,
this.isGroup = false,
this.endOnServer,
});
Ws2Signaling? _signaling;
@@ -2367,12 +2369,23 @@ class CallSession {
} catch (_) {}
}
bool _hangingUp = false;
Future<void> hangup({String? reason}) async {
if (_ended || _hangingUp) return;
_hangingUp = true;
final r = reason ?? _autoHangupReason();
final serverEnd = endOnServer?.call(r).catchError((Object error) {
logger.w('[call] server termination failed: $error');
});
try {
await _signaling?.hangup(reason: r);
} catch (_) {}
_end();
try {
await serverEnd;
} catch (error) {
logger.w('[call] server termination failed: $error');
}
}
String _autoHangupReason() {
+2 -2
View File
@@ -5,6 +5,8 @@
///
/// файл писла нейронка (я че ебанутый чтоль чтобы вручную хуярить опкоды и их значения)
abstract class Opcode {
// IKAR extension: terminate even before the recipient connects to WS2.
static const int ikarEndCall = 1001;
// ── Session ────────────────────────────────────────────────────────
static const int ping = 1; // Пинг
static const int debug = 2; // Отладка / понг
@@ -235,7 +237,6 @@ abstract class Opcode {
static const int loginByQr = 291; // Вход по QR
static const int msgDelivery = 303; // Подтверждение доставки сообщения
static const int storiesGetByStoryId = 220; // Истории по ID
// ── Human-readable names ───────────────────────────────────────────
@@ -424,6 +425,5 @@ abstract class Opcode {
getQrStatus: 'GET_QR_STATUS',
loginByQr: 'LOGIN_BY_QR',
msgDelivery: 'MSG_DELIVERY',
};
}
+1 -1
View File
@@ -563,7 +563,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
child: _localRendererReady && _localRenderer.srcObject != null
? CallVideoView(
renderer: _localRenderer,
mirror: _session?.localScreen != true,
mirror: false,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
placeholder: _localPreviewIcon(cs),
)
+12 -1
View File
@@ -36,10 +36,15 @@ class _CallsTabState extends State<CallsTab>
bool _isLoading = true;
int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные'
StreamSubscription<LoginStatus>? _loginSub;
StreamSubscription<void>? _endedSub;
int _historyGeneration = 0;
@override
void initState() {
super.initState();
_endedSub = CallController.instance.callEnded.listen((_) {
if (accountModule.isLoggedIn) _loadHistory();
});
if (accountModule.isLoggedIn) {
_loadHistory();
} else {
@@ -56,6 +61,7 @@ class _CallsTabState extends State<CallsTab>
@override
void dispose() {
_loginSub?.cancel();
_endedSub?.cancel();
super.dispose();
}
@@ -65,7 +71,9 @@ class _CallsTabState extends State<CallsTab>
}
Future<void> _loadHistory() async {
final generation = ++_historyGeneration;
final p = await AppDatabase.loadActiveProfile();
if (!mounted || generation != _historyGeneration) return;
if (p == null) {
if (mounted) setState(() => _isLoading = false);
return;
@@ -76,9 +84,12 @@ class _CallsTabState extends State<CallsTab>
try {
calls = await callsModule.fetchHistory(p.id, p.id);
} catch (e) {
if (mounted) setState(() => _isLoading = false);
if (mounted && generation == _historyGeneration) {
setState(() => _isLoading = false);
}
return;
}
if (!mounted || generation != _historyGeneration) return;
final List<CallLogEntry> grouped = [];
for (final call in calls) {
@@ -227,6 +227,13 @@ class _NotificationsScreenState extends State<NotificationsScreen>
value: _callNotifications,
onChanged: _onCallNotificationsChanged,
),
if (Platform.isAndroid)
SettingsNavTile(
icon: Symbols.lock,
label: l10n.notificationsCallPermissionTitle,
onTap: () => CallBridge.instance
.openFullScreenIntentSettings(),
),
SettingsToggleTile(
icon: Symbols.person_add,
label: l10n.notificationsNewContactsLabel,