9 Commits
v1.0.2 ... main

11 changed files with 375 additions and 18 deletions

4
.gitignore vendored
View File

@@ -11,4 +11,6 @@ captures/
*.apk *.apk
*.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 = 2 versionCode = 5
versionName = "1.0.1" versionName = "1.0.4"
} }
signingConfigs { signingConfigs {
@@ -55,4 +55,8 @@ kotlin {
compilerOptions { compilerOptions {
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
@@ -72,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() {
@@ -104,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 {
@@ -237,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

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