В современной матрице IT-технологий квалифицированный администратор — персона хоть и не всегда видимая, зато важная и весьма занятая. Отбиваясь от назойливых пользователей, он постоянно должен присматривать за ареалом своего цифрового мира — сайтами. Давай же поможем нашему админу и создадим полезный виджет, внимательно следящий за доступностью всех его ресурсов. И да, погоду этот виджет показывать не будет.

 

Проектируем виджет

Цель нашего проекта — создать виджет, который периодически пингует указанные пользователем сайты и выводит соответствующую информацию на домашний экран. Традиционно будем использовать редактор кода Eclipse с плагином ADT.

В андроиде любой виджет представляет собой визуальный компонент, работающий в рамках того приложения, в которое он встраивается (как правило, это домашний экран). Кроме того, виджет умеет выводить устройство из режима ожидания, чтобы отобразить на экране актуальную информацию. Поэтому при разработке виджета нужно свести к минимуму время его обновления (можно, конечно, и пренебречь, но ANR в виджете — это уже моветон). Вполне логично напрашивается некоторый фоновый сервис, который будет пинговать сайты и записывать результат для дальнейшего анализа. Таким образом, задача виджета сведется к извлечению этих данных и выводу информации в виде текстовой строки — ссылки на сайт и некоторой графики — доступности сайта (рис. 1). Также нам потребуется простенькая форма для ввода списка подконтрольных сайтов, то есть GUI. Кстати, настоятельно рекомендую ознакомиться со статьей в мартовском номере «Хакера» «Хакерский Cron на Android», поскольку там подробно рассмотрена работа фонового сервиса.

Рис. 1. Хакерский виджет
Рис. 1. Хакерский виджет
 

Ping? Нет, не знаю...

В Java есть замечательный класс InetAddress, имеющий в своем чреве не менее замечательный метод isReachable(). Этот метод проверяет доступность ресурса и принимает в качестве параметра тайм-аут, то есть время, по истечении которого не отвечающий ресурс считается недоступным:

if (InetAddress.getByName("www.xakep.ru").isReachable(5000))... // Сайт доступен

Лаконично, не правда ли? Вся проблема в том, что этот код прекрасно работает в Windows, но в Android’е всегда возвращает false, даже если приложению дать разрешение на доступ в интернет. По непонятной причине для отправки ICMP-пакета (Echo-Request) требуется рут.

Мы же ничего требовать не будем и поступим следующим образом: будем подключаться к сайту по протоколу HTTP и смотреть на код ответа. Если получим код 200 (HTTP OK), значит, сайт жив и работает, в противном случае считаем, что что-то не так (сайт недоступен).

 

Разрешения

Так как наша цель — сайты в интернете, необходимо получить соответствующие разрешения у пользователя в манифесте проекта (AndroidManifest.xml):

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>

Первая строка запрашивает разрешение на доступ в интернет, тогда как вторая позволяет отслеживать состояние подключения к сети (в терминологии Play Market — «просмотр состояния сети») — если сеть недоступна, пинговать что-либо особого смысла нет. Функция проверки подключения к сети выглядит следующим образом:

public boolean isConnected() {
    ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
    NetworkInfo ni = cm.getActiveNetworkInfo();
    if (ni != null && ni.isConnected()) {
        return true;
    }
    return false;
}

Для доступа к сервису управления сетевыми подключениями используется константа Context.CONNECTIVITY_SERVICE, после чего метод getActiveNetworkInfo возвращает объект типа NetworkInfo, который содержит информацию о текущем соединении. Если подключение установлено, метод isConnected вернет true.

 

SQLite? No... Shared Preferences!

Как ты сам понимаешь, нам необходимо где-то хранить список сайтов и их (не)доступность. Сначала я хотел вновь использовать базу данных SQLite, но решил не повторяться и рассказать о другой полезной технологии Android — общих настройках (Shared Preferences). Общие настройки — довольно простой механизм, основанный на парах «ключ — значение» и предназначенный для сохранения примитивных данных приложения (число, строка, булево значение). С точки зрения Android настройки хранятся в виде обычного XML-файла внутри приватной директории приложения (data/data/имя_пакета/shared_pref/).

