9 Commits

Author SHA1 Message Date
ua1zbe
fcaf7355b5 Bump version to 1.0.15 (17) 2026-10-09 14:31:26 +03:00
ua1zbe
78bac27175 MainActivity: кнопка «Назад» сворачивает приложение в фон
onBackPressed -> moveTaskToBack(true): задача уходит в фон (как Home),
активность и сервис не завершаются.
2026-10-09 14:29:37 +03:00
ua1zbe
2d332f10bb Splash: не создавать дубликат при тапе по иконке
Если приложение уже запущено (есть задача), launcher-активность добавляется
поверх неё. Добавлен guard isTaskRoot: в этом случае SplashActivity сразу
закрывается, текущий экран просто разворачивается — без splash и перезапуска.
Холодный старт (задача отсутствует) работает как прежде.
2026-10-09 14:25:04 +03:00
ua1zbe
96ab5c76e2 Stats: считать только холодный запуск; время по Москве
Засчитываем запуск лишь когда процесс создан заново (приложение было
выгружено), через флаг SvxApp.coldStart из SplashActivity. Выход из фона
и повторный тап по иконке (процесс жив) больше не учитываются. Время
последнего запуска пишем по Москве (формат ISO с офсетом +03:00).
Убрана зависимость lifecycle-process.
2026-10-09 14:11:56 +03:00
ua1zbe
4e7bf86f37 Stats: считать запуском каждый выход на передний план
SvxApp.onCreate вызывается один раз на процесс, поэтому при живом
фоне повторные открытия не учитывались. Теперь считаем onStart
ProcessLifecycleOwner (холодный старт и каждое открытие приложения).
2026-10-09 14:03:25 +03:00
ua1zbe
b6e4fb7e3a Add launch stats reporting per callsign
При старте приложения обновляет запись своего позывного во внешнем
документе: версия ПО, время последнего запуска и число запусков.
Записи других позывных не изменяются.
2026-10-09 13:51:37 +03:00
ua1zbe
0a24a6f6c0 Bump version to 1.0.14 (16) 2026-10-09 13:20:28 +03:00
ua1zbe
b847320360 reconnect: соблюдать паузу между попытками (backoff)
tryConnectOnce игнорировала результат runTcpLoop и всегда возвращала true,
из-за чего reconnectLoop не делал паузу и переподключался заново каждые
~30 мс, заваливая сервер MSG_ERROR 'Access denied'. Теперь возвращаем
!running, поэтому после разрыва/отказа выдерживается RECONNECT_DELAY_MS.
2026-10-09 13:17:50 +03:00
ua1zbe
203e5f6ed5 reconnect: не уходить в «ошибку авторизации» при коротком обрыве
После короткого обрыва сервер ещё держит старую сессию и на рукопожатии
присылает MSG_ERROR. Клиент трактовал любую ошибку до STATE_READY как
фатальную (stopFatal → STATE_AUTH_FAILED) и прекращал переподключения.

