12 Commits
v1.0.0 ... 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
ua1zbe
f4440ea6b4 Auto-update via Gitea releases (as in svxhomyak) 2026-09-23 08:56:09 +03:00
14 changed files with 640 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,9 +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"
@@ -11,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"
@@ -27,6 +47,12 @@
android:exported="false"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|uiMode"
android:theme="@style/Theme.APRS.Main" />
<provider
android:name=".UpdateFileProvider"
android:authorities="ru.ua1zbe.aprsmap.fileprovider"
android:exported="false"
android:grantUriPermissions="true" />
</application>
</manifest>

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,28 +1,48 @@
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
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
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
import android.webkit.WebViewClient
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import java.io.File
class MainActivity : Activity() {
private lateinit var webView: WebView
private lateinit var progress: ProgressBar
private var pendingUpdate: UpdateChecker.UpdateInfo? = null
private var updateDialog: AlertDialog? = null
private val mainHandler = Handler(Looper.getMainLooper())
@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))
@@ -54,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() {
@@ -86,9 +115,141 @@ 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 {
packageManager.getPackageInfo(packageName, 0).versionName
} catch (_: Exception) {
return@Thread
}
val info = try {
UpdateChecker.check(version)
} catch (e: Exception) {
Log.w(TAG, "update check failed: ${e.message}")
null
}
if (info != null) {
mainHandler.post { showUpdateDialog(info) }
} else {
Log.d(TAG, "update: no newer version")
}
}.start()
}
private fun showUpdateDialog(info: UpdateChecker.UpdateInfo) {
pendingUpdate = info
updateDialog = AlertDialog.Builder(this)
.setTitle(R.string.update_available_title)
.setMessage(getString(R.string.update_available_msg, info.version))
.setPositiveButton(R.string.update_btn) { _, _ -> startUpdate() }
.setNegativeButton(R.string.update_later) { _, _ ->
pendingUpdate = null
updateDialog = null
}
.setOnCancelListener {
pendingUpdate = null
updateDialog = null
}
.show()
}
private fun canInstall(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls()
private fun startUpdate() {
val info = pendingUpdate ?: return
if (!canInstall()) {
startActivity(
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:$packageName")
)
)
return
}
val bar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal)
bar.max = 100
val label = TextView(this)
label.setText(R.string.update_downloading)
label.setPadding(dp(20), dp(16), dp(20), 0)
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(label)
addView(
bar,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = dp(12) }
)
}
updateDialog = AlertDialog.Builder(this)
.setTitle(R.string.update_available_title)
.setView(content)
.setCancelable(false)
.show()
val file = File(cacheDir, "updates/aprsmap-${info.version}.apk")
file.parentFile?.mkdirs()
Thread {
try {
UpdateChecker.download(info.apkUrl, file) { p ->
mainHandler.post { bar.progress = (p * 100).toInt() }
}
mainHandler.post {
updateDialog?.dismiss()
updateDialog = null
pendingUpdate = null
installApk(file)
}
} catch (e: Exception) {
mainHandler.post {
updateDialog?.dismiss()
updateDialog = null
pendingUpdate = null
toast(getString(R.string.update_failed, e.message))
}
}
}.start()
}
private fun installApk(file: File) {
val uri = Uri.Builder()
.scheme("content")
.authority("$packageName.fileprovider")
.path("updates/${file.name}")
.build()
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(intent)
}
private fun toast(msg: String) {
runOnUiThread { Toast.makeText(this, msg, Toast.LENGTH_LONG).show() }
}
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
finish()
@@ -98,4 +259,39 @@ class MainActivity : Activity() {
webView.destroy()
super.onDestroy()
}
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

@@ -0,0 +1,77 @@
package ru.ua1zbe.aprsmap
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/** Проверка и загрузка обновлений через API Gitea. */
object UpdateChecker {
data class UpdateInfo(val version: String, val apkUrl: String)
private const val API_URL =
"https://git2.ua1zbe.ru/api/v1/repos/ua1zbe/aprsmap/releases/latest"
/** Вернуть информацию об обновлении, если доступна более новая версия. */
fun check(currentVersion: String): UpdateInfo? {
val conn = URL(API_URL).openConnection() as HttpURLConnection
conn.connectTimeout = 10_000
conn.readTimeout = 10_000
return try {
if (conn.responseCode != 200) return null
val json = JSONObject(conn.inputStream.bufferedReader().readText())
val version = json.optString("tag_name", "").removePrefix("v")
if (!isNewer(version, currentVersion)) return null
val assets = json.optJSONArray("assets") ?: JSONArray()
var url: String? = null
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
if (a.optString("name").endsWith(".apk")) {
url = a.optString("browser_download_url")
break
}
}
url?.let { UpdateInfo(version, it) }
} finally {
conn.disconnect()
}
}
/** Скачать APK в файл, сообщая прогресс 0..1. */
fun download(url: String, target: File, onProgress: (Float) -> Unit) {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
try {
val total = conn.contentLengthLong
var done = 0L
conn.inputStream.use { input ->
target.outputStream().use { out ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
done += n
if (total > 0) onProgress(done.toFloat() / total)
}
}
}
} finally {
conn.disconnect()
}
}
private fun isNewer(remote: String, current: String): Boolean {
val r = remote.split('.').mapNotNull { it.toIntOrNull() }
val c = current.split('.').mapNotNull { it.toIntOrNull() }
for (i in 0 until maxOf(r.size, c.size)) {
val a = r.getOrElse(i) { 0 }
val b = c.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false
}
}

View File

@@ -0,0 +1,34 @@
package ru.ua1zbe.aprsmap
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.File
/** Минимальный FileProvider для установки APK (без androidx). */
class UpdateFileProvider : ContentProvider() {
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
val name = uri.lastPathSegment
?: throw IllegalArgumentException("bad uri")
if (name != name.substringAfterLast('/')) {
throw IllegalArgumentException("bad uri")
}
val path = File(context?.cacheDir, "updates/$name")
return ParcelFileDescriptor.open(path, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun getType(uri: Uri): String = "application/vnd.android.package-archive"
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun query(
uri: Uri, projection: Array<out String>?, selection: String?,
selectionArgs: Array<out String>?, sortOrder: String?
): Cursor? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
}

View File

@@ -2,4 +2,12 @@
<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>
<string name="update_later">Позже</string>
<string name="update_downloading">Загрузка обновления…</string>
<string name="update_failed">Не удалось загрузить обновление: %1$s</string>
</resources>

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