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
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:
@@ -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, {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user