Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c9b8d15e | ||
|
|
4bcb04a655 | ||
|
|
82d249e697 | ||
|
|
bc12959147 | ||
|
|
28330dd044 | ||
|
|
bdf3a529a5 | ||
|
|
28bc857788 | ||
|
|
d52382ca5b | ||
|
|
db81f6a51e | ||
|
|
27eab81303 | ||
|
|
7c8aa2fdb2 | ||
|
|
f4440ea6b4 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
147
README.md
@@ -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`.
|
||||||
@@ -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 {
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -1,9 +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.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"
|
||||||
@@ -11,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"
|
||||||
@@ -27,6 +47,12 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|uiMode"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|uiMode"
|
||||||
android:theme="@style/Theme.APRS.Main" />
|
android:theme="@style/Theme.APRS.Main" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name=".UpdateFileProvider"
|
||||||
|
android:authorities="ru.ua1zbe.aprsmap.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
60
app/src/main/java/ru/ua1zbe/aprsmap/App.kt
Normal file
60
app/src/main/java/ru/ua1zbe/aprsmap/App.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,48 @@
|
|||||||
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.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
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
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.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
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.LinearLayout
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MainActivity : Activity() {
|
class MainActivity : Activity() {
|
||||||
|
|
||||||
private lateinit var webView: WebView
|
private lateinit var webView: WebView
|
||||||
private lateinit var progress: ProgressBar
|
private lateinit var progress: ProgressBar
|
||||||
|
|
||||||
|
private var pendingUpdate: UpdateChecker.UpdateInfo? = null
|
||||||
|
private var updateDialog: AlertDialog? = null
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
@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))
|
||||||
@@ -54,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() {
|
||||||
@@ -86,9 +115,141 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
finish()
|
finish()
|
||||||
@@ -98,4 +259,39 @@ class MainActivity : Activity() {
|
|||||||
webView.destroy()
|
webView.destroy()
|
||||||
super.onDestroy()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
55
app/src/main/java/ru/ua1zbe/aprsmap/PushMessagingService.kt
Normal file
55
app/src/main/java/ru/ua1zbe/aprsmap/PushMessagingService.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
77
app/src/main/java/ru/ua1zbe/aprsmap/UpdateChecker.kt
Normal file
77
app/src/main/java/ru/ua1zbe/aprsmap/UpdateChecker.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/src/main/java/ru/ua1zbe/aprsmap/UpdateFileProvider.kt
Normal file
34
app/src/main/java/ru/ua1zbe/aprsmap/UpdateFileProvider.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -2,4 +2,12 @@
|
|||||||
<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&zoom=8</string>
|
<string name="map_url">https://aprs.radiorubka.org/?center=67.5096,32.8271&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>
|
</resources>
|
||||||
@@ -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
|
||||||
@@ -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
37
tools/push.sh
Executable 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; }
|
||||||
Reference in New Issue
Block a user