Удаленный доступ к IP камерам. Часть 2. Мобильное приложение

от автора

В предыдущей статье я рассказывал о простом сервере для работы с камерами видеонаблюдения, но для оперативного просмотра 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 уровня приложения:

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:

AndroidManifest.xml

<?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 и ссылки на редактирование/добавление камер:

MainActivity.kt

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

activity_main.xml

<?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 требуется адаптер:

StreamsAdapter.kt

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

и элемент списка:

stream_item.xml

<?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:

StreamData.kt

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:

EditActivity.kt

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

activity_edit.xml

<?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 подобрано опытным путем. Слишком низкое значение может привести к невозможности отображения видеопотока, слишком высокое увеличивает задержку перед воспроизведением.

VideoActivity.kt

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         }     } }

activity_video.xml

<?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:

toolbar.xml

<?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. Времени на написание комментариев в коде не было, прошу не судить строго. Зато комментарии открыты на Хабре – добро пожаловать!


ссылка на оригинал статьи https://habr.com/ru/post/654915/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *