11 Commits
v1.0.1 ... main

Author SHA1 Message Date
ua1zbe
f1c9b8d15e Bump 1.0.4: card layout fix, remove web debugging 2026-10-09 11:38:35 +03:00
ua1zbe
4bcb04a655 Fix station detail card (modal) collapsing to a narrow column in WebView 2026-10-09 11:32:16 +03:00
ua1zbe
82d249e697 Gitignore single-file push script with embedded secrets 2026-09-23 10:06:24 +03:00
ua1zbe
bc12959147 Improve broadcast push script 2026-09-23 09:42:43 +03:00
ua1zbe
28330dd044 Add README with build, release and push instructions 2026-09-23 09:30:48 +03:00
ua1zbe
bdf3a529a5 Bump 1.0.3: enable RuStore Push (project_id), release candidate 2026-09-23 09:27:01 +03:00
ua1zbe
28bc857788 Add RuStore broadcast push script (secrets gitignored) 2026-09-23 09:22:09 +03:00
ua1zbe
d52382ca5b Add RuStore Push integration (project_id placeholder) 2026-09-23 09:17:43 +03:00
ua1zbe
db81f6a51e Bump version to 1.0.2 2026-09-23 09:09:51 +03:00
ua1zbe
27eab81303 Keep screen on while app is active 2026-09-23 09:06:34 +03:00
ua1zbe
7c8aa2fdb2 Bump version to 1.0.1 2026-09-23 08:58:38 +03:00
12 changed files with 378 additions and 18 deletions

4
.gitignore vendored
View File

@@ -11,4 +11,6 @@ captures/
*.apk
*.aab
keystore.properties
*.jks
*.jks
tools/.push.secrets
tools/push_all.sh

147
README.md
View File

@@ -1,27 +1,148 @@
# APRS RadioRubka
Android-приложение на Kotlin: анимированная заставка «APRS» и карта APRS в WebView.
Android-приложение: анимированная заставка «APRS» → веб-карта APRS и
пуш-уведомления (RuStore Push). Кнопка «назад» закрывает приложение, экран не
гаснет при активном приложении.
## Функции
- Пакет: `ru.ua1zbe.aprsmap`
- Карта: https://aprs.radiorubka.org/?center=67.5096,32.8271&zoom=8
(задаётся строкой `map_url` в `app/src/main/res/values/strings.xml`)
- Репозиторий: https://git2.ua1zbe.ru/ua1zbe/aprsmap
- Анимированная заставка при старте (градиент, волна букв APRS, прогресс-бар).
- Карта `https://aprs.radiorubka.org` открывается внутри приложения (WebView, JS, zoom).
- Кнопка «Назад» закрывает приложение.
## Стек сборки
- Gradle wrapper 9.2.0, AGP 8.13.2, Kotlin 2.2.21, JDK 25
- compileSdk 34, targetSdk 34, minSdk 26
> Не использовать системный gradle 4.4.1 — только `./gradlew`.
## Сборка
```bash
./gradlew assembleDebug
./gradlew :app:assembleDebug # отладочный APK
./gradlew :app:assembleRelease # релизный APK (подписан)
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
Релизный APK: `app/build/outputs/apk/release/app-release.apk`.
## Требования
Перед релизной сборкой нужны (в git НЕ хранятся):
- Android 8.0 (API 26) и выше
- Разрешения: только INTERNET
- `keystore.properties` — подпись релиза (файл в корне проекта):
```
storeFile=/полный/путь/aprsmap-release.jks
storePassword=...
keyAlias=aprsmap
keyPassword=...
```
- `aprsmap-release.jks` — файл ключа (`.gitignore`).
## Ссылки
Отпечаток подписи (SHA-256) релиза. Получение:
- Gitea: http://git2.ua1zbe.ru/ua1zbe/aprsmap
- Карта: https://aprs.radiorubka.org
```bash
keytool -list -v -keystore aprsmap-release.jks -alias aprsmap -storepass <пароль> | grep SHA256
# или
$ANDROID_HOME/build-tools/35.0.0/apksigner verify --print-certs app-release.apk
```
## Версионирование
`app/build.gradle.kts`:
```kotlin
versionCode = 4 // инкремент для каждого релиза
versionName = "1.0.3"
```
## Установка на телефон (adb)
```bash
$ANDROID_HOME/platform-tools/adb install -r app/build/outputs/apk/release/app-release.apk
$ANDROID_HOME/platform-tools/adb shell am start -n ru.ua1zbe.aprsmap/.SplashActivity
```
Запуск только через `.SplashActivity` (MainActivity не exported).
Google Play Protect может блокировать sideload — выключите усиленную защиту.
## Выпуск релиза на Gitea
1. Собрать `assembleRelease`.
2. Токен API Gitea — в `/tmp/gitea_token.txt` (`Authorization: token`).
3. Создать релиз и загрузить APK (см. пример ниже). Имена тегов: `v1.0.0`, `v1.0.1`, ...
```bash
TOKEN=$(cat /tmp/gitea_token.txt)
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d '{"tag_name":"v1.0.3","target_commitish":"main","name":"v1.0.3","body":"...","draft":false,"prerelease":false}' \
"https://git2.ua1zbe.ru/api/v1/repos/ua1zbe/aprsmap/releases"
# затем прикрепить APK, взяв id релиза:
curl -s -H "Authorization: token $TOKEN" \
-F "attachment=@app/build/outputs/apk/release/app-release.apk" \
"https://git2.ua1zbe.ru/api/v1/repos/ua1zbe/aprsmap/releases/<id>/assets?name=APRS_RadioRubka_v1.0.3.apk"
```
Сверка обновлений идёт по интерфейсу
`https://git2.ua1zbe.ru/api/v1/repos/ua1zbe/aprsmap/releases/latest`
— поэтому репозиторий публичный.
## Автообновление
- При старте `MainActivity.checkForUpdates()` сравнивает `versionName` с последним
релизом Gitea; если есть новее — диалог «Обновить».
- APK скачивается в `cacheDir/updates/`, установка через
`ContentProvider` `UpdateFileProvider` (authority `ru.ua1zbe.aprsmap.fileprovider`)
+ `ACTION_VIEW`, при необходимости запрос «Разрешить установку из этого источника».
- `tools/push.sh` — служебное, рассылка пушей (см. ниже).
## Пуш-уведомления (RuStore Push)
Актуально с v1.0.3. SDK `ru.rustore.sdk:pushclient:7.4.0`, топик рассылки —
**`aprsmap`**.
- Параметры в `app/src/main/java/ru/ua1zbe/aprsmap/App.kt`:
`RUSTORE_PROJECT_ID` (project id из консоли RuStore),
`PUSH_TOPIC`. При старте: `RuStorePushClient.init` → `getToken` →
`subscribeToTopic("aprsmap")`.
- `PushMessagingService` получает `onNewToken`/`onMessageReceived`; уведомления с
секцией `notification` показывает сам SDK, `data` с ключами `title`/`body` —
приложение. Канал `aprs_push`.
- Токен устройства пишется в `shared_prefs/aprsmap.xml` (`push_token`) и виден в
logcat `RuStorePush:`.
### Секреты (в git НЕ хранить)
`tools/.push.secrets` (в `.gitignore`):
```
SERVICE_TOKEN="..."
PROJECT_ID="..."
```
SDK на устройстве требует установленный и авторизованный RuStore.
### Рассылка всем подписчикам топика
```bash
./tools/push.sh "Заголовок" "Текст"
```
Реализация: `POST https://vkpns-topics.rustore.ru/v1/projects/{PROJECT_ID}/topics/aprsmap/publish`
с `Authorization: Bearer {SERVICE_TOKEN}`. Ответ
`{"message":"payload has been successfully published to topic aprsmap"}` — успех.
### Точечная отправка на устройство
```bash
curl -s -X POST \
"https://vkpns.rustore.ru/v1/projects/${PROJECT_ID}/messages:send" \
-H "Authorization: Bearer ${SERVICE_TOKEN}" -H "Content-Type: application/json" \
-d '{"message":{"token":"<push_token устройства>","notification":{"title":"...","body":"..."}}}'
```
### Проверка на устройстве
```bash
adb logcat -d | grep -E "RuStorePush:"
adb shell dumpsys notification | grep -E "aprs_push|aprsmap"
```
Ожидаемое: `push token: ...`, `subscribed to aprsmap`, `push displayed by SDK`.

View File

@@ -18,8 +18,8 @@ android {
applicationId = "ru.ua1zbe.aprsmap"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
versionCode = 5
versionName = "1.0.4"
}
signingConfigs {
@@ -55,4 +55,8 @@ kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
implementation("ru.rustore.sdk:pushclient:7.4.0")
}

View File

@@ -1,10 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -12,6 +15,22 @@
android:supportsRtl="true"
android:theme="@style/Theme.APRS.Main">
<service
android:name=".PushMessagingService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="ru.rustore.sdk.pushclient.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="ru.rustore.sdk.pushclient.default_notification_icon"
android:resource="@drawable/ic_launcher_foreground" />
<meta-data
android:name="ru.rustore.sdk.pushclient.default_notification_channel_id"
android:value="@string/push_channel_id" />
<activity
android:name=".SplashActivity"
android:exported="true"

View File

@@ -0,0 +1,60 @@
package ru.ua1zbe.aprsmap
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import ru.rustore.sdk.pushclient.RuStorePushClient
class App : Application() {
override fun onCreate() {
super.onCreate()
createPushChannel()
initRuStorePush()
}
private fun createPushChannel() {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
PushMessagingService.CHANNEL_ID,
getString(R.string.push_channel_name),
NotificationManager.IMPORTANCE_HIGH
)
)
}
private fun initRuStorePush() {
if (RUSTORE_PROJECT_ID.isBlank()) {
Log.w(TAG, "RuStore project_id not set yet, init skipped")
return
}
try {
RuStorePushClient.init(application = this, projectId = RUSTORE_PROJECT_ID)
RuStorePushClient.getToken()
.addOnSuccessListener { token ->
Log.i(TAG, "push token: $token")
saveToken(token)
RuStorePushClient.subscribeToTopic(PUSH_TOPIC)
.addOnSuccessListener { Log.i(TAG, "subscribed to $PUSH_TOPIC") }
.addOnFailureListener { e -> Log.w(TAG, "subscribe failed", e) }
}
.addOnFailureListener { e -> Log.w(TAG, "getToken failed", e) }
} catch (e: Exception) {
Log.w(TAG, "init failed", e)
}
}
private fun saveToken(token: String) {
getSharedPreferences(PREFS, MODE_PRIVATE)
.edit().putString("push_token", token).apply()
}
companion object {
private const val TAG = "RuStorePush"
private const val RUSTORE_PROJECT_ID = "IiZJx8LXrFuVclMLbX08nfrShS9WWB6k"
private const val PUSH_TOPIC = "aprsmap"
const val PREFS = "aprsmap"
}
}

View File

@@ -1,9 +1,11 @@
package ru.ua1zbe.aprsmap
import android.annotation.SuppressLint
import android.Manifest
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
@@ -16,6 +18,7 @@ import android.provider.Settings
import android.util.Log
import android.view.Gravity
import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
@@ -39,6 +42,7 @@ class MainActivity : Activity() {
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val root = FrameLayout(this).apply {
setBackgroundColor(Color.rgb(6, 11, 20))
@@ -70,6 +74,15 @@ class MainActivity : Activity() {
}
return true
}
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
view.evaluateJavascript(
"document.head.insertAdjacentHTML('beforeend','<style id=\"aprs-fix\">' + " +
org.json.JSONObject.quote(FIX_CSS) + " + '</style>')",
null
)
}
}
webChromeClient = object : WebChromeClient() {
@@ -102,10 +115,24 @@ class MainActivity : Activity() {
root.addView(progress)
setContentView(root)
requestNotificationPermission()
webView.loadUrl(getString(R.string.map_url))
checkForUpdates()
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQ_NOTIFICATIONS
)
}
}
private fun checkForUpdates() {
Thread {
val version = try {
@@ -235,5 +262,36 @@ class MainActivity : Activity() {
companion object {
private const val TAG = "APRSmap"
private const val REQ_NOTIFICATIONS = 100
private val FIX_CSS = """
.leaflet-popup-content-wrapper {
max-width: 88vw !important;
box-sizing: border-box;
}
.leaflet-infowindow-content .leaflet-popup-content-wrapper {
width: min(88vw, 440px) !important;
}
.leaflet-infowindow-content .leaflet-popup-content {
width: 100% !important;
box-sizing: border-box;
}
.leaflet-popup-content p {
margin: 8px 0;
}
.leaflet-popup-content img {
max-width: 100% !important;
height: auto !important;
}
.modal-content-body, .td-modal-content, .modal-inner-content {
width: 100% !important;
min-width: 0 !important;
box-sizing: border-box !important;
}
.overview-content-summary, .overview-content-station-list {
max-width: 100% !important;
box-sizing: border-box !important;
}
""".trimIndent()
}
}

View File

@@ -0,0 +1,55 @@
package ru.ua1zbe.aprsmap
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import ru.rustore.sdk.pushclient.messaging.exception.RuStorePushClientException
import ru.rustore.sdk.pushclient.messaging.model.RemoteMessage
import ru.rustore.sdk.pushclient.messaging.service.RuStoreMessagingService
class PushMessagingService : RuStoreMessagingService() {
override fun onNewToken(token: String) {
Log.i(TAG, "onNewToken: $token")
getSharedPreferences(App.PREFS, Context.MODE_PRIVATE)
.edit().putString("push_token", token).apply()
}
override fun onMessageReceived(message: RemoteMessage) {
if (message.notification != null) {
Log.i(TAG, "push displayed by SDK: ${message.data}")
return
}
val title = message.data["title"] ?: getString(R.string.app_name)
val body = message.data["body"] ?: ""
if (title.isNotEmpty() || body.isNotEmpty()) {
showPush(title, body)
}
}
override fun onDeletedMessages() {
Log.i(TAG, "onDeletedMessages")
}
override fun onError(errors: List<RuStorePushClientException>) {
errors.forEach { Log.w(TAG, "error", it) }
}
private fun showPush(title: String, body: String) {
val nm = getSystemService(NotificationManager::class.java)
val n = Notification.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setAutoCancel(true)
.build()
nm.notify(NOTIFICATION_ID, n)
}
companion object {
private const val TAG = "RuStorePush"
const val CHANNEL_ID = "aprs_push"
private const val NOTIFICATION_ID = 2
}
}

View File

@@ -11,6 +11,7 @@ class SplashActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
setContentView(SplashView(this))

View File

@@ -2,6 +2,8 @@
<resources>
<string name="app_name">APRS RadioRubka</string>
<string name="map_url">https://aprs.radiorubka.org/?center=67.5096,32.8271&amp;zoom=8</string>
<string name="push_channel_id">aprs_push</string>
<string name="push_channel_name">Пуш-уведомления</string>
<string name="update_available_title">Доступно обновление</string>
<string name="update_available_msg">Версия %1$s уже вышла. Обновить приложение?</string>
<string name="update_btn">Обновить</string>

View File

@@ -6,4 +6,4 @@ org.gradle.parallel=false
org.gradle.caching=false
kotlin.compiler.execution.strategy=in-process
android.nonTransitiveRClass=true
android.useAndroidX=false
android.useAndroidX=true

View File

@@ -11,6 +11,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://nexus-external.rustore.ru/repository/maven-rustore-exposed/")
}
}

37
tools/push.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Рассылка пуш-уведомления ВСЕМ подписчикам топика "aprsmap" (RuStore Push).
# Топик = все устройства, где установлено приложение APRS RadioRubka
# и оно хотя бы раз запускалось (подписка на клиенте выполняется сама).
#
# Использование:
# ./push.sh "Заголовок" "Текст"
#
# Пример для крон/автоматики:
# */5 * * * * /home/ua1zbe/aprsmap/tools/push.sh "Новость" "$(cat /tmp/news.txt)"
#
# Project id и service token читаются из tools/.push.secrets (в git не хранится)
# либо из переменных окружения PUSH_SECRETS / SERVICE_TOKEN / PROJECT_ID.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SECRETS="${PUSH_SECRETS:-$DIR/.push.secrets}"
[ -f "$SECRETS" ] && . "$SECRETS"
SERVICE_TOKEN="${SERVICE_TOKEN:-${SSP:-}}"
PROJECT_ID="${PROJECT_ID:-}"
[ -n "$SERVICE_TOKEN" ] || { echo "Ошибка: SERVICE_TOKEN не задан" >&2; exit 1; }
[ -n "$PROJECT_ID" ] || { echo "Ошибка: PROJECT_ID не задан" >&2; exit 1; }
title="${1:?Использование: $0 \"Заголовок\" \"Текст\"}"
body="${2:-}"
resp=$(curl -s -X POST \
"https://vkpns-topics.rustore.ru/v1/projects/${PROJECT_ID}/topics/aprsmap/publish" \
-H "Authorization: Bearer ${SERVICE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"message\":{\"notification\":{\"title\":\"${title}\",\"body\":\"${body}\"}}}")
echo "$resp" | grep -q "successfully published" && {
echo "OK: пуш отправлен всем подписчикам топика aprsmap"
} || { echo "Ошибка отправки: $resp" >&2; exit 1; }