Release IKAR 1.0.7: port navigation and sharing improvements, harden calls
Tests / test (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
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-10-08 20:38:54 +03:00
parent 68f7957043
commit 317cfdae38
37 changed files with 1483 additions and 282 deletions
+65
View File
@@ -0,0 +1,65 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:ikar/core/calls/call_session.dart';
import 'package:ikar/core/calls/ws2_signaling.dart';
final config = Ws2Config(
userId: 1,
uri: Uri(scheme: 'wss', host: 'calls.invalid'),
);
class BrokenSignaling extends Ws2Signaling {
BrokenSignaling() : super(config);
int sent = 0;
@override
Future<void> transmitCandidate({
required int participantId,
required String candidate,
required String sdpMid,
required int sdpMLineIndex,
String participantType = 'USER',
int deviceIdx = 0,
}) async {
sent++;
throw const SocketException('mobile network disconnected');
}
@override
Future<void> hangup({String reason = 'HUNGUP'}) async {}
@override
Future<void> close() async {}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'ICE send failure is handled and late candidates are ignored after hangup',
() async {
final signaling = BrokenSignaling();
final session = CallSession(ws2Config: config, role: CallRole.caller);
session.bindCandidateSignalingForTesting(signaling, 2);
final errors = <Object>[];
runZonedGuarded(() {
session.receiveLocalCandidateForTesting(
RTCIceCandidate('candidate:mobile', '0', 0),
);
}, (error, stack) => errors.add(error));
await Future<void>.delayed(Duration.zero);
final reconnecting = session.isReconnecting;
await session.hangup();
runZonedGuarded(() {
session.receiveLocalCandidateForTesting(
RTCIceCandidate('candidate:late', '0', 0),
);
}, (error, stack) => errors.add(error));
await Future<void>.delayed(Duration.zero);
expect(reconnecting, isTrue);
expect(signaling.sent, 1);
expect(errors, isEmpty);
expect(session.currentState, CallSessionState.ended);
await Future<void>.delayed(const Duration(milliseconds: 1100));
},
);
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ikar/core/config/app_bottom_navigation_style.dart';
import 'package:ikar/frontend/widgets/compact_bottom_navigation_bar.dart';
import 'package:ikar/frontend/widgets/bottom_navigation_style_card.dart';
import 'package:ikar/frontend/widgets/sliding_pill_nav.dart';
import 'package:ikar/l10n/app_localizations.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
AppBottomNavigationStyle.current.value = BottomNavigationStyle.floating;
});
testWidgets(
'compact tabs retain badge, selection and account long press at large text size',
(tester) async {
var selected = -1;
var held = -1;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(
size: Size(320, 640),
textScaler: TextScaler.linear(2),
),
child: Scaffold(
bottomNavigationBar: CompactBottomNavigationBar(
items: const [
PillNavItem(
icon: Icons.chat,
label: 'Чаты',
unreadCount: 120,
),
PillNavItem(icon: Icons.call, label: 'Звонки'),
PillNavItem(icon: Icons.people, label: 'Контакты'),
PillNavItem(
icon: Icons.settings,
label: 'Настройки',
longPressable: true,
),
],
currentIndex: 0,
onTap: (index) => selected = index,
onItemLongPress: (index, _) => held = index,
),
),
),
),
);
expect(find.text('99+'), findsOneWidget);
await tester.tap(find.byIcon(Icons.call));
expect(selected, 1);
await tester.longPress(find.byIcon(Icons.settings));
expect(held, 3);
expect(tester.takeException(), isNull);
},
);
testWidgets('appearance setting persists and restores compact layout', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
locale: Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: BottomNavigationStyleCard()),
),
);
await tester.tap(find.text('Компактная'));
await tester.pumpAndSettle();
expect(
AppBottomNavigationStyle.current.value,
BottomNavigationStyle.compact,
);
AppBottomNavigationStyle.current.value = BottomNavigationStyle.floating;
await AppBottomNavigationStyle.load();
expect(
AppBottomNavigationStyle.current.value,
BottomNavigationStyle.compact,
);
});
}
+88
View File
@@ -0,0 +1,88 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ikar/backend/modules/messages.dart';
import 'package:ikar/core/utils/download_progress.dart';
import 'package:ikar/core/utils/file_download.dart';
import 'package:ikar/frontend/widgets/attachment/bubbles/bubble_context.dart';
import 'package:ikar/frontend/widgets/attachment/bubbles/file_bubble.dart';
import 'package:ikar/l10n/app_localizations.dart';
import 'package:ikar/models/attachment.dart';
void main() {
testWidgets(
'whole file card cannot open twice while external opening is pending',
(tester) async {
var opens = 0;
var ready = Completer<FileDownloadResult>();
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) {
const file = FileAttachment(
fileId: 765,
name: 'report.pdf',
size: 1024,
);
return FileBubble(
file: file,
ctx: BubbleContext(
context: context,
cs: Theme.of(context).colorScheme,
text: Colors.black,
shape: BubbleShape.singleMiddle,
contentType: MessageType.attachment,
hasPhotoWithCaption: false,
hasMultiplePhotosNoCaption: false,
message: CachedMessage(
id: '1',
accountId: 1,
chatId: 2,
senderId: 3,
time: 1000,
status: 'sent',
),
isMe: false,
myId: 1,
chatType: 'DIALOG',
),
openFile:
(
name,
url, {
onProgress,
onReady,
download,
downloadOnlyIfMissing = false,
}) {
opens++;
onReady?.call();
return ready.future;
},
);
},
),
),
),
);
await tester.tap(find.text('report.pdf'));
await tester.pump();
await tester.tap(find.text('report.pdf'));
await tester.pump();
expect(opens, 1);
expect(find.textContaining('Открытие'), findsOneWidget);
ready.complete(const FileDownloadResult(ok: true));
await tester.pumpAndSettle();
ready = Completer<FileDownloadResult>();
await tester.tap(find.text('report.pdf'));
expect(opens, 2);
ready.complete(const FileDownloadResult(ok: true));
await tester.pumpAndSettle();
MediaDownloadProgress.set('765_report.pdf', null);
},
);
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ikar/backend/modules/pending_message_deletions.dart';
void main() {
test(
'undo retains other pending removals and isolates accounts and chats',
() {
final pending = PendingMessageDeletions();
pending.hold(1, 2, ['a', 'b']);
pending.hold(1, 2, ['c']);
pending.hold(3, 2, ['a']);
pending.release(1, 2, ['a', 'b']);
expect(pending.contains(1, 2, 'a'), isFalse);
expect(pending.contains(1, 2, 'c'), isTrue);
expect(pending.contains(3, 2, 'a'), isTrue);
expect(pending.contains(1, 4, 'c'), isFalse);
pending.dispose();
},
);
}
+86
View File
@@ -0,0 +1,86 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:ikar/core/share/share_inbox.dart';
void main() {
test(
'concurrent events present each share once and wait for file users',
() async {
final records = [
for (var i = 0; i < 3; i++) {'id': '$i', 'text': 'share $i'},
];
final shown = <String>[];
final acknowledged = <String>[];
final released = <String>[];
final upload = Completer<void>();
final inbox = ShareInbox(
consume: () async => records.isEmpty ? null : records.removeAt(0),
acknowledge: (id) async => acknowledged.add(id),
release: (id) async => released.add(id),
onShare: (payload) => shown.add(payload.text!),
);
await Future.wait([inbox.check(), inbox.check(), inbox.check()]);
expect(shown, ['share 0']);
final completing = inbox.complete(waitForRelease: () => upload.future);
await Future<void>.delayed(Duration.zero);
expect(acknowledged, ['0']);
expect(released, isEmpty);
expect(shown, ['share 0']);
upload.complete();
await completing;
expect(released, ['0']);
expect(shown, ['share 0', 'share 1']);
await inbox.complete();
await inbox.complete();
expect(shown, ['share 0', 'share 1', 'share 2']);
expect(released, ['0', '1', '2']);
},
);
test(
'ack failure keeps active record and retries without presenting twice',
() async {
var failures = 1;
var shown = 0;
var consumed = false;
final inbox = ShareInbox(
consume: () async {
if (consumed) return null;
consumed = true;
return {'id': 'a', 'text': 'text'};
},
acknowledge: (_) async {
if (failures-- > 0) throw StateError('disk');
},
release: (_) async {},
onShare: (_) => shown++,
);
await inbox.check();
await expectLater(inbox.complete(), throwsStateError);
await inbox.check();
expect(shown, 1);
},
);
test(
'invalid record is discarded and next valid record is presented',
() async {
final records = [
{'id': 'bad'},
{'id': 'good', 'text': 'ok'},
];
final acknowledged = <String>[];
final shown = <String>[];
final inbox = ShareInbox(
consume: () async => records.isEmpty ? null : records.removeAt(0),
acknowledge: (id) async => acknowledged.add(id),
release: (_) async {},
onShare: (payload) => shown.add(payload.text!),
);
await inbox.check();
expect(acknowledged, ['bad']);
expect(shown, ['ok']);
await inbox.complete();
},
);
}