Теперь ошибки рукопожатия (MSG_ERROR, протокольные сбои, слишком большой
кадр) считаются временными, если успешная сессия уже была (everReady):
показываем STATE_ERROR и продолжаем автопереподключение. Фатально — только
до первой успешной авторизации (реально неверные креды).
2026-10-09 13:05:05 +03:00
6 changed files with 170 additions and 7 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "ru.ua1zbe.svxremote"
minSdk = 26
targetSdk = 34
versionCode = 15
versionName = "1.0.13"
versionCode = 17
versionName = "1.0.15"
}
buildTypes {

View File

@@ -330,6 +330,12 @@ class MainActivity : Activity(), SvxService.Listener {
}
}
@Suppress("DEPRECATION")
override fun onBackPressed() {
// Кнопка «Назад» сворачивает приложение в фон, а не закрывает его.
moveTaskToBack(true)
}
override fun onDestroy() {
super.onDestroy()
hideNodeOverlay()

View File

@@ -16,6 +16,24 @@ class SplashActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Тап по иконке при уже запущенном приложении: система добавляет
// SplashActivity поверх существующей задачи. Не создаём дубликат —
// закрываемся и показываем текущий экран (как «развернуть», без splash).
if (!isTaskRoot &&
intent?.action == Intent.ACTION_MAIN &&
intent?.hasCategory(Intent.CATEGORY_LAUNCHER) == true
) {
finish()
return
}
// Засчитываем запуск только при холодном старте (процесс был выгружен).
// Выход из фона или повторный тап по иконке (процесс жив) не считаем.
if (SvxApp.coldStart) {
SvxApp.coldStart = false
StatsReporter.report(this)
}
if (mainAlive) {
openMain()
return

View File

@@ -0,0 +1,99 @@
package ru.ua1zbe.svxremote
import android.content.Context
import android.util.Base64
import android.util.Log
import org.json.JSONObject
import ru.ua1zbe.svxremote.prefs.Prefs
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Учёт запусков по позывному.
*
* При старте приложения обновляет во внешнем документе запись своего позывного
* (версия ПО, время последнего запуска, число запусков). Записи других
* позывных не изменяются.
*/
object StatsReporter {
private const val TAG = "SVXRemote"
private const val API =
"https://git2.ua1zbe.ru/api/v1/repos/svxhomyak/svxx/contents/.svxstats.json"
private const val TOKEN = "d1ef9a06c6471f009aefd3097772d263c267b008"
private const val BRANCH = "main"
/** Запускает фоновое обновление статистики (не блокирует старт приложения). */
fun report(context: Context) {
val callsign = Prefs.load(context).callsign.trim().uppercase()
if (callsign.isEmpty()) return
Thread({
repeat(3) { attempt ->
if (runCatching { update(callsign) }.getOrDefault(false)) return@Thread
Thread.sleep(1000L * (attempt + 1))
}
}, "svx-stats").apply { isDaemon = true }.start()
}
private fun update(callsign: String): Boolean {
var sha: String? = null
val root = JSONObject()
val get = URL(API).openConnection() as HttpURLConnection
try {
get.setRequestProperty("Authorization", "token $TOKEN")
get.connectTimeout = 10_000
get.readTimeout = 10_000
if (get.responseCode == 200) {
val obj = JSONObject(get.inputStream.bufferedReader().readText())
sha = obj.optString("sha").ifEmpty { null }
val encoded = obj.optString("content", "").replace("\n", "")
if (encoded.isNotEmpty()) {
val decoded = String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8)
runCatching { JSONObject(decoded) }.getOrNull()?.let { parsed ->
for (key in parsed.keys()) root.put(key, parsed.get(key))
}
}
}
} finally {
get.disconnect()
}
val now = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).apply {
timeZone = TimeZone.getTimeZone("Europe/Moscow")
}.format(Date())
val launches = (root.optJSONObject(callsign)?.optInt("launches", 0) ?: 0) + 1
root.put(callsign, JSONObject().apply {
put("callsign", callsign)
put("version", BuildConfig.VERSION_NAME)
put("lastLaunch", now)
put("launches", launches)
})
val body = JSONObject().apply {
put("content", Base64.encodeToString(root.toString().toByteArray(), Base64.NO_WRAP))
put("message", "stats")
put("branch", BRANCH)
if (sha != null) put("sha", sha)
}
val put = URL(API).openConnection() as HttpURLConnection
return try {
put.requestMethod = "PUT"
put.doOutput = true
put.setRequestProperty("Authorization", "token $TOKEN")
put.setRequestProperty("Content-Type", "application/json")
put.connectTimeout = 10_000
put.readTimeout = 10_000
put.outputStream.use { it.write(body.toString().toByteArray()) }
val code = put.responseCode
Log.i(TAG, "stats $callsign launches=$launches http=$code")
code in 200..299
} finally {
put.disconnect()
}
}
}

View File

@@ -15,6 +15,9 @@ class SvxApp : Application() {
override fun onCreate() {
super.onCreate()
// Процесс только что создан — значит приложение было выгружено и
// запускается заново (а не выходит из фона/по иконке).
coldStart = true
createPushChannel()
initRuStorePush()
}
@@ -59,5 +62,9 @@ class SvxApp : Application() {
private const val TAG = "RuStorePush"
private const val RUSTORE_PROJECT_ID = "Yd7tDjVDHqsnTJwHNYN08cYIvy8fsWU-"
private const val PUSH_TOPIC = "svxhomyak"
/** true, если процесс создан заново (приложение было выгружено). */
@Volatile
var coldStart = false
}
}

View File

@@ -85,6 +85,15 @@ class ReflectorClient(
@Volatile
private var fatal = false
/**
* Была ли хотя бы одна успешная сессия за текущее подключение.
* Если да — креды уже проверены, поэтому ошибки на рукопожатии считаем
* временными (сервер мог ещё держать старую сессию после обрыва) и
* продолжаем автопереподключение вместо показа «ошибки авторизации».
*/
@Volatile
private var everReady = false
@Volatile
private var tcpSocket: Socket? = null
private var udpSocket: DatagramSocket? = null
@@ -107,6 +116,7 @@ class ReflectorClient(
this.settings = settings
manualDisconnect.set(false)
fatal = false
everReady = false
if (running.get()) {
// Перезапуск с новыми настройками (например, смена профиля)
running.set(false)
@@ -254,7 +264,10 @@ class ReflectorClient(
callbacks.onState(STATE_TCP_CONNECTED)
startTcpSenderThread(socket, s)
runTcpLoop(socket, s)
true
// runTcpLoop завершилась: если всё ещё должны работать — значит
// соединение разорвано/отклонено, вернём false, чтобы reconnectLoop
// выдержал RECONNECT_DELAY_MS и не бомбил сервер переподключениями.
!running.get()
} catch (e: Exception) {
try {
socket.close()
@@ -399,6 +412,7 @@ class ReflectorClient(
serverCodecs = info.codecs
callbacks.onServerInfo(info)
state = STATE_READY
everReady = true
callbacks.onState(STATE_READY)
// Сначала сообщаем о себе (версия ПО для дашборда), затем
// выбираем TG для передачи и подписываемся на прослушивание
@@ -441,9 +455,17 @@ class ReflectorClient(
ReflectorProtocol.MSG_ERROR -> {
val err = MsgParser.errorMessage(msg.fields)
Log.w(TAG, "MSG_ERROR: $err")
Log.w(TAG, "MSG_ERROR: $err (state=$state, everReady=$everReady)")
if (state < STATE_READY) {
if (everReady) {
// Креды проверены ранее: ошибка на рукопожатии почти
// всегда временная (сервер ещё держит старую сессию
// после короткого обрыва) — переподключаемся заново.
callbacks.onState(STATE_ERROR, err.ifEmpty { "Переподключение" })
abortSockets()
} else {
stopFatal(err.ifEmpty { "Access denied" })
}
return false
}
callbacks.onError(err)
@@ -452,7 +474,13 @@ class ReflectorClient(
else -> Unit // неизвестные сообщения игнорируются
}
} catch (e: Exception) {
if (everReady) {
// Сбой после успешного подключения — не фатально, переподключаемся.
callbacks.onState(STATE_ERROR, "Ошибка протокола: ${e.message}")
abortSockets()
} else {
stopFatal("Ошибка протокола: ${e.message}")
}
return false
}
return true
@@ -478,7 +506,12 @@ class ReflectorClient(
}
private fun protocolError(detail: String): Boolean {
if (everReady) {
callbacks.onState(STATE_ERROR, detail)
abortSockets()
} else {
stopFatal(detail)
}
return false
}