12 Commits

Author SHA1 Message Date
ddc9cf2584 README: меню в возможностях и новые классы в структуре 2026-09-29 05:12:56 +00:00
a8ec1ce654 Страница: меню-окошко в шапке, свой экран «О приложении» 2026-09-29 05:12:55 +00:00
187de60f5c Манифест: тема без ActionBar, окно «О приложении» 2026-09-29 05:12:53 +00:00
c7a7726eb7 MainActivity: только главный экран, навигация уехала в страницу 2026-09-29 05:12:51 +00:00
ccbde3a9dd AboutActivity: окно «О приложении» на общей странице 2026-09-29 05:12:50 +00:00
828106a6fc BusBridge: JS-мост меню, стрелки назад и версии сборки 2026-09-29 05:12:49 +00:00
e7df71281d Webs: общий WebView для всех экранов 2026-09-29 05:12:31 +00:00
dbad63bc77 Строки: заголовки окон приложения 2026-09-29 05:11:02 +00:00
af1f215b8a Версия 2.6.3: versionCode 12, versionName 2.6.3 2026-09-29 05:11:01 +00:00
38adfdb34b Строки: заголовки окон приложения 2026-09-29 05:10:48 +00:00
5e15e685fa Версия 2.6.3: versionCode 12, versionName 2.6.3 2026-09-29 05:10:46 +00:00
baabcd31f8 README: рассылка через топики, топик не требует создания в консоли 2026-09-28 12:24:10 +00:00
9 changed files with 236 additions and 24 deletions

View File

