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

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:
Claude
2026-03-07 17:11:02 -08:00
parent 78e6c5a4ee
commit 4af4be94a4
26 changed files with 1829 additions and 189 deletions

View 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');
});
});
}

View 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);
});
});
}

View 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);
});
});
}