Для наших целей напишем небольшой класс (PingPref) для сохранения и чтения данных:

public class PingPref {

    private static final String PREF = "pref";
    private static final String pre_ = "site";
    private static final String _url = "_url";
    private static final String _status = "_status";

    private SharedPreferences mSettings;

    PingPref(Context context) {
        mSettings = context.getSharedPreferences(PREF, Context.MODE_PRIVATE);
    }

    public void setData(int num, String url, int status ){
        Editor editor = mSettings.edit();
        // Формируем строку вида siteN, где N — порядковый номер сайта
        String key = pre_ + String.valueOf(num);
        editor.putString(key + _url, url);    // Ключ siteN_url
        editor.putInt(key + _status, status); // Ключ siteN_status
        editor.commit();
    }

    public String[] getData(int num) {
        String key = pre_ + String.valueOf(num);
        String url = mSettings.getString(key + _url, "");
        int status = mSettings.getInt(key + _status, -1);
        return new String[] {url, String.valueOf(status)};
    }
}

В конструкторе класса нужно вызвать метод getSharedPreferences в контексте приложения, указав имя файла общих настроек и режим доступа. Константа Context.MODE_PRIVATE указывает на то, что файл настроек будет доступен только внутри приложения (Google настоятельно рекомендует использовать только это значение). Каждый сайт будем хранить в двух ключах: siteN_url (ссылка) и siteN_status (доступность). В качестве последней используем число: 1 — сайт жив, 0 — сайт ушел (читай: «его ушли»), –1 — статус не определен (например, в случае отсутствия доступа к сети). Сеттеры (put.String, put.Int) и геттеры со значениями по умолчанию (get.String, get.Int) в пояснениях не нуждаются. Содержимое файла pref.xml в работе представлено на рис. 2.

Рис. 2. Pref.xml трудится
Рис. 2. Pref.xml трудится
 

GUI

Здесь все просто — несколько полей ввода (EditText) да кнопка (Button). Вся эта красота представлена на рис. 3. Обработчик кнопки (см. Main.java) считывает содержимое полей (применяется коллекция ArrayList) и записывает их в файл общих настроек, используя метод setData описанного выше класса PingPref:

ArrayList<String> sites = new ArrayList<String>(4);
pf = new PingPref(this);
...
public void bPing_click(View v){
    fillSites();
    for (int i = 0; i < sites.size(); i++) pf.setData(i+1, sites.get(i), -1);
    startservice();
}

public void fillSites(){
    sites.clear();
    sites.add(ed1.getText().toString());
    ...
}

public void startservice(){
    Intent i = new Intent(this, PingService.class);
    this.startService(i);
}

В качестве первоначального статуса доступности сайта, как и условились, устанавливаем –1. В заключение происходит запуск фонового сервиса startService, подробнее о котором поговорим далее.

Рис. 3. GUI такой GUI
Рис. 3. GUI такой GUI
 

Фоновый сервис

Начиная с Android 3.0 (версия API 11), любой функционал, связанный с сетевой активностью, должен обязательно выполняться во вторичном потоке. Любая попытка подобной работы в главном (графическом) потоке приложения приведет к выбросу исключения NetworkOnMainThreadException и немедленному завершению программы. Это правило относится и к фоновому сервису, так как он тоже фактически выполняется в главном потоке. Как ты уже, наверное, догадался (а если нет — срочно покупай мартовский «Хакер»), мы будем использовать IntentService. Данный фоновый сервис берет на себя всю работу по созданию вторичного потока, позволяя нам сосредоточиться непосредственно на функционале.

Для начала зарегистрируем сервис в манифесте:

<service android:name=".PingService" >
</service>

Основная работа сервиса кипит и бурлит внутри onHandleIntent, текст которого приведен ниже (отладочная печать присутствует):

