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
+13 -3
View File
@@ -3,6 +3,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ikar/core/security/app_lock.dart';
import 'package:ikar/frontend/screens/lock/app_lock_layer.dart';
import 'package:ikar/frontend/screens/lock/passcode_pad.dart';
import 'package:ikar/l10n/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -23,9 +24,18 @@ Future<void> _enter(WidgetTester tester, String pin) async {
await tester.tap(find.text(digit));
await tester.pump();
}
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 400)),
);
// Wait for password verification, which can take longer under build load.
for (var attempt = 0; attempt < 100; attempt++) {
final keypad = find.byType(PasscodeKeypad);
if (keypad.evaluate().isEmpty ||
tester.widget<PasscodeKeypad>(keypad).enabled) {
break;
}
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
}
await tester.pumpAndSettle();
}
+14
View File
@@ -34,6 +34,20 @@ class BrokenSignaling extends Ws2Signaling {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('hangup reaches IKAR even before WS2 connects', () async {
final reasons = <String>[];
final session = CallSession(
ws2Config: config,
role: CallRole.caller,
endOnServer: (reason) async {
reasons.add(reason);
},
);
await session.hangup();
await session.hangup();
expect(reasons, ['CANCELED']);
expect(session.currentState, CallSessionState.ended);
});
test(
'ICE send failure is handled and late candidates are ignored after hangup',
() async {
+98
View File
@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:ikar/backend/api.dart';
import 'package:ikar/core/calls/call_controller.dart';
import 'package:ikar/core/protocol/packet.dart';
import 'package:kusoft/src/rust/api.dart' show CallParams;
import 'package:kusoft/src/rust/frb_generated.dart' show RustLib, RustLibApi;
class _PushApi extends Api {
final packets = StreamController<Packet>.broadcast();
@override
Stream<Packet> get pushStream => packets.stream;
}
class _CallDecoder implements RustLibApi {
@override
CallParams? crateApiDecodeVcp({
required String vcp,
required String conversationId,
}) => const CallParams(
token: 'synthetic',
wsEndpoint: 'wss://calls.invalid/ws2',
turn: [],
isVideo: false,
);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() => RustLib.initMock(api: _CallDecoder()));
late _PushApi api;
late CallController controller;
setUp(() {
api = _PushApi();
controller = CallController.forTesting()..appResumed = true;
controller.init(api);
});
tearDown(() async {
controller.dispose();
await api.packets.close();
await api.dispose();
});
Future<void> push(Map<String, dynamic> data) async {
api.packets.add(Packet(opcode: 137, payload: data));
await Future<void>.delayed(Duration.zero);
}
Map<String, dynamic> invite(String id) => {
'conversationId': id,
'callerId': 2,
'vcp': 'synthetic-incoming',
'type': 'AUDIO',
};
test(
'caller cancellation dismisses a ringing recipient without WS2',
() async {
var canceled = 0;
final sub = controller.incomingCanceled.listen((_) => canceled++);
await push(invite('first'));
expect(controller.pendingIncoming?.conversationId, 'first');
await push({'conversationId': 'first', 'event': 'ended'});
expect(controller.pendingIncoming, isNull);
expect(canceled, 1);
await sub.cancel();
},
);
test('late invite after cancellation does not start ringing', () async {
await push({'conversationId': 'first', 'event': 'ended'});
await push(invite('first'));
controller.injectFromNative(invite('first'), autoAccept: true);
expect(controller.pendingIncoming, isNull);
});
test('old cancellation cannot dismiss a newer incoming call', () async {
await push(invite('new'));
await push({'conversationId': 'old', 'event': 'ended'});
expect(controller.pendingIncoming?.conversationId, 'new');
});
test(
'expired invite is ignored and committed history refreshes listeners',
() async {
await push({
...invite('expired'),
'expiresAt': DateTime.now().millisecondsSinceEpoch - 1000,
});
expect(controller.pendingIncoming, isNull);
var refreshed = 0;
final sub = controller.callEnded.listen((_) => refreshed++);
await push({'conversationId': 'expired', 'event': 'history'});
expect(refreshed, 1);
await sub.cancel();
},
);
}