Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В предыдущей статье я рассказывал о простом сервере для работы с камерами видеонаблюдения, но для оперативного просмотра RTSP потоков использовал мобильное приложение VLC, которое меня не вполне устраивало по нескольким причинам. Под катом вы найдете описание и листинги простого мобильного приложения под андроид, написанного специально для охранных камер. Исходники приложения можно взять на github. Для тех, кто не хочет собирать apk самостоятельно, вот ссылка на готовый файл.
Возможно, нам всем сейчас немного не до камер, но Хабр ведь не для политики, верно?
На самом деле доставить контент пользователю можно было бы разными способами, например, через веб приложение. Но, к сожалению, почти все современные браузеры не поддерживают кодек H.265, который мне был очень нужен, поэтому этот путь пришлось отбросить сразу.
Кроме того, в моей схеме подключения участвуют два сервера – локальный, с «серым» 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. Времени на написание комментариев в коде не было, прошу не судить строго. Зато комментарии открыты на Хабре – добро пожаловать!