private ArrayList<String> sites = new ArrayList<String>(4);
private PingPref pf = new PingPref(this);
private AlarmManager am = (AlarmManager)getSystemService(Context.ALARM_SERVICE);
...
@Override
protected void onHandleIntent(Intent intent) {
    Log.d(TAG_LOG, "Сеанс PingService работает...");
    loadSites(); // Чтение списка сайтов
    boolean isConnected = isConnected(); // Есть соединение?
    if (!isConnected) {
      setSitesFail(); // Ставим для всех сайтов флаг -1
      Log.d(TAG_LOG, "Соединение отсутствует!");
    } else
    for (int i = 0; i < sites.size(); i++){
      String site = sites.get(i);
      if (!site.equalsIgnoreCase("")) {
        // Сайт доступен? Да — flag=1, иначе flag=0
        int flag = isSiteAvail(site)? 1 : 0;
        pf.setData(i+1, site, flag);
        Log.d(Main.TAG_LOG, site + ": flag=" + flag);
      }
    }
    refreshWidget(); // Обновляем виджет
    // Создаем отложенное намерение
    Intent si = new Intent(this, PingService.class);
    PendingIntent pi = PendingIntent.getService(this, 0, si, PendingIntent.FLAG_UPDATE_CURRENT);
    am.cancel(pi); // Сбрасываем предыдущую сигнализацию
    // Связь есть? Да — повтор пинга через 15 мин, иначе — проверка связи через 30 мин
    long updateFreq = isConnected ? AlarmManager.INTERVAL_FIFTEEN_MINUTES : AlarmManager.INTERVAL_HALF_HOUR;
    // Определяем время следующего запуска сервиса = текущее + интервал
    long timeToRefresh = SystemClock.elapsedRealtime() + updateFreq;
    // Устанавливаем сигнализацию
    am.setInexactRepeating(AlarmManager.ELAPSED_REALTIME, timeToRefresh, updateFreq, pi);
    Log.d(Main.TAG_LOG, "Следующий сеанс примерно через " + updateFreq/60/1000 + " мин.");
    Log.d(Main.TAG_LOG, "Сеанс PingService завершен");
}

Функция loadSites заполняет коллекцию ArrayList адресами сайтов, сохраненных ранее в общих настройках. Далее происходит проверка на соединение с сетью: если isConnected возвращает false — для всех сайтов устанавливаем флаг -1 (setSitesFail), в противном случае запускаем цикл опроса всех ресурсов (isSiteAvail) с записью результатов (setData). Для активной работы со всемирной паутиной по протоколу HTTP в Java предусмотрен специальный класс HttpURLConnection, использующий объект-ссылку (URL) для указания адреса сайта:

private boolean isSiteAvail(String site){
    try {
        URL url = new URL(site);
        HttpURLConnection urlc = (HttpURLConnection) url.openConnection();
         urlc.setRequestProperty("Connection", "close");
         urlc.setConnectTimeout(5000);
         urlc.connect();
        int Code = urlc.getResponseCode();
        if (Code == HttpURLConnection.HTTP_OK) return true;
    }
    catch (MalformedURLException e) {}
    catch (IOException e) {}
    return false;
}

Так как мы не собираемся в дальнейшем запрашивать каких-либо ресурсов с сайта, в заголовке запроса смело указываем лексему соединения setRequestProperty("Connection", "close"), то есть после ответа сервер разорвет связь. Метод setConnectTimeout устанавливает тайм-аут соединения и подбирается экспериментально (в моем случае пять секунд при соединении 3G вполне хватило). Возвращаемое методом getResponseCode значение HttpURLConnection.HTTP_OK определяет положительный вердикт функции. Имей в виду: при использовании мобильного доступа к интернету шанс словить IOException и MalformedURLException весьма высок. Это происходит потому, что метод isConnected объекта NetworkInfo не всегда оперативно реагирует на изменение состояния сети, и мы можем прийти в isSiteAvail с отсутствующим соединением. Так что, если внезапно все сайты окажутся недоступными, паниковать, конечно, следует, но не сразу.

 

INFO

Объект HttpURLConnection обрабатывает только те ссылки, которые начинаются с http://, то есть протокол нужно указывать явным образом.

Функция refreshWidget инициирует обновление виджета посредством трансляции (передачи) уникального широковещательного намерения FORCE_WIDGET_UPDATE, которое наш виджет будет отлавливать, так как он является широковещательным приемником. Термин «широковещательность» означает глобальный характер обработки намерений — любое другое приложение может обработать наше намерение, равно как и мы можем подписаться на обработку чужого. Чтобы не было путаницы, намерения должны быть уникальными. Кстати, если например, нужно открыть интернет-ссылку (одно намерение), а в системе установлено несколько браузеров (несколько широковещательных приемников) — появится окно с выбором предпочитаемого. В следующем разделе мы рассмотрим этот механизм более подробно.

private void refreshWidget() {
    Intent i = new Intent(PingWidget.FORCE_WIDGET_UPDATE);
    sendBroadcast(i);
}

Ближе к концу создаем уже знакомое тебе отложенное намерение на повторный запуск сервиса через не менее знакомый менеджер сигнализаций. Только вместо метода set будем использовать setRepeating, а точнее — setInexactRepeating. Последний помогает в некоторой степени уменьшить энергозатраты, собирая для выполнения близкие по времени сигнализации. Поэтому вместо точного интервала мы передаем константу AlarmManager.INTERVALFIFTEEN_MINUTES для опроса сайтов примерно через каждые 15 мин и AlarmManager.INTERVAL_HALF_HOUR (~30 мин) в случае отсутствия соединения для новой попытки. Возможно, ты захочешь указать другие константы объекта AlarmManager: INTERVAL_HOUR (час), INTERVAL_HALF_DAY (12 ч), INTERVAL_DAY (раз в сутки). Замечу, что эти интервалы очень_ условные, и при необходимости соблюдения более точного расписания следует использовать метод setRepeating, но, как уже отмечалось, он более прожорлив. К слову, будить устройство мы не станем — используем AlarmManager.ELAPSED_REALTIME, так как обновление информации для виджета при выключенном экране не только не требуется, но и, вероятно, вызовет укоризненный взгляд коллег из рубрики X-Mobile.

Добравшись до середины статьи, мы провели всю подготовительную работу и теперь можем с чистой совестью приступить к главной теме нашего изыскания — созданию виджета.

 

Виджет

В Android виджет реализуется в виде широковещательного приемника, реагирующего на некоторые события (строго говоря — намерения(Intent)), для наполнения визуальной разметки актуальными данными по таймеру, с помощью сервиса, по клику и так далее. Разработка виджета начинается с регистрации его класса (PingWidget) в манифесте проекта:

<receiver
  android:name=".PingWidget" android:label="@string/app_name" >
  <intent-filter>
     <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
  </intent-filter>
  <intent-filter>
     <action android:name="com.example.pinger.FORCE_WIDGET_UPDATE" />
  </intent-filter>
  <meta-data
    android:name="android.appwidget.provider"
    android:resource="@xml/widget_provider" />
</receiver>

Здесь тег intent-filter содержит минимум одно стандартное действие — android.appwidget.action.APPWIDGET_UPDATE, используемое для обновления содержимого виджета (еще есть DELETED, ENABLED и DISABLED, но они необязательны). Так как обновлять виджет мы будем, во-первых, самостоятельно, во-вторых, в разные моменты времени, добавим еще одно действие — com.example.pinger.FORCE_WIDGET_UPDATE для нашей задачи. Кроме того, нам потребуется отдельный XML-файл, описывающий настройки виджета (файл res\xml\widget_provider.xml):

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/widget"
    android:minHeight="110dp"
    android:minWidth="250dp"
    android:resizeMode="none"
    android:label="@string/app_name"
    android:updatePeriodMillis="86400000"
    android:previewImage="@drawable/widget_preview" />

Атрибуты minHeight и minWidth определяют минимально допустимые высоту и ширину виджета. Для расчета этих значений применяется формула

min = 70 dp * (количество ячеек) – 30 dp.

Домашний экран в Android разделен виртуальной сеткой, состоящей из ячеек, размеры которых зависят от физических размеров устройства. Можно сказать, что ярлык приложения на домашнем экране соответствует одной ячейке. Наш виджет будет иметь размеры 4 х 2 или 250 dp x 110 dp (в аппаратно-независимых пикселях). Изменение размеров виджета пользователем мы не планируем, поэтому resizeMode устанавливаем в none.

Атрибут updatePeriodMillis задает минимальный период между обновлениями виджета (в миллисекундах) системой, но нам сейчас он неинтересен, так как виджет мы будем обновлять вручную, как только возникнет такая необходимость. Представь, наш фоновый сервис не запущен, а на рабочем экране висит виджет (типичное состояние устройства после перезагрузки) — Android вызовет процедуру его обновления незамедлительно, а уже потом через updatePeriodMillis миллисекунд. При первом обновлении просто запустим наш сервис, и как только он начнет работать, дальнейшее обновление информации в виджете будет инициировать именно он. Поэтому сейчас смело ставим 86 400 000 (то есть раз в сутки) и двигаемся дальше.

Если ты хочешь, чтобы в меню виджетов вместо иконки приложения красовалась симпатичная картинка (см. рис. 4), добавь ссылку на соответствующий ресурс в формате PNG в атрибуте previewImage. О том, как быстро и просто получить такое превью, читай врезку.

Рис. 4. Меню виджетов
Рис. 4. Меню виджетов

Атрибут initialLayout позволяет указать разметку виджета в формате XML. Да, ты не ошибся, разметка виджета во многом напоминает разметку активности или диалогового окна — те же метки, кнопки, картинки, менеджеры компоновки и прочее. Фрагмент разметки нашего виджета представлен ниже (widget.xml):

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget"
...
android:alpha="0.90"
android:background="@drawable/widget"

<ImageView
    android:id="@+id/im1"
    android:src="@drawable/green" />
    ... />

<TextView
    android:id="@+id/txt1"
    android:text="@string/app_name"
    .../>

Наш виджет состоит из небольших картинок (im1, im2, im3, im4) типа ImageView и текстовых меток (txt1, txt2, txt3, txt4) типа TextView. Для компоновки используем RelativeLayout, то есть все компоненты визуально выровнены друг относительно друга. Разумеется, картинки и надписи мы будем менять при отрисовке виджета. Поле alpha задает непрозрачность виджета в диапазоне от 0 (прозрачный) до 1 (непрозрачный), а вот background позволяет задать картинку для фона с эффектами стекла, бликами и тенями (да, я сторонник скевоморфизма). Для генерации такой текстуры можно воспользоваться онлайн-редакторами, которых в интернете очень много (см. полезные ссылки). Кстати, для правильного масштабирования виджета на разных экранах фон желательно перевести в формат NinePatch, о котором расскажет врезка.

Рис. 5. Виджет на смартфоне, а GitHub-то упал...
Рис. 5. Виджет на смартфоне, а GitHub-то упал...

Итак, от визуальной стороны виджета (см. рис. 5) плавно переходим к логике его работы (класс PingWidget.java):

public class PingWidget extends AppWidgetProvider{
    public static String FORCE_WIDGET_UPDATE = "com.example.pinger.FORCE_WIDGET_UPDATE";
    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
        startService(context);
    }
    @Override
    public void onReceive(Context context, Intent intent){
        super.onReceive(context, intent);
        if (FORCE_WIDGET_UPDATE.equals(intent.getAction())) updateWidget(context);
    }
    private void updateWidget(Context context) {
        AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
        ComponentName thisWidget = new ComponentName(context, PingWidget.class);
        int[] appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget);
        drawWidget(context, appWidgetManager, appWidgetIds);
    }
    ...
}

 

NinePatch vs PNG

Изображения формата NinePatch (или растягивающиеся) — это файлы формата PNG, в которых области, предназначенные для масштабирования, помечены явным образом. Android SDK включает в себя визуальный редактор draw9patch, который находится по адресу SDK/Tools/draw9patch.bat.

На рис. 7 ты можешь видеть область растягивания нашего виджета (Patches), помеченную фиолетовым цветом. Какой бы размер виджета ни был, логотип твоего любимого журнала, а также закругленные уголки искажаться не будут. Эта область задается с помощью линеек слева и сверху от изображения. Обрати внимание на рамку толщиной в один пиксель с черными полосами — именно эта информация будет добавлена к исходному изображению.

Рис. 7. Размер — это главное!
Рис. 7. Размер — это главное!

На рис. 8 темным цветом показана область для контента (Content). Здесь мы видим, что все наши картинки и текстовые метки будут иметь небольшой отступ от рамки виджета. Эту красоту определяют линейки справа и снизу от изображения.

Рис. 8. Область для контента не уступает
Рис. 8. Область для контента не уступает

Результат работы сохраняется в формате PNG c добавлением цифры 9 перед расширением файла (например, widget.9.png). При указании ссылки на графический ресурс указывать девятку не нужно (то есть android:background="@drawable/widget").

Класс AppWidgetProvider, являющийся широковещательным приемником, предоставляет нам удобные обработчики жизненного цикла виджета: onUpdate и onReceive (есть и другие — onDeleted, onDisabled, onEnabled, но мы их не рассматриваем). Первый вызывается при обновлении интерфейса виджета с периодичностью updatePeriodMillis (см. выше), как и договорились — просто запускаем сервис (как вариант, можно использовать уже полученные, но, возможно, неактуальные данные — все зависит от задачи). Второй, onReceive, срабатывает при получении определенного действия — FORCE_WIDGET_UPDATE, зарегистрированного нами ранее в манифесте проекта. Именно этот код и отвечает за манипуляции с картинками и текстовыми полями в UI виджета. Функция updateWidget сначала запрашивает объект класса AppWidgetManager, который применяется для обновления виджетов и предоставляет информацию о них. В частности, нас интересуют идентификаторы всех виджетов (массив appWidgetIds), ведь их может быть несколько и обновить нужно каждый из них:

private void drawWidget(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
    final int N = appWidgetIds.length;
    for (int i = 0; i < N; i++) {
        int appWidgetId = appWidgetIds[i];
        RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget);
        // Обновляем первую строку
        String[] data = pf.getData(1);
        views.setTextViewText(R.id.txt1, data[0]);
        views.setImageViewResource(R.id.im1, getPicture(data[1]));
        ...
        appWidgetManager.updateAppWidget(appWidgetId, views);
    }
}
private int getPicture(String type) {
    switch (type) {
        case "1": return R.drawable.green;
        case "0": return R.drawable.red;
        default:  return R.drawable.gray;
    }
}

В единственном цикле функции drawWidget происходит итерация по всем работающим в данный момент виджетам. Класс RemoteViews используется в качестве компонента для доступа к разметке, размещенной внутри процесса другого приложения (мне одному это напоминает Inject?). Если бы мы работали с активностью или фрагментом, то могли бы использовать вполне обычное findViewById. Но наш виджет работает в рамках домашнего экрана, поэтому для получения доступа к разметке необходимо в конструкторе RemoteViews указать название пакета (context.getPackageName()) и ресурс с разметкой (R.layout.widget). Используя views.setTextViewText и views.setImageViewResource, изменяем надпись и картинку (вспомогательная функция getPicture возвращает ссылку на подходящий ресурс). Как только мы внесли правки по всем строкам, фиксируем их в виджете, вызывая updateAppWidget.

 

Вместо заключения

Мы проделали большую работу, и наш виджет работает как часы — админ наверняка будет доволен. Но вот один вопрос так и остался не заданным: как остановить периодические запуски сервиса, если он больше не нужен? Спешу тебя обрадовать, в коде приложения (загляни на dvd.xakep.ru или мой сайт) остановка уже реализована с помощью специального флага setStopServiceFlag, срабатывающего в момент нажатия кнопки «Назад» в главной активности приложения (на кнопку «Домой» не распространяется). Обязательно изучи этот вопрос — это и будет твоим домашним заданием.

 

Превью для виджета

В Play Market живет мегаполезное приложение для разработчиков виджетов — Widget Preview, которое позволяет встроить в себя любой зарегистрированный в системе виджет, после чего делает его снимок. Результат можно сохранить в файл или отправить по почте.

Рис. 6. Так какого цвета платье?
Рис. 6. Так какого цвета платье?

 

WWW

Генераторы кнопок:

Гайд по созданию виджетов от Google: http://goo.gl/4DtqUs

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии