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

2
.gitignore vendored
View File

@@ -12,3 +12,5 @@ captures/
*.aab *.aab
keystore.properties keystore.properties
*.jks *.jks
tools/.push.secrets
tools/push_all.sh

147
README.md
View File

@@ -1,27 +1,148 @@
# APRS RadioRubka # 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 ```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) и выше - `keystore.properties` — подпись релиза (файл в корне проекта):
- Разрешения: только INTERNET ```
storeFile=/полный/путь/aprsmap-release.jks
storePassword=...
keyAlias=aprsmap
keyPassword=...
```
- `aprsmap-release.jks` — файл ключа (`.gitignore`).
## Ссылки Отпечаток подписи (SHA-256) релиза. Получение:
- Gitea: http://git2.ua1zbe.ru/ua1zbe/aprsmap ```bash
- Карта: https://aprs.radiorubka.org 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" applicationId = "ru.ua1zbe.aprsmap"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 5
versionName = "1.0.0" versionName = "1.0.4"
} }
signingConfigs { signingConfigs {
@@ -56,3 +56,7 @@ kotlin {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) 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"?> <?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.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".App"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -12,6 +15,22 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.APRS.Main"> 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 <activity
android:name=".SplashActivity" android:name=".SplashActivity"
android:exported="true" 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 package ru.ua1zbe.aprsmap
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.graphics.PorterDuff import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter import android.graphics.PorterDuffColorFilter
@@ -16,6 +18,7 @@ import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.Gravity import android.view.Gravity
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
@@ -39,6 +42,7 @@ class MainActivity : Activity() {
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val root = FrameLayout(this).apply { val root = FrameLayout(this).apply {
setBackgroundColor(Color.rgb(6, 11, 20)) setBackgroundColor(Color.rgb(6, 11, 20))
@@ -70,6 +74,15 @@ class MainActivity : Activity() {
} }
return true 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() { webChromeClient = object : WebChromeClient() {
@@ -102,10 +115,24 @@ class MainActivity : Activity() {
root.addView(progress) root.addView(progress)
setContentView(root) setContentView(root)
requestNotificationPermission()
webView.loadUrl(getString(R.string.map_url)) webView.loadUrl(getString(R.string.map_url))
checkForUpdates() 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() { private fun checkForUpdates() {
Thread { Thread {
val version = try { val version = try {
@@ -235,5 +262,36 @@ class MainActivity : Activity() {
companion object { companion object {
private const val TAG = "APRSmap" 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
setContentView(SplashView(this)) setContentView(SplashView(this))

View File

@@ -2,6 +2,8 @@
<resources> <resources>
<string name="app_name">APRS RadioRubka</string> <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="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_title">Доступно обновление</string>
<string name="update_available_msg">Версия %1$s уже вышла. Обновить приложение?</string> <string name="update_available_msg">Версия %1$s уже вышла. Обновить приложение?</string>
<string name="update_btn">Обновить</string> <string name="update_btn">Обновить</string>

View File

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

View File

@@ -11,6 +11,7 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() 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; }