В этой статье я бы хотел рассказать о том, как в минимум усилий написать своё простое VoIP-приложение с бэкэндом и работой в фоне на платформе Windows Phone 8.
До выхода Windows Phone 8 пользователей voip-приложений очень разочаровывала работа в фоне, которая, собственно, практически отсутствовала — максимум из того, что могли сделать разработчики, чтобы показать пользователю входящий звонок пока приложение в бэкграунде — это показать toast notification, который слабозаметен, еле слышен и быстро исчезает. С одной стороны, это не позволяло поедать батарейку как если бы приложение работало полноценно в фоне, но с другой — делало его малополезным инструментом. До выхода WP8, Microsoft подогревала интерес публики к новой версии платформы обещаниями интегрировать Skype в операционную систему и работу в фоне. Что ж, обещания они свои выполнили — теперь стало возможно:
Microsoft не стало делать это эксклюзивными возможностями (кроме интеграции в контактную книгу) для своего продукта и открыло API, что дает возможность сторонним разработчикам реализовывать такие же сценарии, не будучи при этом привилегированным партнером (как было в WP7 с native sdk). И хотя так же красиво интегрироваться в контактную книгу не получится — можно воспользоваться ContactStore и Protocol handlers, чтобы изменить в контакте поле URL и сделать открытие приложение по клику).
В конце статьи приложены исходники двух проектов: один из них пример Microsoft Chatterbox, в котором объясняется, как работают бэкграунд процессы с симуляцией бэк-энда с входящими звонками и даже с видео; второй — мой проект с простым бэкэндом, который позволяет общаться по voip на двух устройствах и использует voip push notifications, но обо всем по порядку.
Если вы задались целью написать полноценное voip приложение, то к сожалению (или к счастью) вам не обойтись без native компонента на C++ (потому как нормальное API для работы с аудио-девайсами не доступно из managed части) Если кратко, то voip — приложение, которое умеет работать в фоне, должно состоять из двух процессов:
Графически это выглядит так:
Начнем по порядку:
Сперва разберемся с уровнем транспорта наших данных. Конечно, это очень простой пример, который я соорудил за день, поэтому никаких мегаумных штук тут не будет — вы сами понимаете: мало написать транспорт, запись и воспроизведение аудио — надо ещё чтобы это работало быстро без задержек даже на слабых каналах связи — но это тема ни одной книги. И так, для транспорта мы будем использовать очень удобный класс из нового API — DatagramSocket (он прост и работает по UDP что логичнее аудио\видео потоками (нам же не надо ожидать подтверждения доставки каждого аудио пакета, правда?). Благодаря async\await работа с ним очень проста:
я настолько привык к async\await, что воспользовался этим же классом и для серверной части (о том, как использовать WinRT API в десктопном дотнете смотрите тут). Проткол тоже очень прост: COMMAND!BODY – для нашего примера хватит.
В Managed части для записи данных с микрофона есть два класса:
В нашем примере мы будем использовать первый (он доступен ещё с WP7) так как лично я не смог разобраться в том, как проиграть аудио со второго без использования native api), но, понятное дело, для реализации серьезного voip приложения вам придется использовать второй способ (StartRecordingToSinkAsync, который отдает чистый несжатый поток данных с микрофона). И так, запись данных с микрофона организована всего парой строк:
В нашем примере мы будем использовать очень неоптимальный, но рабочий и небольшой код:
К сожалению, альтернатив нет и через managed часть нет возможности проигрывать аудио на динамике для звонков, а только на спикере, поэтому возможно появление эхо и прочих шумов (это же простой пример).
Киллер-фичей нашего примера будет является то, что если вы установите это приложение на два девайса — вы сможете звонить через приложение на другой девайс без необходимости нахождения в foreground приложения на том девайсе. Сперва необходимо зарегистрировать Push URI для обоих девайсов на сервере вместе с каким-нибудь идентификатором пользователя (в Skype это произвольное имя, в Viber — номер телефона пользователя). Затем, когда девайс А захочет позвонить девайсу Б — он отправит на сервере команду, сервер найдет push uri для девайса Б и отправит на MPNS xml c некоторыми данными о звонящем с обязательным условием наличия в хедере запроса X-NotificationClass=4. До выхода WP8 было всего три класса Push notifications
но как видите, с WP8 добавился новый четвертый класс — VoIP. MPNS по своим каналам отправляет этот пакет на клиента и поднимает специально запущенный для этих целей ScheduledTaskAgent. Если этот агент правильно отработает — пользователю отобразится экран входящего звонка (аналогичный обычному GSM-звонку). И так, что же должен сделать ScheduledTaskAgent?
Стоит заметить, что VoIP-пуши в отличие от всех остальных видов, могут прилетать как в открытое приложение, так и если оно закрыто — Skype принимает входящие звонки только через пуши даже если он в данный момент в foreground — на самом деле спорное решение, т.к. voip пуши иногда тормозят. Увы, в нашем примере мы не сможем поднять разговор, если voip пуш прилетит когда приложение запущено — у нас в нашем примере нет нативного межпроцессного компонента, чтобы сообщить основному процессу об этом (и да, OnNavigatedTo,From не сработают при появлении UI входящего звонка, хотя возможно будет вызов события Obscured у фрейма, но мы не сможем достать номер звонящего) — поэтому в моем примере при звонке принимающая сторона должна выйти из приложения, чтобы корректно подхватить разговор.
Всего это хватило, чтобы написать простое VoIP-приложение за день. Увы, оно умеет разговаривать только через спикер, не умеет выключать экран при поднесении к уху (proximity sensor) и продолжать разговор, если приложение сворачивается — для всего этого необходим native компонент, который очень подробно описан в примере Microsoft Chatterbox — мой же пример попроще, но зато с серверной частью. Изначально я хотел рассказать только о VoIP-пушах, но получилось немного больше. Конечно, для реализации полноценных VoIP-приложений лучше смотреть в сторону быстро развивающегося WebRTC, который, к слову, уже официально работает в хроме в Android, но, надеюсь, мой пример окажется кому-нибудь полезным.
Исходники:
До выхода Windows Phone 8 пользователей voip-приложений очень разочаровывала работа в фоне, которая, собственно, практически отсутствовала — максимум из того, что могли сделать разработчики, чтобы показать пользователю входящий звонок пока приложение в бэкграунде — это показать toast notification, который слабозаметен, еле слышен и быстро исчезает. С одной стороны, это не позволяло поедать батарейку как если бы приложение работало полноценно в фоне, но с другой — делало его малополезным инструментом. До выхода WP8, Microsoft подогревала интерес публики к новой версии платформы обещаниями интегрировать Skype в операционную систему и работу в фоне. Что ж, обещания они свои выполнили — теперь стало возможно:
- инициировать звонок на Skype через контактную книгу телефона
- продолжать разговор по Skype даже если вы целенаправленно или случайно свернете приложение (раньше если при разговоре вы случайно заденете кнопку поиска — разговор обрывался)
- и самое интересное: принимать входящие звонки с интерфейсом а-ля обычный gsm-звонок в условиях когда Skype не запущен (не в foreground) и более того — он в фоне ничего не делает (не поедает батарейку)
Microsoft не стало делать это эксклюзивными возможностями (кроме интеграции в контактную книгу) для своего продукта и открыло API, что дает возможность сторонним разработчикам реализовывать такие же сценарии, не будучи при этом привилегированным партнером (как было в WP7 с native sdk). И хотя так же красиво интегрироваться в контактную книгу не получится — можно воспользоваться ContactStore и Protocol handlers, чтобы изменить в контакте поле URL и сделать открытие приложение по клику).
В конце статьи приложены исходники двух проектов: один из них пример Microsoft Chatterbox, в котором объясняется, как работают бэкграунд процессы с симуляцией бэк-энда с входящими звонками и даже с видео; второй — мой проект с простым бэкэндом, который позволяет общаться по voip на двух устройствах и использует voip push notifications, но обо всем по порядку.
Архитектура VoIP приложения с работой в фоне
Если вы задались целью написать полноценное voip приложение, то к сожалению (или к счастью) вам не обойтись без native компонента на C++ (потому как нормальное API для работы с аудио-девайсами не доступно из managed части) Если кратко, то voip — приложение, которое умеет работать в фоне, должно состоять из двух процессов:
- Foreground — собственно обычный процесс, в котором «бежит» интерфейс приложения
- Background — второй процесс, который, по сути, состоит из четырех агентов:
- VoipHttpIncomingCallTask — запускается когда к нам приходит входящий звонок по пуш каналу (особый вид push уведомлений, будет описан ниже).
- VoipForegroundLifetimeAgent – запускается когда наше приложение становится активным и работает до тех пор, пока приложение не свернули или закрыли.
- VoipCallInProgressAgent – Запускается при звонке сигнализируя о том, что процессу выделено больше ресурсов процессора для поддержки звонка. Таким образом (де)кодирование видео и аудио надо начинать после этого события.
- VoipKeepAliveTask – запускается периодически каждые 6 часов. По сути, он нужен для того, чтобы периодически напоминать вашему серверу, что приложение всё ещё установлено на телефоне
- Out-of-process — межпроцессный компонент, призванный решить проблему коммуникации между первыми двумя. На самом деле это всё тот же второй процесс.
Графически это выглядит так:
Как же написать своё VoIP приложение?
Начнем по порядку:
1. Транспорт
Сперва разберемся с уровнем транспорта наших данных. Конечно, это очень простой пример, который я соорудил за день, поэтому никаких мегаумных штук тут не будет — вы сами понимаете: мало написать транспорт, запись и воспроизведение аудио — надо ещё чтобы это работало быстро без задержек даже на слабых каналах связи — но это тема ни одной книги. И так, для транспорта мы будем использовать очень удобный класс из нового API — DatagramSocket (он прост и работает по UDP что логичнее аудио\видео потоками (нам же не надо ожидать подтверждения доставки каждого аудио пакета, правда?). Благодаря async\await работа с ним очень проста:
const string host = "192.168.1.12";
const string port = "12398";
var socket = new DatagramSocket();
socket.MessageReceived += (s, e) =>
{
//читаем входящие данные в строку
var reader = e.GetDataReader();
string message = reader.ReadString(reader.UnconsumedBufferLength);
};
await socket.BindServiceNameAsync(host);
var stream = await socket.GetOutputStreamAsync(new HostName(host), port);
var dataWriter = new DataWriter(stream);
//отправляем строку на удаленный хост
dataWriter.WriteString("Hello!");
await dataWriter.StoreAsync(); //передает данные в системный буфер ОС для отправки
я настолько привык к async\await, что воспользовался этим же классом и для серверной части (о том, как использовать WinRT API в десктопном дотнете смотрите тут). Проткол тоже очень прост: COMMAND!BODY – для нашего примера хватит.
2. Запись голоса
В Managed части для записи данных с микрофона есть два класса:
- XNA Microphone
- AudioVideoCaptureDevice
В нашем примере мы будем использовать первый (он доступен ещё с WP7) так как лично я не смог разобраться в том, как проиграть аудио со второго без использования native api), но, понятное дело, для реализации серьезного voip приложения вам придется использовать второй способ (StartRecordingToSinkAsync, который отдает чистый несжатый поток данных с микрофона). И так, запись данных с микрофона организована всего парой строк:
_microphone = Microphone.Default;
_microphone.BufferDuration = TimeSpan.FromMilliseconds(500);
_microphoneBuffer = new byte[_microphone.GetSampleSizeInBytes(_microphone.BufferDuration)];
_microphone.BufferReady += (s, e) =>
{
_microphone.GetData(_microphoneBuffer);
//отправка байтов на сервер
};
_microphone.Start();
3. Проигрывание аудио
В нашем примере мы будем использовать очень неоптимальный, но рабочий и небольшой код:
_soundEffect = new SoundEffect(e.Data, _microphone.SampleRate, AudioChannels.Mono);
_soundEffect.Play();
К сожалению, альтернатив нет и через managed часть нет возможности проигрывать аудио на динамике для звонков, а только на спикере, поэтому возможно появление эхо и прочих шумов (это же простой пример).
4. VoIP push уведомления
Киллер-фичей нашего примера будет является то, что если вы установите это приложение на два девайса — вы сможете звонить через приложение на другой девайс без необходимости нахождения в foreground приложения на том девайсе. Сперва необходимо зарегистрировать Push URI для обоих девайсов на сервере вместе с каким-нибудь идентификатором пользователя (в Skype это произвольное имя, в Viber — номер телефона пользователя). Затем, когда девайс А захочет позвонить девайсу Б — он отправит на сервере команду, сервер найдет push uri для девайса Б и отправит на MPNS xml c некоторыми данными о звонящем с обязательным условием наличия в хедере запроса X-NotificationClass=4. До выхода WP8 было всего три класса Push notifications
- Tile
- Raw
- Toast
но как видите, с WP8 добавился новый четвертый класс — VoIP. MPNS по своим каналам отправляет этот пакет на клиента и поднимает специально запущенный для этих целей ScheduledTaskAgent. Если этот агент правильно отработает — пользователю отобразится экран входящего звонка (аналогичный обычному GSM-звонку). И так, что же должен сделать ScheduledTaskAgent?
var incomingCallTask = task as VoipHttpIncomingCallTask;
if (incomingCallTask != null)
{
//десериализуем XML с номером и именем звонящего
Notification pushNotification;
using (var ms = new MemoryStream(incomingCallTask.MessageBody))
{
var xs = new XmlSerializer(typeof(Notification));
pushNotification = (Notification)xs.Deserialize(ms);
}
VoipPhoneCall callObj;
var callCoordinator = VoipCallCoordinator.GetDefault();
//запрос на отображения gsm-call-like интерфейса
callCoordinator.RequestNewIncomingCall("/MainPage.xaml?incomingCall=" + pushNotification.Number,
pushNotification.Name, pushNotification.Number, new Uri(defaultContactImageUri), "Voip.Client.Phone",
new Uri(appLogoUri), "Я VoIP-push!", new Uri(logoUrl), VoipCallMedia.Audio,
TimeSpan.FromMinutes(5), out callObj);
callObj.AnswerRequested += (s, e) =>
{
s.NotifyCallActive(); //запустит наше приложение
//далее небольшой воркэрунд для примера:
//как я писал выше, у нас нет возможности проигрывать
//аудио на внутреннем динамике используя managed code, а NotifyCallActive включает
//именно его без возможности проигрывать звуки на внешнем,
//так что таким способом мы отключаем внутренний, и включаем внешний
await Task.Delay(3000);
s.NotifyCallEnded();
};
callObj.RejectRequested += (s, e) => s.NotifyCallEnded();
}
Стоит заметить, что VoIP-пуши в отличие от всех остальных видов, могут прилетать как в открытое приложение, так и если оно закрыто — Skype принимает входящие звонки только через пуши даже если он в данный момент в foreground — на самом деле спорное решение, т.к. voip пуши иногда тормозят. Увы, в нашем примере мы не сможем поднять разговор, если voip пуш прилетит когда приложение запущено — у нас в нашем примере нет нативного межпроцессного компонента, чтобы сообщить основному процессу об этом (и да, OnNavigatedTo,From не сработают при появлении UI входящего звонка, хотя возможно будет вызов события Obscured у фрейма, но мы не сможем достать номер звонящего) — поэтому в моем примере при звонке принимающая сторона должна выйти из приложения, чтобы корректно подхватить разговор.
Заключение
Всего это хватило, чтобы написать простое VoIP-приложение за день. Увы, оно умеет разговаривать только через спикер, не умеет выключать экран при поднесении к уху (proximity sensor) и продолжать разговор, если приложение сворачивается — для всего этого необходим native компонент, который очень подробно описан в примере Microsoft Chatterbox — мой же пример попроще, но зато с серверной частью. Изначально я хотел рассказать только о VoIP-пушах, но получилось немного больше. Конечно, для реализации полноценных VoIP-приложений лучше смотреть в сторону быстро развивающегося WebRTC, который, к слову, уже официально работает в хроме в Android, но, надеюсь, мой пример окажется кому-нибудь полезным.
Исходники: