
В предыдущей статье я рассказывал о простом сервере для работы с камерами видеонаблюдения, но для оперативного просмотра RTSP потоков использовал мобильное приложение VLC, которое меня не вполне устраивало по нескольким причинам. Под катом вы найдете описание и листинги простого мобильного приложения под андроид, написанного специально для охранных камер. Исходники приложения можно взять на github. Для тех, кто не хочет собирать apk самостоятельно, вот ссылка на готовые файлы.
На самом деле доставить контент пользователю можно было бы разными способами, например, через веб приложение. Но, к сожалению, почти все современные браузеры не поддерживают кодек H.265, который мне был очень нужен, поэтому этот путь пришлось отбросить сразу.
UPD: В следующей части будет рассказано о варианте доставки видеопотоков в современные браузеры на основе Chromium 106+ (такая возможность появилась после публикации этой статьи).
Кроме того, в моей схеме подключения участвуют два сервера – локальный, с «серым» IP адесом, и удаленный, с «белым» IP, который предоставляет доступ к камерам через интернет по протоколу TCP. Поэтому одно из главных требований к приложению – возможность явного переключения TCP/UDP. Такой роскоши в VLC нет.
Немного порывшись в плей маркете и перепробовав некоторое количество существующих приложений разного толка, активно впихивающих рекламу, выклянчивающих деньги за платный контент, требующих доступ к всем мыслимым и немыслимым активам телефона и бодро сливающие мои данные в недра интернета, я понял, что для решения такой казалось бы простой задачи все-таки придется заняться мобильной разработкой.
Лирическое отступление о выборе платформы
Фреймворки для разработки кроссплатформенных приложений также пришлось исключить, потому что мне нужно обрабатывать жесты для масштабирования и позиционирования изображения, и сделать это максимально плавно.
Кс��ати, JetBrains предлагает вроде бы интересное мультиплатформенное решение – Kotlin Multiplatform Mobile. Надо попробовать! Устанавливаю плагин KMM в Android Studio, создаю проект по единственному предложенному шаблону. Структура проекта не нравится. Ладно, может быть можно вынести в shared хотя бы строковые ресурсы? Нет, без танцев с бубном нельзя. А как собрать приложение под iOS? Да никак, для этого нужна iOS. А если учесть, что в стране, где я живу, будущее продукции Apple несколько туманно, смысл теряется окончательно. Решено: буду честно писать под андроид на его официальном языке — котлине.
Реализация
Приложение должно быть максимально простым, я (пока) не буду использовать фрагменты и граф навигации. У меня будет всего три экрана: список камер, редактор настроек камеры и экран видео:

Для работы с потоками я буду использовать библиотеку libvlc, настройки сохранять в приватном каталоге во внутреннем хранилище устройства в формате json с помощью библиотеки gson. Для взаимодействия с элементами представления мне нравится view binding, который включается опцией viewBinding true в файле build.gradle уровня приложения:
plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' } android { compileSdk 32 defaultConfig { applicationId "com.vladpen.cams" minSdk 23 targetSdk 32 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } buildFeatures { viewBinding true } packagingOptions { jniLibs { useLegacyPackaging = true } } } dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'com.google.android.material:material:1.5.0' implementation 'com.google.code.gson:gson:2.8.6' implementation 'org.videolan.android:libvlc-all:3.4.9' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' }
В манифесте, помимо трех activity, нужно не забыть включить разрешение на доступ к сети android.permission.INTERNET:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.vladpen.cams"> <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <activity android:name=".EditActivity" android:exported="false" /> <activity android:name=".VideoActivity" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:exported="false" /> </application> </manifest>
Главный экран приложения (MainActivity) содержит список камер recyclerView и ссылки на редактирование/добавление камер:
package com.vladpen.cams import android.content.Intent import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager import com.vladpen.StreamData import com.vladpen.StreamsAdapter import com.vladpen.cams.databinding.ActivityMainBinding class MainActivity: AppCompatActivity() { private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } private val streams by lazy { StreamData.getStreams(this) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) initActivity() } private fun initActivity() { binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.adapter = StreamsAdapter(streams) binding.toolbar.btnBack.visibility = View.GONE binding.toolbar.tvToolbarLabel.text = getString(R.string.app_name) binding.toolbar.tvToolbarLink.text = getString(R.string.add) binding.toolbar.tvToolbarLink.visibility = View.VISIBLE binding.toolbar.tvToolbarLink.setOnClickListener { editScreen() } } private fun editScreen() { val editIntent = Intent(this, EditActivity::class.java) editIntent.putExtra("id", -1) startActivity(editIntent) } }
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> <include android:id="@+id/toolbar" layout="@layout/toolbar" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="wrap_content" android:textColor="@color/text" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/toolbar" tools:listitem="@layout/stream_item" /> </androidx.constraintlayout.widget.ConstraintLayout>
Для работы recyclerView требуется адаптер:
package com.vladpen import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.vladpen.cams.VideoActivity import com.vladpen.cams.EditActivity import com.vladpen.cams.databinding.StreamItemBinding class StreamsAdapter(private val dataSet: List<StreamDataModel>) : RecyclerView.Adapter<StreamsAdapter.StreamHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StreamHolder { val binding = StreamItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) return StreamHolder(parent.context, binding) } override fun onBindViewHolder(holder: StreamHolder, position: Int) { val row: StreamDataModel = dataSet[position] holder.bind(position, row) } override fun getItemCount(): Int = dataSet.size inner class StreamHolder(private val context: Context, private val binding: StreamItemBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(position: Int, row: StreamDataModel) { with(binding) { tvStreamName.text = row.name tvStreamName.setOnClickListener { val intent = Intent(context, VideoActivity::class.java) navigate(context, intent, position) } btnEdit.setOnClickListener { val intent = Intent(context, EditActivity::class.java) navigate(context, intent, position) } } } } private fun navigate(context: Context, intent: Intent, position: Int) { intent.setFlags(FLAG_ACTIVITY_NEW_TASK).putExtra("position", position) context.startActivity(intent) } }
и элемент списка:
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView android:id="@+id/tvStreamName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="" android:textSize="20sp" android:padding="16dp" android:textColor="@color/text" android:background="?attr/selectableItemBackground" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <ImageButton android:id="@+id/btnEdit" android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@color/background" android:foreground="?android:attr/selectableItemBackground" android:contentDescription="@string/settings" android:padding="10dp" android:src="@drawable/ic_baseline_settings_24" app:tint="@color/hint" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
За хранение данных отвечает синглтон StreamData, формат данных описывает data class StreamDataModel:
package com.vladpen import android.content.Context import android.util.Log import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.io.File data class StreamDataModel(val name: String, val url: String, val tcp: Boolean) object StreamData { private const val fileName = "streams.json" private var streams = mutableListOf<StreamDataModel>() fun save(context: Context, position: Int, stream: StreamDataModel) { if (position < 0) { streams.add(stream) } else { streams[position] = stream } streams.sortBy { it.name } write(context) } fun delete(context: Context, position: Int) { if (position < 0) { return } streams.removeAt(position) write(context) } private fun write(context: Context) { val json = Gson().toJson(streams) context.openFileOutput(fileName, Context.MODE_PRIVATE).use { it.write(json.toByteArray()) } } fun getStreams(context: Context): MutableList<StreamDataModel> { if (streams.size == 0) { try { val filesDir = context.filesDir if (File(filesDir, fileName).exists()) { val json: String = File(filesDir, fileName).readText() initStreams(json) } else { Log.i("DATA", "Data file $fileName does not exist") } } catch (e: Exception) { Log.e("Data", e.localizedMessage ?: "Can't read data file $fileName") } } return streams } fun getByPosition(position: Int): StreamDataModel? { if (position < 0 || position >= streams.count()) { return null } return streams[position] } private fun initStreams(json: String) { if (json == "") { return } val listType = object : TypeToken<List<StreamDataModel>>() { }.type streams = Gson().fromJson<List<StreamDataModel>>(json, listType).toMutableList() } }
Камеры (streams) хранятся в списке mutableList, доступ к данным камеры можно получить по индексу (position).
Экран редактирования настроек камер (EditActivity) отвечает за добавление, редактирование и удаление записей в списке streams:
package com.vladpen.cams import android.content.Intent import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import com.vladpen.StreamData import com.vladpen.StreamDataModel import com.vladpen.cams.databinding.ActivityEditBinding class EditActivity : AppCompatActivity() { private val binding by lazy { ActivityEditBinding.inflate(layoutInflater) } private val streams by lazy { StreamData.getStreams(this) } private var position: Int = -1 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) initActivity() } private fun initActivity() { position = intent.getIntExtra("position", -1) val stream = StreamData.getByPosition(position) if (stream == null) { position = -1 binding.toolbar.tvToolbarLabel.text = getString(R.string.cam_add) } else { binding.toolbar.tvToolbarLabel.text = stream.name binding.etEditName.setText(stream.name) binding.etEditUrl.setText(stream.url) binding.scEditTcp.isChecked = !stream.tcp binding.tvDeleteLink.visibility = View.VISIBLE binding.tvDeleteLink.setOnClickListener { delete() } } binding.btnSave.setOnClickListener { save() } binding.toolbar.btnBack.setOnClickListener { back() } } private fun save() { if (!validate()) { return } StreamData.save(this, position, StreamDataModel( binding.etEditName.text.toString().trim(), binding.etEditUrl.text.toString().trim(), !binding.scEditTcp.isChecked )) back() } private fun validate(): Boolean { val name = binding.etEditName.text.toString().trim() val url = binding.etEditUrl.text.toString().trim() var ok = true if (name.isEmpty() || name.length > 255) { binding.etEditName.error = getString(R.string.err_invalid) ok = false } if (url.isEmpty() || url.length > 255) { binding.etEditUrl.error = getString(R.string.err_invalid) ok = false } for (i in streams.indices) { if (i == position) { break } if (streams[i].name == name) { binding.etEditName.error = getString(R.string.err_cam_exists) ok = false } if (streams[i].name == url) { binding.etEditUrl.error = getString(R.string.err_cam_exists) ok = false } } return ok } private fun delete() { AlertDialog.Builder(this) .setMessage(R.string.cam_delete) .setPositiveButton(R.string.delete) { _, _ -> StreamData.delete(this, position) back() } .setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } .create().show() } private fun back() { startActivity(Intent(this, MainActivity::class.java)) } }
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <include android:id="@+id/toolbar" layout="@layout/toolbar"/> <TextView android:id="@+id/tvHintName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/cam_name" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/toolbar" /> <EditText android:id="@+id/etEditName" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="text" android:hint="@string/cam_name_hint" android:autofillHints="" android:gravity="center" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/tvHintName" /> <TextView android:id="@+id/tvHintUrl" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/cam_url" android:layout_marginTop="16dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/etEditName" /> <EditText android:id="@+id/etEditUrl" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="textUri" android:hint="@string/cam_url_hint" android:autofillHints="" android:gravity="center" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/tvHintUrl" /> <androidx.appcompat.widget.SwitchCompat android:id="@+id/scEditTcp" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/cam_tcp_udp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/etEditUrl" /> <Button android:id="@+id/btnSave" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:padding="10dp" android:text="@string/save" android:background="@color/buttonBackground" android:foreground="?android:attr/selectableItemBackground" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/scEditTcp" /> <TextView android:id="@+id/tvDeleteLink" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/delete" android:layout_marginTop="18dp" android:padding="10dp" android:textColor="@color/error" android:clickable="true" android:focusable="true" android:background="?attr/selectableItemBackground" android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/scEditTcp" /> </androidx.constraintlayout.widget.ConstraintLayout>
Экран видео (VideoActivity) инициализирует медиаплеер (MediaPlayer(libVlc)) и добавляет необходимые параметры --rtsp-tcp и network-caching. К сожалению, не существует рекомендуемого набора опций, при которых плеер будет работать «хорошо». Значение параметра network-caching подобрано опытным путем. Слишком низкое значение может привести к невозможности отображения видеопотока, слишком высокое увеличивает задержку перед воспроизведением.
package com.vladpen.cams import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.* import android.view.ScaleGestureDetector.SimpleOnScaleGestureListener import androidx.appcompat.app.AppCompatActivity import com.vladpen.StreamData import com.vladpen.cams.databinding.ActivityVideoBinding import org.videolan.libvlc.LibVLC import org.videolan.libvlc.Media import org.videolan.libvlc.MediaPlayer import org.videolan.libvlc.util.VLCVideoLayout import java.io.IOException import kotlin.math.max import kotlin.math.min class VideoActivity : AppCompatActivity(), MediaPlayer.EventListener { private val binding by lazy { ActivityVideoBinding.inflate(layoutInflater) } private lateinit var libVlc: LibVLC private lateinit var mediaPlayer: MediaPlayer private lateinit var videoLayout: VLCVideoLayout private lateinit var scaleGestureDetector: ScaleGestureDetector private var scaleFactor = 1.0f private var position: Int = -1 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) initActivity() } private fun initActivity() { position = intent.getIntExtra("position", -1) val stream = StreamData.getByPosition(position) if (stream == null) { position = -1 return } binding.toolbar.tvToolbarLabel.text = stream.name binding.toolbar.btnBack.setOnClickListener { val mainIntent = Intent(this, MainActivity::class.java) startActivity(mainIntent) } videoLayout = binding.videoLayout libVlc = LibVLC(this, ArrayList<String>().apply { if (stream.tcp) { add("--rtsp-tcp") } }) mediaPlayer = MediaPlayer(libVlc) mediaPlayer.setEventListener(this) mediaPlayer.attachViews(videoLayout, null, false, false) try { val uri = Uri.parse(stream.url) Media(libVlc, uri).apply { setHWDecoderEnabled(true, false) addOption(":network-caching=150") mediaPlayer.media = this }.release() mediaPlayer.play() } catch (e: IOException) { e.printStackTrace() } scaleGestureDetector = ScaleGestureDetector(this, ScaleListener()) } override fun onStop() { super.onStop() mediaPlayer.stop() mediaPlayer.detachViews() } override fun onDestroy() { super.onDestroy() mediaPlayer.release() libVlc.release() } override fun onEvent(ev: MediaPlayer.Event) { if (ev.type == MediaPlayer.Event.Buffering && ev.buffering == 100f) { binding.pbLoading.visibility = View.GONE } } override fun onTouchEvent(ev: MotionEvent): Boolean { // Let the ScaleGestureDetector inspect all events. scaleGestureDetector.onTouchEvent(ev) return true } inner class ScaleListener : SimpleOnScaleGestureListener() { override fun onScale(scaleGestureDetector: ScaleGestureDetector): Boolean { scaleFactor *= scaleGestureDetector.scaleFactor scaleFactor = max(1f, min(scaleFactor, 10.0f)) videoLayout.scaleX = scaleFactor videoLayout.scaleY = scaleFactor return true } } }
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".VideoActivity"> <org.videolan.libvlc.util.VLCVideoLayout android:id="@+id/videoLayout" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> <ProgressBar android:id="@+id/pbLoading" android:layout_width="wrap_content" android:layout_height="wrap_content" android:indeterminate="true" app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> <include android:id="@+id/toolbar" layout="@layout/toolbar"/> </androidx.constraintlayout.widget.ConstraintLayout>
Экран видео дополнительно реализует (implements) интерфейс MediaPlayer.EventListener, который нужен для отключения индикатора загрузки (pbLoading) после окончания буферизации потока. Внутренний класс ScaleListener обрабатывает жест масштабирования «pinch zoom».
Заголовок экранов я вынес в отдельный файл, включаемый в разметку экранов директивой include:
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/overlay_background"> <ImageButton android:id="@+id/btnBack" android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@color/transparent_background" android:foreground="?android:attr/selectableItemBackground" android:padding="10dp" android:src="@drawable/ic_baseline_arrow_back_24" android:contentDescription="@string/back" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> <TextView android:id="@+id/tvToolbarLabel" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:textColor="@android:color/white" android:textSize="20sp" android:textStyle="bold" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toEndOf="@+id/btnBack" app:layout_constraintTop_toTopOf="parent" /> <TextView android:id="@+id/tvToolbarLink" android:layout_width="wrap_content" android:layout_height="wrap_content" android:padding="10dp" android:layout_marginEnd="6dp" android:textColor="@color/hint" android:clickable="true" android:focusable="true" android:background="?attr/selectableItemBackground" android:visibility="gone" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
В результате приложение получилось если не максимально простым, то, по крайне мере, максимально близким к этому:)
Сборка
Хотя нативные приложения имеют минимальный размер (и максимальную производительность), использование библиотеки libvlc-all увеличивает результирующий размер сборки:

Как видите, поддержка каждой платформы съедает около 19 МБ дискового пространства. Такова цена «всеядности» VLC, который работает почти всегда и везде и воспроизводит все, что вообще может воспроизводиться.
TODO
Поскольку мне нужно было сделать максимально просто, в эту статью не вошла реализация перемещения увеличенного изображение – это требует некоторого количества арифметических вычислений, не добавляющих понятности коду. Кроме того, чуть позже я планирую добавить поддержку воспроизведения сохраненного архива через SFTP.
Вместо заключения
В результате моих исследований получилось простое, но вполне рабочее приложение, поэтому я оставлю его здесь, на Хабре. Надеюсь, кому-нибудь поможет.
P.S. Времени на написание комментариев в коде не было, прошу не судить строго. Зато комментарии открыты на Хабре – добро пожаловать!
