13 Commits
v2.6 ... v2.6.2

Author SHA1 Message Date
83fc10878b README: раздел про RuStore Push и структура классов пуша 2026-09-28 12:19:43 +00:00
576f794ff7 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:42 +00:00
bd1db2a468 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:41 +00:00
905214b49e Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:40 +00:00
74d3f395ce Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:39 +00:00
034e7e1e27 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:38 +00:00
09bffa78a3 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:37 +00:00
f0088b08b6 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:36 +00:00
91a43a37b4 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:35 +00:00
4a2aa59c77 Пуш-уведомления RuStore в apk-webview: SDK, сервис приёма, канал 2026-09-28 12:19:33 +00:00
3aab0b826b Версия 2.6.1
Сборка соответствует опубликованному коду: тег v2.6 остался на более
раннем коммите, где карточки ближайших рейсов ещё зависели от выбора
дня. Исправление (карточки всегда на сегодня) было закоммичено позже,
и тег на него не указывал. Тег v2.6.1 поставлен на актуальный код.

Само поведение не менялось — это та же исправленная версия, что была
выложена как 2.6, но теперь исходники, тег и APK соответствуют
друг другу.
2026-09-28 10:53:02 +00:00
6e03bc997c Кнопки дней влияют только на полное расписание
Карточки ближайших рейсов больше не зависят от выбора дня в полном
расписании: они всегда показывают рейсы на текущий день недели по
московскому времени. Кнопки «Будни / Суббота / Воскресенье»
переключают только таблицу.

День недели для карточек вычисляется заново при каждой отрисовке,
так что после полуночи приложение само переходит на расписание
нового дня, не сбрасывая выбор в таблице.

В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе: кнопки дней в полном расписании,
убрана надпись «Мурманская область», экран не гаснет.

versionCode поднят с 8 до 9, чтобы обновление поверх уже
установленной сборки 2.6 ставилось поверх неё.
2026-09-28 10:46:16 +00:00
f04bb1843c Кнопки дней влияют только на полное расписание
Карточки ближайших рейсов больше не зависят от выбора дня в полном
расписании: они всегда показывают рейсы на текущий день недели по
московскому времени. Кнопки «Будни / Суббота / Воскресенье»
переключают только таблицу.

День недели для карточек вычисляется заново при каждой отрисовке,
так что после полуночи приложение само переходит на расписание
нового дня, не сбрасывая выбор в таблице.

В шапке приложения убрана надпись «Мурманская область» вместе с её
стилем .route, который больше не используется. Заголовок
«Автобус №127» теперь начинает шапку.

В MainActivity добавлен флаг FLAG_KEEP_SCREEN_ON: экран телефона
не гаснет, пока открыто приложение с расписанием — не нужно
касаться экрана, чтобы посмотреть время отправления.

Также в этом же релизе: кнопки дней в полном расписании,
убрана надпись «Мурманская область», экран не гаснет.

versionCode поднят с 8 до 9, чтобы обновление поверх уже
установленной сборки 2.6 ставилось поверх неё.
2026-09-28 10:46:15 +00:00
12 changed files with 291 additions and 15 deletions

View File

@@ -12,6 +12,7 @@
- **Автоопределение дня недели** — будни/выходные. - **Автоопределение дня недели** — будни/выходные.
- **Полное расписание** — по кнопке раскрывается вся таблица - **Полное расписание** — по кнопке раскрывается вся таблица
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша). (4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
- **Пуш-уведомления** — через RuStore Push, см. раздел ниже.
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин). - Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
## Технические данные ## Технические данные
@@ -24,6 +25,42 @@
| Сборка | Gradle 8.6 + AGP 8.2.2 | | Сборка | Gradle 8.6 + AGP 8.2.2 |
| Подпись | keystore `~/bus127.keystore` (alias `bus127`) | | Подпись | keystore `~/bus127.keystore` (alias `bus127`) |
## Пуш-уведомления (RuStore)
Приложение интегрировано с [RuStore Push SDK](https://www.rustore.ru/help/sdk/push-notifications)
(аналог Firebase Cloud Messaging). Проект в консоли RuStore привязан к пакету
`ru.ua1zbe.bus127` и подписи release-keystore; пока подпись не меняется, перерегистрация не нужна.
Для доставки на устройстве должен быть установлен и авторизован **RuStore**.
Рассылка выполняется с сервера через API RuStore:
```
POST https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
Authorization: Bearer {service-token}
Content-Type: application/json
{
"message": {
"token": "{push-token устройства}",
"notification": {
"title": "Автобус 127",
"body": "Ближайший рейс через 5 мин"
}
}
}
```
- `project_id` и `service-token` берутся в консоли RuStore: **Push-уведомления > Проекты**.
В приложение попадает только `project_id`; сервисный токен в APK не хранится.
- `push-token` устройства приходит в `onNewToken` (лог `RuStorePush`) и сохраняется
в `SharedPreferences` `bus127_push`.
- Приложение подписывается на топик `bus127` — топик должен существовать в консоли.
Сообщения без поля `topic` приходят всем устройствам проекта.
- Если в запросе есть секция `notification` — уведомление показывает сам SDK;
если переданы только `data` (`title`/`body`, необязательный `url`) — приложение
показывает его само и открывает ссылку по клику.
## Структура ## Структура
``` ```
@@ -32,7 +69,12 @@ apk-webview/ # исходники APK (WebView)
│ ├── build.gradle │ ├── build.gradle
│ └── src/main/ │ └── src/main/
│ ├── AndroidManifest.xml │ ├── AndroidManifest.xml
│ ├── java/ru/ua1zbe/bus127/MainActivity.java │ ├── java/ru/ua1zbe/bus127/
│ │ ├── MainActivity.java # WebView + запрос разрешения на уведомления
│ │ ├── Bus127App.java # инициализация RuStore Push SDK
│ │ ├── PushMessagingService.java # приём уведомлений
│ │ ├── PushIntents.java # переход по клику
│ │ └── push/PushStore.java # хранение push-токена
│ ├── assets/index.html # интерфейс (HTML/JS) │ ├── assets/index.html # интерфейс (HTML/JS)
│ └── res/values/strings.xml │ └── res/values/strings.xml
└── settings.gradle, build.gradle, gradle.properties └── settings.gradle, build.gradle, gradle.properties

View File

@@ -19,8 +19,8 @@ android {
applicationId "ru.ua1zbe.bus127" applicationId "ru.ua1zbe.bus127"
minSdk 26 minSdk 26
targetSdk 34 targetSdk 34
versionCode 8 versionCode 11
versionName "2.6" versionName "2.6.2"
} }
buildTypes { buildTypes {
@@ -35,3 +35,7 @@ android {
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
} }
dependencies {
implementation "ru.rustore.sdk:pushclient:7.4.0"
}

View File

@@ -1,13 +1,34 @@
<?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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".Bus127App"
android:label="@string/app_name" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.NoActionBar"> android:theme="@android:style/Theme.Material.NoActionBar">
<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="@mipmap/ic_launcher" />
<meta-data
android:name="ru.rustore.sdk.pushclient.default_notification_channel_id"
android:value="@string/push_channel_id" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -255,13 +255,17 @@ let currentStop = "Полярные Зори";
// Date#getDay(): 0 — воскресенье, 6 — суббота // Date#getDay(): 0 — воскресенье, 6 — суббота
function dayKeyFor(dow){ return dow===0 ? "sunday" : dow===6 ? "saturday" : "weekday"; } function dayKeyFor(dow){ return dow===0 ? "sunday" : dow===6 ? "saturday" : "weekday"; }
// Карточки ближайших рейсов всегда показывают реальный сегодняшний день
function todayKey(){ return dayKeyFor(nowMs().day); }
// Выбор пользователя — только для полного расписания
function sched(){ return SCHEDULES[activeDay]; } function sched(){ return SCHEDULES[activeDay]; }
function render(){ function render(){
const now = nowMs(); const now = nowMs();
const curMin = now.hour*60 + now.minute; const curMin = now.hour*60 + now.minute;
const dirs = DIRECTIONS[currentStop]; const dirs = DIRECTIONS[currentStop];
const data = sched(); const data = SCHEDULES[todayKey()];
document.getElementById("dirBox").innerHTML = dirs.map(d=>{ document.getElementById("dirBox").innerHTML = dirs.map(d=>{
const upcoming = data const upcoming = data
.map((r,ri)=>{ .map((r,ri)=>{
@@ -303,12 +307,12 @@ function renderDay(){
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`; document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
} }
// Подсветка выбранного типа дня и перерисовка расписания // Подсветка выбранного типа дня. Таблица полного расписания перерисовывается,
// карточки ближайших рейсов не трогаем — они всегда на сегодня.
function selectDay(key){ function selectDay(key){
activeDay = key; activeDay = key;
document.querySelectorAll("#dayPick button").forEach(b=> document.querySelectorAll("#dayPick button").forEach(b=>
b.classList.toggle("active", b.dataset.day === key)); b.classList.toggle("active", b.dataset.day === key));
render();
renderTable(); renderTable();
} }
@@ -342,9 +346,9 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
b.classList.toggle("open", !open); b.classList.toggle("open", !open);
}); });
let activeDay = dayKeyFor(nowMs().day); // по умолчанию — реальный день недели let activeDay = todayKey(); // по умолчанию — реальный день недели
selectDay(activeDay); selectDay(activeDay);
tickClock(); renderDay(); tickClock(); renderDay(); render();
setInterval(tickClock,1000); setInterval(tickClock,1000);
setInterval(render,30000); setInterval(render,30000);
setInterval(renderDay,60000); setInterval(renderDay,60000);

View File

@@ -0,0 +1,60 @@
package ru.ua1zbe.bus127;
import android.app.Application;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.util.Log;
import ru.rustore.sdk.pushclient.RuStorePushClient;
import ru.ua1zbe.bus127.push.PushStore;
/**
* Инициализация RuStore Push SDK.
*
* project_id и topic берутся из консоли RuStore (Push-уведомления > Проекты).
* Сервисный токен в приложении не хранится — он нужен только серверу
* для вызова https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
*/
public class Bus127App extends Application {
public static final String CHANNEL_ID = "bus127_push";
@Override
public void onCreate() {
super.onCreate();
createPushChannel();
initRuStorePush();
}
private void createPushChannel() {
NotificationManager nm = getSystemService(NotificationManager.class);
nm.createNotificationChannel(new NotificationChannel(
CHANNEL_ID,
getString(R.string.push_channel_name),
NotificationManager.IMPORTANCE_HIGH));
}
private void initRuStorePush() {
try {
RuStorePushClient.INSTANCE.init(this, RUSTORE_PROJECT_ID);
RuStorePushClient.INSTANCE.getToken()
.addOnSuccessListener(token -> {
Log.i(TAG, "push token: " + token);
PushStore.setToken(this, token);
RuStorePushClient.INSTANCE.subscribeToTopic(PUSH_TOPIC)
.addOnSuccessListener(v ->
Log.i(TAG, "subscribed to " + PUSH_TOPIC))
.addOnFailureListener(e ->
Log.w(TAG, "subscribe to " + PUSH_TOPIC + " failed", e));
})
.addOnFailureListener(e ->
Log.w(TAG, "getToken failed", e));
} catch (Exception e) {
Log.w(TAG, "init failed", e);
}
}
private static final String TAG = "RuStorePush";
private static final String RUSTORE_PROJECT_ID = "upSEOtZyXP96tskl4hf-442AqciCUTxs";
private static final String PUSH_TOPIC = "bus127";
}

View File

@@ -1,6 +1,9 @@
package ru.ua1zbe.bus127; package ru.ua1zbe.bus127;
import android.Manifest;
import android.app.Activity; import android.app.Activity;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.view.WindowManager; import android.view.WindowManager;
import android.webkit.WebSettings; import android.webkit.WebSettings;
@@ -13,6 +16,7 @@ public class MainActivity extends Activity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
// экран не гаснет, пока открыто расписание // экран не гаснет, пока открыто расписание
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
requestNotificationsPermission();
WebView webView = new WebView(this); WebView webView = new WebView(this);
WebSettings settings = webView.getSettings(); WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true); settings.setJavaScriptEnabled(true);
@@ -22,4 +26,13 @@ public class MainActivity extends Activity {
webView.loadUrl("file:///android_asset/index.html"); webView.loadUrl("file:///android_asset/index.html");
setContentView(webView); setContentView(webView);
} }
// Android 13+ требует явного согласия на показ уведомлений
private void requestNotificationsPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, 1);
}
}
} }

View File

@@ -0,0 +1,20 @@
package ru.ua1zbe.bus127;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
/** Открытие расписания по клику на уведомление. */
final class PushIntents {
private PushIntents() {
}
static PendingIntent web(Context context, String url) {
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
}

View File

@@ -0,0 +1,75 @@
package ru.ua1zbe.bus127;
import android.app.Notification;
import android.app.NotificationManager;
import android.util.Log;
import java.util.List;
import java.util.Map;
import ru.rustore.sdk.pushclient.messaging.exception.RuStorePushClientException;
import ru.rustore.sdk.pushclient.messaging.model.RemoteMessage;
import ru.rustore.sdk.pushclient.messaging.service.RuStoreMessagingService;
import ru.ua1zbe.bus127.push.PushStore;
/**
* Приём пуш-уведомлений RuStore.
*
* Если сервер прислал секцию notification — SDK показывает уведомление сам.
* Если пришли только data (title/body) — показываем уведомление сами.
*/
public class PushMessagingService extends RuStoreMessagingService {
@Override
public void onNewToken(String token) {
Log.i(TAG, "onNewToken: " + token);
PushStore.setToken(this, token);
}
@Override
public void onMessageReceived(RemoteMessage message) {
if (message.getNotification() != null) {
Log.i(TAG, "push displayed by SDK: " + message.getData());
return;
}
Map<String, String> data = message.getData();
String title = data.get("title");
if (title == null || title.isEmpty()) {
title = getString(R.string.app_name);
}
String body = data.get("body");
if (body == null) {
body = "";
}
if (!title.isEmpty() || !body.isEmpty()) {
showPush(title, body, data.get("url"));
}
}
@Override
public void onDeletedMessages() {
Log.i(TAG, "onDeletedMessages");
}
@Override
public void onError(List<? extends RuStorePushClientException> errors) {
for (RuStorePushClientException e : errors) {
Log.w(TAG, "error", e);
}
}
private void showPush(String title, String body, String url) {
Notification.Builder b = new Notification.Builder(this, Bus127App.CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.mipmap.ic_launcher)
.setAutoCancel(true);
if (url != null && !url.isEmpty()) {
b.setContentIntent(PushIntents.web(this, url));
}
getSystemService(NotificationManager.class).notify(NOTIFICATION_ID, b.build());
}
private static final String TAG = "RuStorePush";
private static final int NOTIFICATION_ID = 127;
}

View File

@@ -0,0 +1,27 @@
package ru.ua1zbe.bus127.push;
import android.content.Context;
import android.content.SharedPreferences;
/** Хранение push-токена устройства, чтобы отправлять ему уведомления с сервера. */
public final class PushStore {
private static final String PREFS = "bus127_push";
private static final String KEY_TOKEN = "push_token";
private PushStore() {
}
private static SharedPreferences sp(Context context) {
return context.getApplicationContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
public static String getToken(Context context) {
return sp(context).getString(KEY_TOKEN, "");
}
public static void setToken(Context context, String token) {
sp(context).edit().putString(KEY_TOKEN, token).apply();
}
}

View File

@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Автобус 127</string> <string name="app_name">Автобус 127</string>
<string name="push_channel_id">bus127_push</string>
<string name="push_channel_name">Пуш-уведомления</string>
</resources> </resources>

View File

@@ -2,7 +2,11 @@ pluginManagement {
repositories { google(); mavenCentral(); gradlePluginPortal() } repositories { google(); mavenCentral(); gradlePluginPortal() }
} }
dependencyResolutionManagement { dependencyResolutionManagement {
repositories { google(); mavenCentral() } repositories {
google()
mavenCentral()
maven { url "https://nexus-external.rustore.ru/repository/maven-rustore-exposed/" }
}
} }
rootProject.name = "BusSchedule127" rootProject.name = "BusSchedule127"
include ':app' include ':app'

View File

@@ -255,13 +255,17 @@ let currentStop = "Полярные Зори";
// Date#getDay(): 0 — воскресенье, 6 — суббота // Date#getDay(): 0 — воскресенье, 6 — суббота
function dayKeyFor(dow){ return dow===0 ? "sunday" : dow===6 ? "saturday" : "weekday"; } function dayKeyFor(dow){ return dow===0 ? "sunday" : dow===6 ? "saturday" : "weekday"; }
// Карточки ближайших рейсов всегда показывают реальный сегодняшний день
function todayKey(){ return dayKeyFor(nowMs().day); }
// Выбор пользователя — только для полного расписания
function sched(){ return SCHEDULES[activeDay]; } function sched(){ return SCHEDULES[activeDay]; }
function render(){ function render(){
const now = nowMs(); const now = nowMs();
const curMin = now.hour*60 + now.minute; const curMin = now.hour*60 + now.minute;
const dirs = DIRECTIONS[currentStop]; const dirs = DIRECTIONS[currentStop];
const data = sched(); const data = SCHEDULES[todayKey()];
document.getElementById("dirBox").innerHTML = dirs.map(d=>{ document.getElementById("dirBox").innerHTML = dirs.map(d=>{
const upcoming = data const upcoming = data
.map((r,ri)=>{ .map((r,ri)=>{
@@ -303,12 +307,12 @@ function renderDay(){
document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`; document.getElementById("dayBadge").innerHTML = `Сегодня <b>${DAYS[dow]}</b>`;
} }
// Подсветка выбранного типа дня и перерисовка расписания // Подсветка выбранного типа дня. Таблица полного расписания перерисовывается,
// карточки ближайших рейсов не трогаем — они всегда на сегодня.
function selectDay(key){ function selectDay(key){
activeDay = key; activeDay = key;
document.querySelectorAll("#dayPick button").forEach(b=> document.querySelectorAll("#dayPick button").forEach(b=>
b.classList.toggle("active", b.dataset.day === key)); b.classList.toggle("active", b.dataset.day === key));
render();
renderTable(); renderTable();
} }
@@ -342,9 +346,9 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
b.classList.toggle("open", !open); b.classList.toggle("open", !open);
}); });
let activeDay = dayKeyFor(nowMs().day); // по умолчанию — реальный день недели let activeDay = todayKey(); // по умолчанию — реальный день недели
selectDay(activeDay); selectDay(activeDay);
tickClock(); renderDay(); tickClock(); renderDay(); render();
setInterval(tickClock,1000); setInterval(tickClock,1000);
setInterval(render,30000); setInterval(render,30000);
setInterval(renderDay,60000); setInterval(renderDay,60000);