@@ -13,6 +13,8 @@
- **Полное расписание** — по кнопке раскрывается вся таблица
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
- **Пуш-уведомления** — через RuStore Push, см. раздел ниже.
- **Меню** — кнопка `⋮` в шапке открывает выпадающее окошко с пунктом
«О приложении» (описание и версия сборки).
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
## Технические данные
@@ -55,12 +57,35 @@ Content-Type: application/json
В приложение попадает только `project_id`; сервисный токен в APK не хранится.
- `push-token` устройства приходит в `onNewToken` (лог `RuStorePush`) и сохраняется
в `SharedPreferences` `bus127_push`.
- Приложение подписывается на топик `bus127` — топик должен существовать в консоли.
Сообщения без поля `topic` приходят всем устройствам проекта.
- Приложение подписывается на топик `bus127` при старте. Отдельного создания топика
в консоли не требуется — публикация в него проходит и для несуществующего.
- Если в запросе есть секция `notification` — уведомление показывает сам SDK;
если переданы только `data` (`title`/`body`, необязательный `url`) — приложение
показывает его само и открывает ссылку по клику.
### Рассылка
**Всем подписчикам топика** (все устройства, открывшие приложение):
```
POST https://vkpns-topics.rustore.ru/v1/projects/{project_id}/topics/{topic}/publish
Authorization: Bearer {service-token}
{"message":{"notification":{"title":"...","body":"..."}}}
```
**Одному устройству** — по его push-токену:
```
POST https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
Authorization: Bearer {service-token}
{"message":{"token":"...","notification":{"title":"...","body":"..."}}}
```
Если устройства не подписались сами, их токены можно подписать разом:
`POST .../topics/{topic}/batch_subscribe` с `{"push_tokens":[...]}`.
Ответ `400` с текстом про `registration token` означает, что адресат не указан
или токен устройства неверен; `401`/`403` — неверный сервисный токен или его пара с `project_id`.
## Структура
```
@@ -70,7 +95,10 @@ apk-webview/ # исходники APK (WebView)
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── java/ru/ua1zbe/bus127/
│ │ ├── MainActivity.java # WebView + запрос разрешения на уведомления
│ │ ├── MainActivity.java # главный экран: WebView + запрос разрешения
│ │ ├── AboutActivity.java # окно «О приложении»
│ │ ├── Webs.java # общий WebView для всех экранов
│ │ ├── BusBridge.java # мост страницы и приложения (меню, назад, версия)
│ │ ├── Bus127App.java # инициализация RuStore Push SDK
│ │ ├── PushMessagingService.java # приём уведомлений
│ │ ├── PushIntents.java # переход по клику

View File

@@ -19,8 +19,8 @@ android {
applicationId "ru.ua1zbe.bus127"
minSdk 26
targetSdk 34
versionCode 11
versionName "2.6.2"
versionCode 12
versionName "2.6.3"
}
buildTypes {

View File

@@ -32,12 +32,20 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@android:style/Theme.Material.NoActionBar"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".AboutActivity"
android:exported="false"
android:label="@string/about_title"
android:parentActivityName=".MainActivity"
android:theme="@android:style/Theme.Material.NoActionBar" />
</application>
</manifest>

View File

@@ -55,6 +55,44 @@
.fullbtn:hover{border-color:var(--accent2)}
.fullbtn .chev{float:right;transition:.2s;color:var(--muted)}
.fullbtn.open .chev{transform:rotate(180deg)}
.fulltitle{display:none;margin:0;padding:14px 12px 2px;font-size:16px;font-weight:700}
/* строка заголовка: стрелка назад слева, меню справа, название ровно по центру */
.headwrap{position:relative}
.headrow{display:grid;grid-template-columns:38px 1fr 38px;align-items:center;gap:8px}
.headrow h1{text-align:center;margin:0;min-width:0}
.iconbtn{display:none;background:var(--card2);border:1px solid var(--line);
color:var(--txt);border-radius:10px;width:38px;height:38px;
font-size:22px;line-height:1;font-family:inherit;cursor:pointer;
align-items:center;justify-content:center;padding:0}
body.app .iconbtn,body.about .iconbtn{display:inline-flex}
/* на главном экране стрелки нет, но место под неё оставляем, иначе съедет центр */
body.app .iconbtn.back{visibility:hidden}
/* троеточие в круглой кнопке приподнято: рамка остаётся на одной линии с названием */
#navMenu{padding-bottom:7px}
.iconbtn:active{border-color:var(--accent2)}
/* выпадающее окошко: висит поверх содержимого, прижато к кнопке меню */
.menu{position:absolute;top:calc(100% + 8px);right:0;z-index:60;
width:min(260px,100%);background:var(--card);border:1px solid var(--line);
border-radius:14px;overflow:hidden;margin:0;
box-shadow:0 18px 44px rgba(0,0,0,.6)}
.menu[hidden]{display:none}
.menuitem{display:block;width:100%;text-align:left;padding:13px 14px;border:0;
background:transparent;color:var(--txt);font-size:15px;font-weight:600;
font-family:inherit;cursor:pointer}
.menuitem:hover{background:var(--card2)}
/* экран «О приложении» */
.aboutcard{display:none;background:var(--card);border:1px solid var(--line);
border-radius:14px;padding:16px;margin-bottom:10px}
body.about .aboutcard{display:block}
body.about .pickers,body.about .where,body.about .dir,
body.about #fullTable,body.about .fullbtn{display:none}
.aboutcard h2{margin:0 0 4px;font-size:17px}
.aboutcard .ver{color:var(--muted);font-size:14px;margin-bottom:12px}
.aboutcard p{margin:0 0 10px;font-size:14px;line-height:1.5}
.aboutcard p.muted{color:var(--muted);font-size:13px}
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
overflow:hidden;display:none;margin-bottom:8px}
@@ -85,7 +123,17 @@
<body>
<div class="wrap">
<header>
<h1>Автобус №127</h1>
<div class="headwrap">
<div class="headrow">
<button class="iconbtn back" id="navBack" aria-label="Назад">‹</button>
<h1>Автобус №127</h1>
<button class="iconbtn" id="navMenu" aria-label="Меню">⋮</button>
</div>
<nav class="menu" id="menuBox" hidden>
<button class="menuitem" id="mAbout">О приложении</button>
</nav>
</div>
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
<div class="clock" id="clock">--:--:-- <small>МСК</small></div>
<div class="day" id="dayBadge"></div>
@@ -103,6 +151,7 @@
<button class="fullbtn" id="fullBtn">Полное расписание <span class="chev">▾</span></button>
<section class="tablecard" id="fullTable">
<h2 class="fulltitle">Полное расписание</h2>
<div class="days" id="dayPick">
<button data-day="weekday" class="active">Будни</button>
<button data-day="saturday">Суббота</button>
@@ -117,6 +166,18 @@
<div id="tableBody"></div>
</section>
<section class="aboutcard" id="aboutBox">
<h2>О приложении</h2>
<div class="ver" id="aboutVer"></div>
<p>Расписание автобуса №127 по маршруту Кандалакша — Полярные Зори — Зашеек.</p>
<p>Ближайшие рейсы считаются по московскому времени и обновляются каждые 30 секунд.
Полное расписание раскрывается прямо на этом экране — кнопкой под списком рейсов,
с переключением между буднями, субботой и воскресеньем.</p>
<p class="muted">Расписание действует с 30.09.2020. Время московское (МСК).</p>
<p class="muted">Пуш-уведомления приходят через RuStore Push — для этого нужен
установленный и авторизованный RuStore на устройстве.</p>
</section>
<footer>Расписание действует с 30.09.2020 · время московское (МСК)</footer>
</div>
@@ -346,6 +407,35 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
b.classList.toggle("open", !open);
});
// Режим показа задаётся якорью в адресе:
// (пусто) — браузер;
// #app — главный экран приложения;
// #about — окно «О приложении».
// В приложении (не в браузере) страница дёргает нативный код через объект Bus.
const MODE = location.hash.replace("#","") || "web";
const NATIVE = MODE !== "web" && typeof Bus !== "undefined";
document.body.classList.add(MODE);
if (MODE === "about" && NATIVE) {
// версию берём из приложения, чтобы не расходилась со сборкой
const v = document.getElementById("aboutVer");
if (Bus.version && Bus.version()) v.textContent = "Версия " + Bus.version();
}
if (NATIVE) {
const menuBox = document.getElementById("menuBox");
const closeMenu = () => { menuBox.hidden = true; };
document.getElementById("navMenu").addEventListener("click",e=>{
e.stopPropagation();
menuBox.hidden = !menuBox.hidden;
});
document.getElementById("navBack").addEventListener("click",()=>Bus.goBack());
document.getElementById("mAbout").addEventListener("click",()=>Bus.openAbout());
document.addEventListener("click",e=>{
if (!e.target.closest(".menu") && !e.target.closest("#navMenu")) closeMenu();
});
}
let activeDay = todayKey(); // по умолчанию — реальный день недели
selectDay(activeDay);
tickClock(); renderDay(); render();

View File

@@ -0,0 +1,16 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.os.Bundle;
/**
* Описание приложения и версия. Рисуется той же страницей расписания,
* поэтому тема оформления совпадает с остальными экранами.
*/
public class AboutActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(Webs.create(this, Webs.URL_ABOUT));
}
}

View File

@@ -0,0 +1,51 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.webkit.JavascriptInterface;
/**
* Мост между страницей расписания и приложением: кнопки заголовка
* (стрелка назад и меню) вызывают сюда. В браузерной версии страницы
* объект не существует, и кнопки там не показываются.
*/
public class BusBridge {
private final Activity activity;
BusBridge(Activity activity) {
this.activity = activity;
}
@JavascriptInterface
public void goBack() {
activity.runOnUiThread(activity::finish);
}
@JavascriptInterface
public void openAbout() {
open(AboutActivity.class);
}
/** «2.6.3 (сборка 12)» — чтобы текст не расходился со сборкой. */
@JavascriptInterface
public String version() {
try {
PackageInfo info = activity.getPackageManager()
.getPackageInfo(activity.getPackageName(), 0);
long code = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
? info.getLongVersionCode() : info.versionCode;
return info.versionName + " (сборка " + code + ")";
} catch (PackageManager.NameNotFoundException e) {
return "";
}
}
private void open(Class<?> target) {
activity.runOnUiThread(() ->
activity.startActivity(new Intent(activity, target)));
}
}

View File

@@ -1,14 +1,8 @@
package ru.ua1zbe.bus127;
import android.Manifest;
import android.app.Activity;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.view.WindowManager;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
public class MainActivity extends Activity {
@Override
@@ -17,22 +11,15 @@ public class MainActivity extends Activity {
// экран не гаснет, пока открыто расписание
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
requestNotificationsPermission();
WebView webView = new WebView(this);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setSupportZoom(false);
webView.setWebViewClient(new WebViewClient());
webView.loadUrl("file:///android_asset/index.html");
setContentView(webView);
setContentView(Webs.create(this, Webs.URL_MAIN));
}
// 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);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU
&& checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[]{android.Manifest.permission.POST_NOTIFICATIONS}, 1);
}
}
}

View File

@@ -0,0 +1,30 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
/** Создание WebView с одинаковыми настройками для всех экранов. */
final class Webs {
/** Главный экран: полное расписание раскрывается прямо на странице. */
static final String URL_MAIN = "file:///android_asset/index.html#app";
/** Описание приложения и версия. */
static final String URL_ABOUT = "file:///android_asset/index.html#about";
private Webs() {
}
static WebView create(Activity activity, String url) {
WebView webView = new WebView(activity);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setSupportZoom(false);
webView.setWebViewClient(new WebViewClient());
webView.addJavascriptInterface(new BusBridge(activity), "Bus");
webView.loadUrl(url);
return webView;
}
}

View File

@@ -3,4 +3,6 @@
<string name="app_name">Автобус 127</string>
<string name="push_channel_id">bus127_push</string>
<string name="push_channel_name">Пуш-уведомления</string>
<string name="about_title">О приложении</string>
</resources>