Add FCM push notifications, queue alerts, caller ID fixes, and auto-revert agent status
All checks were successful
Create Release / build (push) Successful in 6s
All checks were successful
Create Release / build (push) Successful in 6s
Server-side: - Add push credential auto-creation for FCM incoming call notifications - Add queue alert FCM notifications (data-only for background delivery) - Add queue alert cancellation on call accept/disconnect - Fix caller ID to show caller's number instead of Twilio number - Fix FCM token storage when refresh_token is null - Add pre_call_status tracking to revert agent status 30s after call ends - Add SSE fallback polling for mobile app connectivity Mobile app: - Add Android telecom permissions and phone account registration - Add VoiceFirebaseMessagingService for incoming call push handling - Add insistent queue alert notifications with custom sound - Fix caller number display on active call screen - Add caller ID selection dropdown on dashboard - Add phone numbers endpoint and provider support - Add unit tests for CallInfo, QueueState, and CallProvider - Remove local.properties from tracking, add .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
129
mobile/test/call_info_test.dart
Normal file
129
mobile/test/call_info_test.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:twp_softphone/models/call_info.dart';
|
||||
|
||||
void main() {
|
||||
group('CallInfo', () {
|
||||
test('default state is idle', () {
|
||||
const info = CallInfo();
|
||||
expect(info.state, CallState.idle);
|
||||
expect(info.callSid, isNull);
|
||||
expect(info.callerNumber, isNull);
|
||||
expect(info.duration, Duration.zero);
|
||||
expect(info.isMuted, false);
|
||||
expect(info.isSpeakerOn, false);
|
||||
expect(info.isOnHold, false);
|
||||
});
|
||||
|
||||
test('isActive returns true for ringing, connecting, connected', () {
|
||||
expect(const CallInfo(state: CallState.ringing).isActive, true);
|
||||
expect(const CallInfo(state: CallState.connecting).isActive, true);
|
||||
expect(const CallInfo(state: CallState.connected).isActive, true);
|
||||
});
|
||||
|
||||
test('isActive returns false for idle and disconnected', () {
|
||||
expect(const CallInfo(state: CallState.idle).isActive, false);
|
||||
expect(const CallInfo(state: CallState.disconnected).isActive, false);
|
||||
});
|
||||
|
||||
test('copyWith preserves unmodified fields', () {
|
||||
const original = CallInfo(
|
||||
state: CallState.connected,
|
||||
callSid: 'CA123',
|
||||
callerNumber: '+1234567890',
|
||||
isMuted: true,
|
||||
);
|
||||
|
||||
final modified = original.copyWith(isSpeakerOn: true);
|
||||
expect(modified.state, CallState.connected);
|
||||
expect(modified.callSid, 'CA123');
|
||||
expect(modified.callerNumber, '+1234567890');
|
||||
expect(modified.isMuted, true);
|
||||
expect(modified.isSpeakerOn, true);
|
||||
});
|
||||
|
||||
test('copyWith can change state', () {
|
||||
const info = CallInfo(state: CallState.connecting);
|
||||
final updated = info.copyWith(state: CallState.connected);
|
||||
expect(updated.state, CallState.connected);
|
||||
});
|
||||
|
||||
test('copyWith with callerNumber preserves it', () {
|
||||
const info = CallInfo(callerNumber: '+19095737372');
|
||||
final updated = info.copyWith(state: CallState.connected);
|
||||
expect(updated.callerNumber, '+19095737372');
|
||||
});
|
||||
|
||||
test('reset to idle clears all fields', () {
|
||||
// Verify a complex state exists
|
||||
const connected = CallInfo(
|
||||
state: CallState.connected,
|
||||
callSid: 'CA123',
|
||||
callerNumber: '+1234567890',
|
||||
isMuted: true,
|
||||
isSpeakerOn: true,
|
||||
isOnHold: true,
|
||||
duration: Duration(seconds: 30),
|
||||
);
|
||||
expect(connected.isActive, true);
|
||||
|
||||
// Simulating what callEnded does
|
||||
const reset = CallInfo();
|
||||
expect(reset.state, CallState.idle);
|
||||
expect(reset.callSid, isNull);
|
||||
expect(reset.callerNumber, isNull);
|
||||
expect(reset.isActive, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallState transitions', () {
|
||||
test('outbound call flow: idle -> connecting -> connected -> idle', () {
|
||||
var info = const CallInfo();
|
||||
expect(info.state, CallState.idle);
|
||||
|
||||
// makeCall sets connecting + callerNumber
|
||||
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||
expect(info.state, CallState.connecting);
|
||||
expect(info.callerNumber, '+19095737372');
|
||||
expect(info.isActive, true);
|
||||
|
||||
// SDK fires connected
|
||||
info = info.copyWith(state: CallState.connected);
|
||||
expect(info.state, CallState.connected);
|
||||
expect(info.callerNumber, '+19095737372'); // preserved
|
||||
expect(info.isActive, true);
|
||||
|
||||
// callEnded resets
|
||||
info = const CallInfo();
|
||||
expect(info.state, CallState.idle);
|
||||
expect(info.isActive, false);
|
||||
});
|
||||
|
||||
test('inbound call flow: idle -> ringing -> connected -> idle', () {
|
||||
var info = const CallInfo();
|
||||
|
||||
info = info.copyWith(state: CallState.ringing);
|
||||
expect(info.isActive, true);
|
||||
|
||||
// callerNumber set from active.from
|
||||
info = info.copyWith(callerNumber: '+18005551234');
|
||||
expect(info.callerNumber, '+18005551234');
|
||||
|
||||
info = info.copyWith(state: CallState.connected);
|
||||
expect(info.state, CallState.connected);
|
||||
|
||||
info = const CallInfo();
|
||||
expect(info.state, CallState.idle);
|
||||
});
|
||||
|
||||
test('outbound callerNumber not overwritten by null copyWith', () {
|
||||
var info = const CallInfo(
|
||||
state: CallState.connecting,
|
||||
callerNumber: '+19095737372',
|
||||
);
|
||||
|
||||
// copyWith without callerNumber should preserve it
|
||||
info = info.copyWith(state: CallState.connected);
|
||||
expect(info.callerNumber, '+19095737372');
|
||||
});
|
||||
});
|
||||
}
|
||||
367
mobile/test/call_provider_test.dart
Normal file
367
mobile/test/call_provider_test.dart
Normal file
@@ -0,0 +1,367 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:twilio_voice/twilio_voice.dart';
|
||||
import 'package:twp_softphone/models/call_info.dart';
|
||||
import 'package:twp_softphone/providers/call_provider.dart';
|
||||
import 'package:twp_softphone/services/voice_service.dart';
|
||||
|
||||
/// Minimal mock of VoiceService for testing CallProvider logic.
|
||||
/// Only stubs methods that CallProvider calls directly.
|
||||
class MockVoiceService implements VoiceService {
|
||||
final StreamController<CallEvent> _eventController =
|
||||
StreamController<CallEvent>.broadcast();
|
||||
bool makeCallResult = true;
|
||||
bool acceptQueueCallShouldThrow = false;
|
||||
String? lastCallTo;
|
||||
String? lastCallerId;
|
||||
bool answerCalled = false;
|
||||
bool hangUpCalled = false;
|
||||
|
||||
@override
|
||||
Stream<CallEvent> get callEvents => _eventController.stream;
|
||||
|
||||
void emitEvent(CallEvent event) => _eventController.add(event);
|
||||
|
||||
@override
|
||||
Future<bool> makeCall(String to, {String? callerId}) async {
|
||||
lastCallTo = to;
|
||||
lastCallerId = callerId;
|
||||
return makeCallResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> answer() async {
|
||||
answerCalled = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> hangUp() async {
|
||||
hangUpCalled = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reject() async {}
|
||||
|
||||
@override
|
||||
Future<void> toggleMute(bool mute) async {}
|
||||
|
||||
@override
|
||||
Future<void> toggleSpeaker(bool speaker) async {}
|
||||
|
||||
@override
|
||||
Future<void> sendDigits(String digits) async {}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> getQueueCalls(int queueId) async => [];
|
||||
|
||||
@override
|
||||
Future<void> acceptQueueCall(String callSid) async {
|
||||
if (acceptQueueCallShouldThrow) {
|
||||
throw Exception('Network error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> holdCall(String callSid) async {}
|
||||
|
||||
@override
|
||||
Future<void> unholdCall(String callSid) async {}
|
||||
|
||||
@override
|
||||
Future<void> transferCall(String callSid, String target) async {}
|
||||
|
||||
@override
|
||||
Future<void> initialize({String? deviceToken}) async {}
|
||||
|
||||
@override
|
||||
String? get identity => 'agent2testuser';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventController.close();
|
||||
}
|
||||
|
||||
// Unused stubs required by the interface
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('CallProvider.makeCall', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('sets state to connecting and passes number', () async {
|
||||
mockVoice.makeCallResult = true;
|
||||
await provider.makeCall('+19095737372');
|
||||
|
||||
expect(mockVoice.lastCallTo, '+19095737372');
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||
});
|
||||
|
||||
test('passes callerId when provided', () async {
|
||||
mockVoice.makeCallResult = true;
|
||||
await provider.makeCall('+19095737372', callerId: '+19516215107');
|
||||
|
||||
expect(mockVoice.lastCallerId, '+19516215107');
|
||||
});
|
||||
|
||||
test('resets to idle when call.place() returns false', () async {
|
||||
mockVoice.makeCallResult = false;
|
||||
await provider.makeCall('+19095737372');
|
||||
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
expect(provider.callInfo.callerNumber, isNull);
|
||||
});
|
||||
|
||||
test('stays connecting when call.place() returns true', () async {
|
||||
mockVoice.makeCallResult = true;
|
||||
await provider.makeCall('+19095737372');
|
||||
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider.hangUp', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('resets to idle even if SDK does not fire callEnded', () async {
|
||||
// Simulate a connecting state
|
||||
mockVoice.makeCallResult = true;
|
||||
await provider.makeCall('+19095737372');
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
|
||||
// Hang up without SDK firing callEnded
|
||||
await provider.hangUp();
|
||||
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
expect(provider.callInfo.callerNumber, isNull);
|
||||
expect(mockVoice.hangUpCalled, true);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider state transitions', () {
|
||||
test('outbound: connecting preserves callerNumber through state changes', () {
|
||||
// Simulating what CallProvider does internally
|
||||
var info = const CallInfo();
|
||||
|
||||
// makeCall sets connecting + callerNumber
|
||||
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||
expect(info.state, CallState.connecting);
|
||||
expect(info.callerNumber, '+19095737372');
|
||||
|
||||
// SDK fires connected — callerNumber preserved
|
||||
info = info.copyWith(state: CallState.connected);
|
||||
expect(info.state, CallState.connected);
|
||||
expect(info.callerNumber, '+19095737372');
|
||||
});
|
||||
|
||||
test('makeCall failure resets cleanly to idle', () {
|
||||
var info = const CallInfo();
|
||||
|
||||
// makeCall sets connecting
|
||||
info = info.copyWith(state: CallState.connecting, callerNumber: '+19095737372');
|
||||
expect(info.state, CallState.connecting);
|
||||
|
||||
// call.place() returns false -> reset
|
||||
info = const CallInfo();
|
||||
expect(info.state, CallState.idle);
|
||||
expect(info.callerNumber, isNull);
|
||||
expect(info.isActive, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider.acceptQueueCall', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('sets state to connecting before server call', () async {
|
||||
await provider.acceptQueueCall('CA123abc');
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
});
|
||||
|
||||
test('auto-answers incoming call after acceptQueueCall', () async {
|
||||
await provider.acceptQueueCall('CA123abc');
|
||||
|
||||
// Simulate the FCM incoming call event that arrives after server redirect
|
||||
mockVoice.emitEvent(CallEvent.incoming);
|
||||
|
||||
// Allow the stream listener to process
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
// Should have auto-answered
|
||||
expect(mockVoice.answerCalled, true);
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
});
|
||||
|
||||
test('normal incoming call shows ringing without auto-answer', () async {
|
||||
// Without calling acceptQueueCall first
|
||||
mockVoice.emitEvent(CallEvent.incoming);
|
||||
|
||||
await Future.delayed(Duration.zero);
|
||||
|
||||
expect(mockVoice.answerCalled, false);
|
||||
expect(provider.callInfo.state, CallState.ringing);
|
||||
});
|
||||
|
||||
test('connected event after auto-answer sets connected state', () async {
|
||||
await provider.acceptQueueCall('CA123abc');
|
||||
|
||||
mockVoice.emitEvent(CallEvent.incoming);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(mockVoice.answerCalled, true);
|
||||
|
||||
mockVoice.emitEvent(CallEvent.connected);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(provider.callInfo.state, CallState.connected);
|
||||
});
|
||||
|
||||
test('resets to idle on API error and clears pendingAutoAnswer', () async {
|
||||
mockVoice.acceptQueueCallShouldThrow = true;
|
||||
await provider.acceptQueueCall('CA123abc');
|
||||
|
||||
// Should have reset to idle after error
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
|
||||
// Future incoming call should NOT be auto-answered
|
||||
mockVoice.emitEvent(CallEvent.incoming);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(mockVoice.answerCalled, false);
|
||||
expect(provider.callInfo.state, CallState.ringing);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider.hangUp edge cases', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('hangUp when already idle is a no-op', () async {
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
await provider.hangUp();
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
expect(mockVoice.hangUpCalled, true);
|
||||
});
|
||||
|
||||
test('hangUp clears pendingAutoAnswer flag', () async {
|
||||
await provider.acceptQueueCall('CA123abc');
|
||||
expect(provider.callInfo.state, CallState.connecting);
|
||||
|
||||
await provider.hangUp();
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
|
||||
// Incoming call should NOT auto-answer after hangUp cleared the flag
|
||||
mockVoice.emitEvent(CallEvent.incoming);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(mockVoice.answerCalled, false);
|
||||
expect(provider.callInfo.state, CallState.ringing);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider.toggleMute and toggleSpeaker', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('toggleMute flips isMuted state', () async {
|
||||
expect(provider.callInfo.isMuted, false);
|
||||
await provider.toggleMute();
|
||||
expect(provider.callInfo.isMuted, true);
|
||||
await provider.toggleMute();
|
||||
expect(provider.callInfo.isMuted, false);
|
||||
});
|
||||
|
||||
test('toggleSpeaker flips isSpeakerOn state', () async {
|
||||
expect(provider.callInfo.isSpeakerOn, false);
|
||||
await provider.toggleSpeaker();
|
||||
expect(provider.callInfo.isSpeakerOn, true);
|
||||
await provider.toggleSpeaker();
|
||||
expect(provider.callInfo.isSpeakerOn, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('CallProvider.callEnded', () {
|
||||
late MockVoiceService mockVoice;
|
||||
late CallProvider provider;
|
||||
|
||||
setUp(() {
|
||||
mockVoice = MockVoiceService();
|
||||
provider = CallProvider(mockVoice);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
provider.dispose();
|
||||
mockVoice.dispose();
|
||||
});
|
||||
|
||||
test('callEnded resets state completely', () async {
|
||||
// Set up a connected call
|
||||
mockVoice.makeCallResult = true;
|
||||
await provider.makeCall('+19095737372');
|
||||
|
||||
mockVoice.emitEvent(CallEvent.connected);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(provider.callInfo.state, CallState.connected);
|
||||
expect(provider.callInfo.callerNumber, '+19095737372');
|
||||
|
||||
// End the call
|
||||
mockVoice.emitEvent(CallEvent.callEnded);
|
||||
await Future.delayed(Duration.zero);
|
||||
expect(provider.callInfo.state, CallState.idle);
|
||||
expect(provider.callInfo.callerNumber, isNull);
|
||||
expect(provider.callInfo.callSid, isNull);
|
||||
expect(provider.callInfo.isActive, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
92
mobile/test/queue_state_test.dart
Normal file
92
mobile/test/queue_state_test.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:twp_softphone/models/queue_state.dart';
|
||||
|
||||
void main() {
|
||||
group('QueueInfo', () {
|
||||
test('parses from JSON with all fields', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'name': 'General',
|
||||
'type': 'general',
|
||||
'extension': '100',
|
||||
'waiting_count': 3,
|
||||
};
|
||||
|
||||
final queue = QueueInfo.fromJson(json);
|
||||
expect(queue.id, 1);
|
||||
expect(queue.name, 'General');
|
||||
expect(queue.type, 'general');
|
||||
expect(queue.extension, '100');
|
||||
expect(queue.waitingCount, 3);
|
||||
});
|
||||
|
||||
test('parses from JSON with string numbers', () {
|
||||
final json = {
|
||||
'id': '5',
|
||||
'name': 'Support',
|
||||
'type': 'personal',
|
||||
'waiting_count': '0',
|
||||
};
|
||||
|
||||
final queue = QueueInfo.fromJson(json);
|
||||
expect(queue.id, 5);
|
||||
expect(queue.waitingCount, 0);
|
||||
expect(queue.extension, isNull);
|
||||
});
|
||||
|
||||
test('handles missing fields gracefully', () {
|
||||
final json = <String, dynamic>{};
|
||||
final queue = QueueInfo.fromJson(json);
|
||||
expect(queue.id, 0);
|
||||
expect(queue.name, '');
|
||||
expect(queue.type, '');
|
||||
expect(queue.extension, isNull);
|
||||
expect(queue.waitingCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('QueueCall', () {
|
||||
test('parses from JSON', () {
|
||||
final json = {
|
||||
'call_sid': 'CA123abc',
|
||||
'from_number': '+18005551234',
|
||||
'to_number': '+19095737372',
|
||||
'position': 1,
|
||||
'status': 'waiting',
|
||||
'wait_time': 45,
|
||||
};
|
||||
|
||||
final call = QueueCall.fromJson(json);
|
||||
expect(call.callSid, 'CA123abc');
|
||||
expect(call.fromNumber, '+18005551234');
|
||||
expect(call.toNumber, '+19095737372');
|
||||
expect(call.position, 1);
|
||||
expect(call.status, 'waiting');
|
||||
expect(call.waitTime, 45);
|
||||
});
|
||||
|
||||
test('handles string wait_time', () {
|
||||
final json = {
|
||||
'call_sid': 'CA456',
|
||||
'from_number': '+1800',
|
||||
'to_number': '+1900',
|
||||
'position': '2',
|
||||
'status': 'waiting',
|
||||
'wait_time': '120',
|
||||
};
|
||||
|
||||
final call = QueueCall.fromJson(json);
|
||||
expect(call.position, 2);
|
||||
expect(call.waitTime, 120);
|
||||
});
|
||||
|
||||
test('handles missing fields gracefully', () {
|
||||
final json = <String, dynamic>{};
|
||||
final call = QueueCall.fromJson(json);
|
||||
expect(call.callSid, '');
|
||||
expect(call.fromNumber, '');
|
||||
expect(call.position, 0);
|
||||
expect(call.waitTime, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user