1.0.12: запись приёма в OGG с пропуском тишины
- RxRecorder: MediaCodec(Opus) + MediaMuxer(OGG), пишет декодированный RX PCM 48 кГц; pts не продвигается во время тишины, поэтому паузы в эфире не попадают в файл. - OpusEngine.rxTap: перехват PCM из выхода декодера. - SvxService: startRecording()/stopRecording()/isRecording(). - MainActivity: кнопка btn_record между позывным и монитором, индикация (иконка краснеет), тосты о старте/сохранении. - Файлы: Android/data/ru.ua1zbe.svxremote/files/recordings/rec_*.ogg. Пустой файл (весь сеанс — тишина) удаляется.
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "ru.ua1zbe.svxremote"
|
applicationId = "ru.ua1zbe.svxremote"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 13
|
versionCode = 14
|
||||||
versionName = "1.0.11"
|
versionName = "1.0.12"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class MainActivity : Activity(), SvxService.Listener {
|
|||||||
private lateinit var pttButton: PttButton
|
private lateinit var pttButton: PttButton
|
||||||
private lateinit var btnSettings: ImageButton
|
private lateinit var btnSettings: ImageButton
|
||||||
private lateinit var btnMonitor: ImageButton
|
private lateinit var btnMonitor: ImageButton
|
||||||
|
private lateinit var btnRecord: ImageButton
|
||||||
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
private var titleHoldRunnable: Runnable? = null
|
private var titleHoldRunnable: Runnable? = null
|
||||||
@@ -91,6 +92,7 @@ class MainActivity : Activity(), SvxService.Listener {
|
|||||||
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
||||||
svc = (binder as SvxService.LocalBinder).service()
|
svc = (binder as SvxService.LocalBinder).service()
|
||||||
svc?.listener = this@MainActivity
|
svc?.listener = this@MainActivity
|
||||||
|
updateRecordUi(svc?.isRecording() ?: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceDisconnected(name: ComponentName) {
|
override fun onServiceDisconnected(name: ComponentName) {
|
||||||
@@ -148,9 +150,11 @@ class MainActivity : Activity(), SvxService.Listener {
|
|||||||
pttButton = findViewById(R.id.ptt_button)
|
pttButton = findViewById(R.id.ptt_button)
|
||||||
btnSettings = findViewById(R.id.btn_settings)
|
btnSettings = findViewById(R.id.btn_settings)
|
||||||
btnMonitor = findViewById(R.id.btn_monitor)
|
btnMonitor = findViewById(R.id.btn_monitor)
|
||||||
|
btnRecord = findViewById(R.id.btn_record)
|
||||||
pttButton.enabled2 = false
|
pttButton.enabled2 = false
|
||||||
|
|
||||||
btnMonitor.setOnClickListener { toggleMonitoring() }
|
btnMonitor.setOnClickListener { toggleMonitoring() }
|
||||||
|
btnRecord.setOnClickListener { toggleRecording() }
|
||||||
updateMonitorButton()
|
updateMonitorButton()
|
||||||
val testText = intent?.getStringExtra("node_toast_test")
|
val testText = intent?.getStringExtra("node_toast_test")
|
||||||
if (testText != null) {
|
if (testText != null) {
|
||||||
@@ -669,6 +673,30 @@ class MainActivity : Activity(), SvxService.Listener {
|
|||||||
getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor)
|
getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun toggleRecording() {
|
||||||
|
val s = svc ?: return
|
||||||
|
if (s.isRecording()) {
|
||||||
|
val path = s.stopRecording()
|
||||||
|
updateRecordUi(false)
|
||||||
|
toast(getString(R.string.record_saved, path ?: ""))
|
||||||
|
} else {
|
||||||
|
val path = s.startRecording()
|
||||||
|
if (path != null) {
|
||||||
|
updateRecordUi(true)
|
||||||
|
toast(getString(R.string.record_started))
|
||||||
|
} else {
|
||||||
|
toast(getString(R.string.record_failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRecordUi(active: Boolean) {
|
||||||
|
btnRecord.imageTintList = ColorStateList.valueOf(
|
||||||
|
if (active) Color.parseColor("#F85149") else Color.parseColor("#8B949E")
|
||||||
|
)
|
||||||
|
btnRecord.contentDescription = getString(R.string.btn_record)
|
||||||
|
}
|
||||||
|
|
||||||
/** Переключиться на другую группу: отправить серверу по живому соединению. */
|
/** Переключиться на другую группу: отправить серверу по живому соединению. */
|
||||||
private fun switchGroup(tg: Long) {
|
private fun switchGroup(tg: Long) {
|
||||||
settings = settings.copy(talkGroup = tg)
|
settings = settings.copy(talkGroup = tg)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.os.Binder
|
|||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import ru.ua1zbe.svxremote.audio.OpusEngine
|
import ru.ua1zbe.svxremote.audio.OpusEngine
|
||||||
|
import ru.ua1zbe.svxremote.audio.RxRecorder
|
||||||
import ru.ua1zbe.svxremote.prefs.Prefs
|
import ru.ua1zbe.svxremote.prefs.Prefs
|
||||||
import ru.ua1zbe.svxremote.proto.MsgParser
|
import ru.ua1zbe.svxremote.proto.MsgParser
|
||||||
import ru.ua1zbe.svxremote.proto.ReflectorClient
|
import ru.ua1zbe.svxremote.proto.ReflectorClient
|
||||||
@@ -40,10 +41,13 @@ class SvxService : Service(), ReflectorClient.Callbacks {
|
|||||||
OpusEngine(
|
OpusEngine(
|
||||||
onLevel = { source, level -> listener?.onLevel(source, level) },
|
onLevel = { source, level -> listener?.onLevel(source, level) },
|
||||||
onRxStats = { lossPct, jitterMs -> listener?.onRxStats(lossPct, jitterMs) }
|
onRxStats = { lossPct, jitterMs -> listener?.onRxStats(lossPct, jitterMs) }
|
||||||
)
|
).also { it.rxTap = { pcm, real -> recorder.feed(pcm, real) } }
|
||||||
}
|
}
|
||||||
val client: ReflectorClient by lazy { ReflectorClient(this) }
|
val client: ReflectorClient by lazy { ReflectorClient(this) }
|
||||||
|
|
||||||
|
/** Запись приёма в OGG (управляется из UI). */
|
||||||
|
private val recorder = RxRecorder()
|
||||||
|
|
||||||
private val binder = LocalBinder()
|
private val binder = LocalBinder()
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
inner class LocalBinder : Binder() {
|
||||||
@@ -189,4 +193,27 @@ class SvxService : Service(), ReflectorClient.Callbacks {
|
|||||||
override fun onError(detail: String) {
|
override fun onError(detail: String) {
|
||||||
listener?.onError(detail)
|
listener?.onError(detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Запись приёма в файл ----------
|
||||||
|
|
||||||
|
fun isRecording(): Boolean = recorder.isRecording()
|
||||||
|
|
||||||
|
/** Начать запись. Возвращает путь к файлу или null при ошибке. */
|
||||||
|
fun startRecording(): String? {
|
||||||
|
if (recorder.isRecording()) return recorder.getFilePath()
|
||||||
|
val dir = getExternalFilesDir(null) ?: filesDir
|
||||||
|
return if (recorder.start(dir)) recorder.getFilePath() else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Остановить запись. Возвращает путь к сохранённому файлу или null. */
|
||||||
|
fun stopRecording(): String? {
|
||||||
|
if (!recorder.isRecording()) return null
|
||||||
|
recorder.stop()
|
||||||
|
return recorder.getFilePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
recorder.stop()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ class OpusEngine(
|
|||||||
// Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет).
|
// Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет).
|
||||||
@Volatile
|
@Volatile
|
||||||
var txBitrateKbps = 0
|
var txBitrateKbps = 0
|
||||||
|
// Перехват декодированного RX PCM (для записи в файл). Аргументы: PCM, реальный приём?
|
||||||
|
@Volatile
|
||||||
|
var rxTap: ((pcm: ByteArray, isReal: Boolean) -> Unit)? = null
|
||||||
// Текущий битрейт в режиме Авто (пересчитывается по статистике приёма)
|
// Текущий битрейт в режиме Авто (пересчитывается по статистике приёма)
|
||||||
@Volatile
|
@Volatile
|
||||||
private var autoKbps = 16
|
private var autoKbps = 16
|
||||||
@@ -242,6 +245,7 @@ class OpusEngine(
|
|||||||
// Уровень показываем только для реальных пакетов: во время
|
// Уровень показываем только для реальных пакетов: во время
|
||||||
// скрытия потерь (conceal) шкала должна быть в нуле
|
// скрытия потерь (conceal) шкала должна быть в нуле
|
||||||
val real = System.currentTimeMillis() - lastRealPacketTime < 400
|
val real = System.currentTimeMillis() - lastRealPacketTime < 400
|
||||||
|
rxTap?.invoke(pcm, real)
|
||||||
val lvl = if (real) pcmLevel(pcm) else 0f
|
val lvl = if (real) pcmLevel(pcm) else 0f
|
||||||
// Порог тишины: шум декодера и паузы речи — в ноль
|
// Порог тишины: шум декодера и паузы речи — в ноль
|
||||||
onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl)
|
onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl)
|
||||||
|
|||||||
179
app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt
Normal file
179
app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package ru.ua1zbe.svxremote.audio
|
||||||
|
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.media.MediaMuxer
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись декодированного RX-аудио (PCM 48 кГц моно 16 бит) в OGG/Opus.
|
||||||
|
* Тишина (отсутствие приёма) не пишется: во время пауз pts не продвигается,
|
||||||
|
* поэтому речь в файле склеивается без пропусков/пустых участков.
|
||||||
|
*/
|
||||||
|
class RxRecorder {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "SVXRemote"
|
||||||
|
const val SAMPLE_RATE = 48000
|
||||||
|
const val CHANNELS = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var muxer: MediaMuxer? = null
|
||||||
|
@Volatile
|
||||||
|
private var encoder: MediaCodec? = null
|
||||||
|
private val recording = AtomicBoolean(false)
|
||||||
|
private val queue = LinkedBlockingQueue<ByteArray>()
|
||||||
|
private var worker: Thread? = null
|
||||||
|
private var filePath = ""
|
||||||
|
// Накопленный pts (мкс). Растёт только при подаче реального аудио (без тишины).
|
||||||
|
private var ptsUs = 0L
|
||||||
|
private var trackIndex = -1
|
||||||
|
private var muxStarted = false
|
||||||
|
private var wroteAny = false
|
||||||
|
private val info = MediaCodec.BufferInfo()
|
||||||
|
|
||||||
|
fun isRecording(): Boolean = recording.get()
|
||||||
|
|
||||||
|
fun getFilePath(): String = filePath
|
||||||
|
|
||||||
|
/** Начать запись. dir — базовая директория (используется подкаталог recordings). */
|
||||||
|
fun start(dir: File): Boolean {
|
||||||
|
if (recording.get()) return true
|
||||||
|
val outDir = File(dir, "recordings")
|
||||||
|
if (!outDir.exists()) outDir.mkdirs()
|
||||||
|
val f = File(outDir, "rec_%016x.ogg".format(System.currentTimeMillis()))
|
||||||
|
filePath = f.absolutePath
|
||||||
|
val mux = try {
|
||||||
|
MediaMuxer(filePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "recorder muxer create failed: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val fmt = MediaFormat()
|
||||||
|
fmt.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
|
||||||
|
fmt.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE)
|
||||||
|
fmt.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
|
||||||
|
fmt.setInteger(MediaFormat.KEY_BIT_RATE, 32000)
|
||||||
|
fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1920)
|
||||||
|
val enc = try {
|
||||||
|
MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
|
||||||
|
it.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
it.start()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "recorder encoder create failed: ${e.message}")
|
||||||
|
try { mux.release() } catch (_: Exception) {}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
muxer = mux
|
||||||
|
encoder = enc
|
||||||
|
trackIndex = -1
|
||||||
|
muxStarted = false
|
||||||
|
ptsUs = 0L
|
||||||
|
queue.clear()
|
||||||
|
recording.set(true)
|
||||||
|
worker = Thread({ workerLoop() }, "rx-recorder").also { it.isDaemon = true; it.start() }
|
||||||
|
Log.d(TAG, "recorder started -> $filePath")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подача декодированного PCM. isReal == false (тишина/скрытие потерь) — не пишем. */
|
||||||
|
fun feed(pcm: ByteArray, isReal: Boolean) {
|
||||||
|
if (!recording.get() || !isReal) return
|
||||||
|
queue.offer(pcm)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun workerLoop() {
|
||||||
|
val enc = encoder ?: return
|
||||||
|
val mux = muxer ?: return
|
||||||
|
val timeout = 1000L
|
||||||
|
try {
|
||||||
|
while (recording.get() || queue.isNotEmpty()) {
|
||||||
|
val pcm = queue.poll()
|
||||||
|
if (pcm != null) {
|
||||||
|
var inIdx = enc.dequeueInputBuffer(timeout)
|
||||||
|
if (inIdx >= 0) {
|
||||||
|
val buf = enc.getInputBuffer(inIdx) ?: continue
|
||||||
|
buf.clear()
|
||||||
|
buf.put(pcm)
|
||||||
|
val pts = ptsUs
|
||||||
|
enc.queueInputBuffer(inIdx, 0, pcm.size, pts, 0)
|
||||||
|
// Продвигаем pts только для реального аудио (без тишины)
|
||||||
|
ptsUs += (pcm.size / 2) * 1_000_000L / SAMPLE_RATE
|
||||||
|
} else {
|
||||||
|
queue.offer(pcm)
|
||||||
|
Thread.sleep(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drainEncoder(enc, mux, false)
|
||||||
|
if (!recording.get() && queue.isEmpty()) break
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "recorder worker error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
// Дренаж остатков после остановки
|
||||||
|
try {
|
||||||
|
drainEncoder(enc, mux, true)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
try { enc.stop() } catch (_: Exception) {}
|
||||||
|
try { enc.release() } catch (_: Exception) {}
|
||||||
|
if (muxStarted) {
|
||||||
|
try { mux.stop() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
try { mux.release() } catch (_: Exception) {}
|
||||||
|
encoder = null
|
||||||
|
muxer = null
|
||||||
|
Log.d(TAG, "recorder stopped -> $filePath")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drainEncoder(enc: MediaCodec, mux: MediaMuxer, drainAll: Boolean) {
|
||||||
|
val timeout = if (drainAll) 0L else 1000L
|
||||||
|
var outIdx = enc.dequeueOutputBuffer(info, timeout)
|
||||||
|
while (outIdx >= 0) {
|
||||||
|
if (info.size > 0) {
|
||||||
|
if (trackIndex < 0) {
|
||||||
|
trackIndex = mux.addTrack(enc.outputFormat)
|
||||||
|
mux.start()
|
||||||
|
muxStarted = true
|
||||||
|
Log.d(TAG, "recorder muxer track added, started")
|
||||||
|
}
|
||||||
|
val outBuf = enc.getOutputBuffer(outIdx) ?: run {
|
||||||
|
enc.releaseOutputBuffer(outIdx, false)
|
||||||
|
outIdx = enc.dequeueOutputBuffer(info, timeout)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outBuf.position(info.offset)
|
||||||
|
outBuf.limit(info.offset + info.size)
|
||||||
|
mux.writeSampleData(trackIndex, outBuf, info)
|
||||||
|
wroteAny = true
|
||||||
|
}
|
||||||
|
enc.releaseOutputBuffer(outIdx, false)
|
||||||
|
outIdx = enc.dequeueOutputBuffer(info, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!recording.get()) return
|
||||||
|
recording.set(false)
|
||||||
|
val w = worker
|
||||||
|
if (w != null && w.isAlive) {
|
||||||
|
try { w.join(4000) } catch (_: InterruptedException) {}
|
||||||
|
}
|
||||||
|
worker = null
|
||||||
|
// Если ни одного кадра не записалось (весь сеанс — тишина) — удаляем пустой файл.
|
||||||
|
if (!wroteAny) {
|
||||||
|
try {
|
||||||
|
val f = File(filePath)
|
||||||
|
if (f.exists()) f.delete()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
app/src/main/res/drawable/ic_record.xml
Normal file
9
app/src/main/res/drawable/ic_record.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#8B949E"
|
||||||
|
android:pathData="M12,12m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0" />
|
||||||
|
</vector>
|
||||||
@@ -85,10 +85,22 @@
|
|||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:text="@string/settings_not_configured" />
|
android:text="@string/settings_not_configured" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_record"
|
||||||
|
android:layout_width="44dp"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:src="@drawable/ic_record"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/btn_record"
|
||||||
|
android:scaleType="centerInside"
|
||||||
|
android:tint="@color/text_secondary" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btn_monitor"
|
android:id="@+id/btn_monitor"
|
||||||
android:layout_width="44dp"
|
android:layout_width="44dp"
|
||||||
android:layout_height="44dp"
|
android:layout_height="44dp"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
android:src="@drawable/ic_headset"
|
android:src="@drawable/ic_headset"
|
||||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
android:contentDescription="@string/btn_monitor"
|
android:contentDescription="@string/btn_monitor"
|
||||||
|
|||||||
@@ -41,10 +41,14 @@
|
|||||||
<string name="status_disconnected">Отключено</string>
|
<string name="status_disconnected">Отключено</string>
|
||||||
<string name="app_title">SVXHomyak</string>
|
<string name="app_title">SVXHomyak</string>
|
||||||
<string name="btn_power">Выключить: отключиться и закрыть</string>
|
<string name="btn_power">Выключить: отключиться и закрыть</string>
|
||||||
|
<string name="btn_record">Запись приёма в файл (нажмите, чтобы начать/остановить)</string>
|
||||||
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
|
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
|
||||||
<string name="monitoring_dev_warning">Мониторинг выбранных групп!</string>
|
<string name="monitoring_dev_warning">Мониторинг выбранных групп!</string>
|
||||||
<string name="monitor_off">Обычный режим</string>
|
<string name="monitor_off">Обычный режим</string>
|
||||||
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string>
|
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string>
|
||||||
|
<string name="record_started">Запись приёма начата</string>
|
||||||
|
<string name="record_saved">Сохранено: %1$s</string>
|
||||||
|
<string name="record_failed">Не удалось начать запись (формат OGG недоступен)</string>
|
||||||
<string name="status_monitoring">Мониторинг: %1$s</string>
|
<string name="status_monitoring">Мониторинг: %1$s</string>
|
||||||
<string name="talker_now">Говорит TG%1$d</string>
|
<string name="talker_now">Говорит TG%1$d</string>
|
||||||
<string name="duplex_rx">RX</string>
|
<string name="duplex_rx">RX</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user