36 Commits

Author SHA1 Message Date
b5821a1fc7 Версия 2.6.5 (сборка 14) 2026-09-29 12:04:52 +00:00
c639996ca8 Мост JS→Java: добавлен openMap()
index.html вызывает Bus.openMap(), но метода не было в репозитории.
Сборка из исходников давала бы приложение, где пункт меню «Карта»
ничего не делает. APK 2.6.4 собирался из локальной копии, поэтому у
пользователей дефект не проявлялся. Здесь же актуализирован комментарий
к version(), значение он по-прежнему берёт из менеджера пакетов.
2026-09-29 12:04:51 +00:00
9b2a45568e Расписание по официальным данным лета 2026, прибытие и шторка на карте
Данные диспетчерской службы ООО «Гарант-Авто» на 01.05–31.10.2026,
источник kandalaksha.org/static/auto.html. Опубликованы четыре списка
ОТПОРЯЖЕНИЙ, 13/10/6 рейсов; колонка 2 — это выезд из Зашейка,
а не прибытие, из-за чего приложение показывало нелогичные интервалы.

Расписание:
- убран рейс 08:00/08:50/09:20/09:50, которого нет в источнике;
- добавлен рейс 11:30/12:20/12:50/13:20;
- в рейсе на Кандалакшу 14:10 → 14:15;
- суббота и воскресенье на 5 минут (09:05 → 09:10 и далее по списку);
- 14:20, 14:50, 15:10 и 15:50 подтверждены источником и оставлены.

Время прибытия в карточках направлений считается, а не берётся
из таблицы: Зашеек = отправление + 20 мин, но не дольше интервала
до выезда оттуда; Кандалакша = + 50 мин.

Карта:
- нижняя шторка с ручкой: перетаскивание, тап для сворачивания,
  автопрокрутка текста, карта перестраивается при изменении размера;
- заголовок «Автобус №127» в одну строку на всех экранах, раньше
  на карте он переносился, а на остальных терял пробел перед №127;
- кнопка «‹» на карте возвращает на главный экран;
- между прибытием в Зашеек и выездом автобус стоит на остановке;
- убраны 40px лишней прокрутки страницы на вкладке карты.
2026-09-29 12:04:50 +00:00
6d3f794bb7 README: карта и поведение меню 2026-09-29 08:14:57 +00:00
b5eed3284c Версия 2.6.4 2026-09-29 08:14:56 +00:00
5cdddcc9b3 Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:55 +00:00
36b59c2e2e Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:54 +00:00
66688630a3 Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:53 +00:00
9cda0ba3df Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:51 +00:00
c2505ea8f3 Карта: экран на VK Картах с положением автобусов по расписанию 2026-09-29 08:14:50 +00:00
8daf3d01ce Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:49 +00:00
9c8d8b5db7 Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:48 +00:00
4f972d527b Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:46 +00:00
873a2b7cd7 Карта: SDK VK Карт и геометрия маршрута (OSRM) 2026-09-29 08:14:40 +00:00
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
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
19 changed files with 1259 additions and 55 deletions

View File

@@ -12,6 +12,17 @@
- **Автоопределение дня недели** — будни/выходные.
- **Полное расписание** — по кнопке раскрывается вся таблица
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
- **Карта с автобусами** — пункт меню «Карта» открывает VK Карты: видно
пройденный участок маршрута и автобусы прямо сейчас (без подписей остановок —
они были бы только шумом). Положение считается **по расписанию** (приложение
не берёт координаты с GPS), поэтому это оценка с точностью до нескольких
минут; внизу перечислены автобусы с направлением и временем прибытия.
Обновление каждые 20 сек.
- **Пуш-уведомления** — через RuStore Push, см. раздел ниже.
- **Меню** — кнопка `⋮` в шапке открывает выпадающее окошко с пунктами
«Карта» и «О приложении». Меню сворачивается после выбора пункта, по нажатию
вне его и по кнопке «‹» (которая в этом случае только закрывает меню,
а не уходит назад).
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
## Технические данные
@@ -24,6 +35,65 @@
| Сборка | Gradle 8.6 + AGP 8.2.2 |
| Подпись | 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` при старте. Отдельного создания топика
в консоли не требуется — публикация в него проходит и для несуществующего.
- Если в запросе есть секция `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`.
## Структура
```
@@ -32,8 +102,20 @@ apk-webview/ # исходники APK (WebView)
│ ├── build.gradle
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── java/ru/ua1zbe/bus127/MainActivity.java
│ ├── java/ru/ua1zbe/bus127/
│ │ ├── MainActivity.java # главный экран: WebView + запрос разрешения
│ │ ├── AboutActivity.java # окно «О приложении»
│ │ ├── MapActivity.java # экран карты (свой WebView на тот же index.html#map)
│ │ ├── Webs.java # общий WebView для всех экранов
│ │ ├── BusBridge.java # мост страницы и приложения (меню, назад, версия)
│ │ ├── Bus127App.java # инициализация RuStore Push SDK
│ │ ├── PushMessagingService.java # приём уведомлений
│ │ ├── PushIntents.java # переход по клику
│ │ └── push/PushStore.java # хранение push-токена
│ ├── assets/index.html # интерфейс (HTML/JS)
│ ├── assets/route.js # геометрия маршрута OSRM + координаты остановок ([lat,lon])
│ ├── assets/mmr-gl.js # VK Maps SDK (UMD, глобаль mmrgl), BSD-3-Clause
│ ├── assets/mmr-gl.css # стили SDK
│ └── res/values/strings.xml
└── settings.gradle, build.gradle, gradle.properties
web/index.html # веб-версия (та же страница, для браузера)
@@ -52,4 +134,9 @@ cd apk-webview
## Лицензия
MIT.
MIT. В `app/src/main/assets/` лежит сторонний код со своей лицензией:
- `mmr-gl.js`, `mmr-gl.css` — VK Maps SDK, BSD-3-Clause,
текст в `mmr-gl.LICENSE.txt`;
- `route.js` — геометрия дороги и координаты остановок из OpenStreetMap
(ODbL), атрибуция также выводится самими VK Картами.

View File

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

View File

@@ -1,22 +1,58 @@
<?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
android:name=".Bus127App"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true"
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
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=".MapActivity"
android:exported="false"
android:label="@string/map_title"
android:parentActivityName=".MainActivity"
android:theme="@android:style/Theme.Material.NoActionBar" />
<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,104 @@
.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,body.map .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}
/* экран «Карта»: страница целиком занята картой, шапка остаётся */
body.map{height:100vh;height:100dvh;min-height:0;padding:24px 16px 16px;overflow:hidden}
body.map .wrap{height:100%;display:flex;flex-direction:column;max-width:none}
body.map header{margin-bottom:12px}
/* заголовок всегда в одну строку, в том числе на карте */
body.map h1{font-size:20px;line-height:1.2;white-space:nowrap}
body.map .clock{font-size:18px;margin-top:6px}
body.map .day,body.map .pickers,body.map .where,body.map .dir,
body.map .fullbtn,body.map #fullTable,body.map .aboutcard,body.map footer{display:none}
body.map #mapBox{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:8px}
.mapcanvas{flex:1 1 auto;min-height:0;border:1px solid var(--line);
border-radius:14px;overflow:hidden;background:var(--card);position:relative}
.mapcanvas.loading::after{content:"Загружаю карту…";position:absolute;inset:0;
display:flex;align-items:center;justify-content:center;
color:var(--muted);font-size:14px}
/* нижняя шторка: свёрнута до ручки с заголовком, тянется вверх,
а текст внутри прокручивается отдельно от карты */
.mapsheet{flex:0 0 auto;position:relative;display:flex;flex-direction:column;
background:var(--card);border:1px solid var(--line);border-radius:14px;
overflow:hidden;box-shadow:0 -6px 20px rgba(0,0,0,.35)}
.grab{position:relative;flex:0 0 auto;display:flex;align-items:center;gap:8px;
padding:14px 14px 8px;cursor:grab;touch-action:none;user-select:none;
border-bottom:1px solid var(--line)}
.grab .bar{position:absolute;top:6px;left:50%;width:34px;height:4px;margin-left:-17px;
border-radius:2px;background:#3a4a70}
.grab .cap{flex:1 1 auto;min-width:0;font-size:12px;font-weight:700;color:var(--muted);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.grab .chev{color:var(--muted);font-size:11px;transition:transform .2s}
.mapsheet.open .grab .chev{transform:rotate(180deg)}
.mapstatus{flex:1 1 auto;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;
overscroll-behavior:contain;padding:11px 14px 14px}
.mapstatus .head{font-size:14px;font-weight:700;margin-bottom:4px}
.mapstatus .head b{color:var(--accent)}
.mapstatus .rows{font-size:13px;color:var(--muted);line-height:1.55}
.mapstatus .rows div{display:flex;gap:8px;align-items:baseline}
.mapstatus .rows .when{color:var(--txt);font-variant-numeric:tabular-nums;
white-space:nowrap;font-size:12px}
.mapstatus .hint{margin-top:7px;font-size:11px;color:var(--muted);line-height:1.4}
.note{margin-top:10px;padding-top:10px;border-top:1px solid var(--line);
font-size:12px;line-height:1.55;color:var(--muted)}
.note p{margin:0 0 8px}
.note p:last-child{margin-bottom:0}
.note b{color:var(--txt)}
/* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */
.pin{transform:translate(-50%,-100%);white-space:nowrap;pointer-events:none}
.pin .box{display:flex;align-items:center;gap:6px;padding:4px 9px 4px 5px;
border-radius:999px;background:rgba(13,18,32,.92);border:1px solid var(--line);
box-shadow:0 4px 14px rgba(0,0,0,.5)}
.pin .num{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;
justify-content:center;font-size:10px;font-weight:700;color:#04101c;flex:0 0 auto}
.pin .txt{font-size:11px;font-weight:600;color:var(--txt)}
.pin.fwd .num{background:var(--accent)}
.pin.back .num{background:var(--accent2)}
.pin::after{content:"";position:absolute;left:50%;bottom:-5px;width:8px;height:8px;
margin-left:-4px;background:rgba(13,18,32,.92);
border-right:1px solid var(--line);border-bottom:1px solid var(--line);
transform:rotate(45deg)}
.tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
overflow:hidden;display:none;margin-bottom:8px}
@@ -85,7 +183,18 @@
<body>
<div class="wrap">
<header>
<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="mMap">Карта</button>
<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 +212,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>
@@ -110,37 +220,98 @@
</div>
<div class="thead">
<div><b>Кандалакша</b>отпр.</div>
<div><b>Полярные Зори</b>→ Зашеек</div>
<div><b>Зашеек</b>прибытие</div>
<div><b>Полярные Зори</b>→ Кандалакша</div>
<div><b>Полярные Зори</b>отпр. в Зашеек</div>
<div><b>Зашеек</b>отпр.</div>
<div><b>Полярные Зори</b>отпр. в Канд.</div>
</div>
<div id="tableBody"></div>
</section>
<footer>Расписание действует с 30.09.2020 · время московское (МСК)</footer>
<section class="mapcard" id="mapBox" hidden>
<div class="mapcanvas loading" id="mapCanvas"></div>
<div class="mapsheet" id="mapSheet">
<div class="grab" id="sheetGrab">
<span class="bar"></span>
<span class="cap" id="sheetCap">Автобусы и расписание</span>
<span class="chev">▴</span>
</div>
<div class="mapstatus">
<div class="head" id="mapHead">Карта маршрута</div>
<div class="rows" id="mapRows"></div>
<div class="hint">Положение автобуса рассчитано по расписанию, а не по GPS:
между остановками интерполируется по дороге. Точность — до нескольких минут.</div>
<div class="note">
<p><b>Как читать расписание.</b> Таблица и списки показывают официальное время
<b>отправлений</b>. Время прибытия в расписании не публикуется: оно берётся
из отправления следующей остановки, потому что автобус приезжает
на Полярные Зори и сразу уходит дальше. Исключение — Зашеек: там
прибытие посчитано как выезд из Полярных Зорь плюс 20 минут.
Получившиеся интервалы в обе стороны по одной дороге отличаются
на 5–10 минут — так и есть в официальном расписании, это не ошибка.</p>
<p><b>Положение автобусов.</b> GPS у приложения нет, координаты
интерполируются по дороге между остановками по времени рейса.
В Зашейке автобус стоит, пока не подошло время обратного выезда.
Точность — до нескольких минут, в пробки отставание больше.</p>
<p class="muted">Расписание сверено с официальным: отправления взяты из данных
диспетчерской службы перевозчика, летнее расписание
с 01.05.2026 по 31.10.2026, время московское. Карта и тайлы — VK Карты,
геометрия дороги — OpenStreetMap.</p>
</div>
</div>
</div>
</section>
<section class="aboutcard" id="aboutBox">
<h2>О приложении</h2>
<div class="ver" id="aboutVer"></div>
<p>Расписание автобуса №127 по маршруту Кандалакша — Полярные Зори — Зашеек.</p>
<p>Ближайшие рейсы считаются по московскому времени и обновляются каждые 30 секунд.
Полное расписание раскрывается прямо на этом экране — кнопкой под списком рейсов,
с переключением между буднями, субботой и воскресеньем.</p>
<p>В меню есть «Карта»: там видно, где сейчас едут автобусы, и как пройден
участок между остановками. Положение считается по расписанию — приложение
не получает данные с GPS, поэтому это оценка с точностью до нескольких минут.</p>
<p class="muted">Расписание сверено с официальным: отправления взяты из данных
диспетчерской службы перевозчика, летнее расписание
с 01.05.2026 по 31.10.2026. Время московское (МСК).</p>
<p class="muted">Карта и тайлы — VK Карты, геометрия дороги — OpenStreetMap.
Пуш-уведомления приходят через RuStore Push — для этого нужен
установленный и авторизованный RuStore на устройстве.</p>
</section>
<footer>Летнее расписание с 01.05.2026 по 31.10.2026 · отправления по данным
диспетчерской службы перевозчика · время московское (МСК)</footer>
</div>
<script>
// Расписание по типам дней недели.
// Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) ·
// прибытие в Зашеек · Полярные Зори (обратно).
// Расписание по типам дней недели. Взято с официального расписания
// (данные диспетчерской службы перевозчика ООО «Гарант-Авто»,
// kandalaksha.org/static/auto.html), летнее расписание
// с 01.05.2026 по 31.10.2026.
//
// Будни — полная таблица маршрута. По субботе и воскресенью в репозитории
// хранились только отправления из Полярных Зорь и прибытия в Зашеек
// (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому
// первая и четвёртая колонки восстановлены по тем же правилам, что и для
// будней, и требуют сверки с официальным расписанием:
// кол0 = кол1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин)
// кол3 = кол2 + 20 мин (так разность кол3−кол2 выглядит в 8 строках из 13)
// Опубликованы только ОТПРАВЛЕНИЯ, и четыре колонки склеиваются
// в один круг рейса попарно, 1:1 — проверено, что моменты строго
// возрастают в каждой строке:
// кол0 — отправление из Кандалакши
// кол1 — отправление из Полярных Зорь в Зашеек
// кол2 — отправление ИЗ Зашейка (обратно в Полярные Зори)
// кол3 — отправление из Полярных Зорь в Кандалакшу
//
// Важно: кол2 — это выезд из Зашейка, а не прибытие в него.
// Раньше колонка была подписана как прибытие, и из-за этого один и тот же
// автобус «прибывал» и «уезжал» в Зашеек в одну минуту, а интервалы
// туда и обратно выходили разными на одной и той же дороге.
// Прибытие в Зашеек в расписании не публикуется — оно считается
// отдельно, см. arrZasheek ниже.
const SCHEDULES = {
weekday: [
["05:20","06:20","06:30","06:50"],
["06:10","07:00","07:25","07:45"],
["07:40","08:30","09:00","09:20"],
["08:00","08:50","09:20","09:50"],
["09:45","10:35","11:05","11:25"],
["11:00","11:50","12:20","12:40"],
["12:30","13:20","13:50","14:10"],
["11:30","12:20","12:50","13:20"],
["12:30","13:20","13:50","14:15"],
["13:30","14:20","14:50","15:10"],
["14:00","14:50","15:25","15:50"],
["15:30","16:20","17:00","17:25"],
@@ -150,23 +321,23 @@ const SCHEDULES = {
],
saturday: [
["06:10","07:00","07:25","07:45"],
["07:30","08:20","08:45","09:05"],
["08:45","09:35","10:05","10:25"],
["10:10","11:00","11:30","11:50"],
["12:30","13:20","13:50","14:10"],
["14:00","14:50","15:45","16:05"],
["15:30","16:20","16:45","17:05"],
["17:00","17:50","18:20","18:40"],
["18:10","19:00","19:30","19:50"],
["20:20","21:10","21:40","22:00"]
["07:30","08:20","08:45","09:10"],
["08:45","09:35","10:05","10:30"],
["10:10","11:00","11:30","11:55"],
["12:30","13:20","13:50","14:15"],
["14:00","14:50","15:45","16:10"],
["15:30","16:20","16:45","17:10"],
["17:00","17:50","18:20","18:45"],
["18:10","19:00","19:30","19:55"],
["20:20","21:10","21:40","22:05"]
],
sunday: [
["06:10","07:00","07:25","07:45"],
["08:45","09:35","10:05","10:25"],
["12:30","13:20","13:50","14:10"],
["14:00","14:50","15:25","15:45"],
["16:50","17:40","18:10","18:30"],
["20:20","21:10","21:40","22:00"]
["08:45","09:35","10:05","10:30"],
["12:30","13:20","13:50","14:15"],
["14:00","14:50","15:25","15:50"],
["16:50","17:40","18:10","18:35"],
["20:20","21:10","21:40","22:05"]
]
};
const DAY_KEYS = ["weekday","saturday","sunday"];
@@ -176,21 +347,38 @@ const TRIPS_SHOWN = 5; // сколько ближайших рейсов по
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
// (в первой строке 60 — по всей видимости опечатка).
// (в первой строке 60 — по всей видимости опечатка в самом расписании).
const RIDE_PZ_KAND = 50;
// arrs — список прибытий: {label, col} — время из колонки таблицы,
// {label, offset} — «время в пути» от отправления (если col не задан).
// Полярные Зори → Зашеек. В расписании тоже нет: там опубликованы только
// отправления. Принимаем 20 минут — но не больше, чем есть между выездом
// из Полярных Зорь и выездом обратно, иначе на первом утреннем рейсе
// (06:20 → 06:30) автобус «прибыл» бы в Зашеек уже после своего выезда.
const RIDE_PZ_ZASHEEK = 20;
// Прибытие в Зашеек — оценка, потому что официального времени прибытия
// в расписании нет. Считаем от выезда из Полярных Зорь.
function arrZasheek(row){
const t1 = toMin(row[1]), t2 = toMin(row[2]);
return t1 + Math.max(0, Math.min(RIDE_PZ_ZASHEEK, t2 - t1));
}
// arrs — список прибытий: {label, col} — время из колонки расписания,
// {label, offset} — «время в пути» от отправления (если col не задан),
// {label, arrZ:true} — оценка прибытия в Зашеек, см. arrZasheek().
// В расписании опубликованы только отправления, поэтому прибытия
// (кроме Зашейка, где есть своя оценка) берутся из отправлений следующей
// остановки: автобус приезжает на ПЗ и сразу уходит дальше.
const DIRECTIONS = {
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
"Кандалакша": [
{to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
arrs:[{label:"Полярные Зори", col:1},
{label:"Зашеек", col:2}]}
{label:"Зашеек", arrZ:true}]}
],
"Полярные Зори": [
{to:"Зашеек", dep:1, via:"≈ 30 мин в пути",
arrs:[{label:"Зашеек", col:2}]},
{to:"Зашеек", dep:1, via:"≈ 20 мин в пути",
arrs:[{label:"Зашеек", arrZ:true}]},
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
],
@@ -271,7 +459,9 @@ function render(){
.map((r,ri)=>{
const dep = toMin(r[d.dep]);
const arrs = d.arrs.map(a=>{
const base = a.col!==undefined ? toMin(r[a.col]) : dep;
const base = a.col!==undefined ? toMin(r[a.col])
: a.arrZ ? arrZasheek(r)
: dep;
return {label:a.label, t:fmtHM(base + (a.offset||0))};
});
return {dep, arrs, ri};
@@ -346,12 +536,358 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
b.classList.toggle("open", !open);
});
// Режим показа задаётся якорью в адресе:
// (пусто) — браузер;
// #app — главный экран приложения;
// #map — карта с положением автобуса;
// #about — окно «О приложении».
// В приложении (не в браузере) страница дёргает нативный код через объект Bus.
const MODE = location.hash.replace("#","") || "web";
const NATIVE = MODE !== "web" && typeof Bus !== "undefined";
document.body.classList.add(MODE);
document.getElementById("mapBox").hidden = MODE !== "map";
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",()=>{
// «назад» сначала сворачивает открытое меню и только потом уходит назад
if (!menuBox.hidden) { closeMenu(); return; }
Bus.goBack();
});
document.getElementById("mMap").addEventListener("click",()=>{ closeMenu(); Bus.openMap(); });
document.getElementById("mAbout").addEventListener("click",()=>{ closeMenu(); Bus.openAbout(); });
document.addEventListener("click",e=>{
if (!e.target.closest(".menu") && !e.target.closest("#navMenu")) closeMenu();
});
}
let activeDay = todayKey(); // по умолчанию — реальный день недели
selectDay(activeDay);
tickClock(); renderDay(); render();
setInterval(tickClock,1000);
setInterval(render,30000);
setInterval(renderDay,60000);
// ─────────────────────────── Карта ───────────────────────────
// Где едет автобус, приложение не знает — GPS у него нет. Но в расписании
// есть отправление из Кандалакши, отправление из Полярных Зорь в Зашеек,
// прибытие в Зашеек и отправление из Полярных Зорь обратно. Между соседними
// точками известно, сколько минут шёл автобус, поэтому положение внутри
// участка можно оценить. Дорога между точками лежит в route.js (OpenStreetMap),
// доля пути считается по длине ломаной — маркер идёт по трассе, а не по прямой.
//
// Координаты и геометрия дороги, стиль и тайлы — VK Карты (MMR GL JS),
// подключаются только на этом экране, ключ доступа — из кабинета RuStore.
const VK_MAP_KEY = "RS545b2f43f1096207eeb54edd762e325c17f1e0ccd5fdd234afb61cf93f7331";
const MAP_STYLE = "https://maps.vk.com/api/styles/main_style.json";
// как часто пересчитывать положение: точность всё равно ограничена расписанием
const MAP_TICK_MS = 20000;
// четыре направления дороги: туда до Зашейка и обратно. Заполняются после
// загрузки route.js — до этого карта не считает ничего.
let KAND_TO_PZ = null; // Кандалакша → Полярные Зори
let PZ_TO_ZASHEEK = null;// Полярные Зори → Зашеек
let PZ_TO_ZASHEEK_BACK = null;
let KAND_TO_PZ_BACK = null;
let MAPREF = null; // карта нужна шторке, чтобы пересчитать размер холста
// Длина ломаной с накопленными отрезками. Расстояние считаем в
// равнопромежуточной проекции — на таком масштабе это точнее градусов.
function measure(line){
const cum = [0];
for (let i=1;i<line.length;i++){
const [x1,y1] = line[i-1], [x2,y2] = line[i];
const k = Math.cos((y1+y2)/2 * Math.PI/180); // сжатие по долготе
cum.push(cum[i-1] + Math.hypot((x2-x1)*k, y2-y1));
}
return {line, cum, len: cum[cum.length-1]};
}
// Тот же путь, но пройденный в обратную сторону
function reverse(p){
return {line: p.line.slice().reverse(),
cum: p.cum.map(v=>p.len-v).reverse(), len: p.len};
}
// Точка на ломаной по доле пройденного расстояния
function pointAt(p, f){
const target = Math.max(0, Math.min(1, f)) * p.len;
let i = 1;
while (i < p.cum.length-1 && p.cum[i] < target) i++;
const seg = p.cum[i]-p.cum[i-1] || 1;
const t = (target - p.cum[i-1]) / seg;
const [x1,y1] = p.line[i-1], [x2,y2] = p.line[i];
return [x1+(x2-x1)*t, y1+(y2-y1)*t];
}
// Рейс разложен на участки круга: Кандалакша → ПЗ → Зашеек → ПЗ → Кандалакша.
// Колонка кол2 — это выезд ИЗ Зашейка, поэтому между прибытием туда
// (оценка arrZasheek) и выездом автобус стоит на остановке: отдельный
// участок hold, на котором точка не двигается. Время в пути ПЗ → Кандалакша
// в расписании не указано, берём те же 50 минут, что и в DIRECTIONS.
function makeRun(row){
const t0=toMin(row[0]), t1=toMin(row[1]), t2=toMin(row[2]), t3=toMin(row[3]);
const z = arrZasheek(row); // прибытие в Зашеек (оценка)
const back = t3 + RIDE_PZ_KAND; // прибытие в Кандалакшу
const segs = [
{a:t0, b:t1, line:KAND_TO_PZ, to:"Полярные Зори", end:t1},
{a:t1, b:z, line:PZ_TO_ZASHEEK, to:"Зашеек", end:z}
];
if (t2 > z) segs.push({a:z, b:t2, line:PZ_TO_ZASHEEK, to:"Зашеек", end:t2, hold:true});
segs.push(
{a:t2, b:t3, line:PZ_TO_ZASHEEK_BACK, to:"Полярные Зори", end:t3},
{a:t3, b:back, line:KAND_TO_PZ_BACK, to:"Кандалакша", end:back}
);
return {t0, tEnd: back, segs: segs.filter(s=>s.b > s.a)};
}
// Все рейсы, которые сейчас в пути, вместе с точкой и участком
function busesNow(){
const d = nowMs();
const T = d.hour*60 + d.minute + d.second/60;
const out = [];
SCHEDULES[todayKey()].forEach((row, idx) => {
const run = makeRun(row);
if (T < run.t0 || T > run.tEnd) return;
const seg = run.segs.find(s => T >= s.a && T <= s.b);
if (!seg) return;
// на участке стоянки в Зашейке точка не двигается — держим конец подъезда
const f = seg.hold ? 1 : (seg.b > seg.a ? (T - seg.a) / (seg.b - seg.a) : 0);
out.push({idx, seg, at: pointAt(seg.line, f)});
});
return out;
}
function pinElement(cls, num, text){
const root = document.createElement("div");
root.className = "pin " + cls;
const box = document.createElement("div");
box.className = "box";
box.innerHTML = `<span class="num">${num}</span><span class="txt">${text}</span>`;
root.appendChild(box);
return root;
}
function renderMapStatus(list){
const head = document.getElementById("mapHead");
const rows = document.getElementById("mapRows");
if (!list.length) {
head.innerHTML = "Сейчас автобусов нет";
rows.innerHTML = `<div>Первый рейс — в ${SCHEDULES[todayKey()][0][0]} из Кандалакши.</div>`;
return;
}
const many = list.length > 1;
head.innerHTML = `Сейчас в пути <b>${list.length}</b> ${many ? "автобуса" : "автобус"}`;
rows.innerHTML = list.map(b => b.seg.hold
? `<div><span class="when">Зашеек</span><span>стоит на остановке, выезд в ${fmtHM(b.seg.b)}</span></div>`
: `<div><span class="when">→ ${b.seg.to}</span><span>прибытие в ${fmtHM(b.seg.end)}</span></div>`
).join("");
}
function startMap(map, mmrgl){
const pins = new Map(); // индекс рейса → метка, чтобы не плодить новые
function update(){
const list = busesNow();
const seen = new Set();
list.forEach(b => {
seen.add(b.idx);
let p = pins.get(b.idx);
if (!p) {
const back = b.seg.to === "Кандалакша";
const marker = new mmrgl.Marker({element: pinElement(back ? "back" : "fwd", "127", "→ " + b.seg.to),
anchor: "bottom"});
// координаты обязаны быть заданы до addTo: при добавлении SDK сразу
// пересчитывает положение метки и падает на пустом LngLat
marker.setLngLat(b.at);
marker.addTo(map);
p = {marker, lastTo: null};
pins.set(b.idx, p);
}
// подпись у участка своя — обновляем, только если направление сменилось
if (p.lastTo !== b.seg.to) {
p.lastTo = b.seg.to;
const el = p.marker.getElement();
el.querySelector(".txt").textContent = "→ " + b.seg.to;
// именно classList, а не className: у метки есть классы SDK
// (mmrgl-marker, mmrgl-marker-anchor-bottom), и перезапись className
// убрала бы position:absolute — метка пропала бы с карты
const back = b.seg.to === "Кандалакша";
el.classList.toggle("back", back);
el.classList.toggle("fwd", !back);
}
p.marker.setLngLat(b.at);
});
// рейсы, закончившиеся, убираем
pins.forEach((p, idx) => {
if (!seen.has(idx)) { p.marker.remove(); pins.delete(idx); }
});
renderMapStatus(list);
}
update();
setInterval(update, MAP_TICK_MS);
document.addEventListener("visibilitychange", () => { if (!document.hidden) update(); });
}
function linkCss(href){
const l = document.createElement("link");
l.rel = "stylesheet"; l.href = href;
document.head.appendChild(l);
}
function loadScript(src){
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error("не загрузился " + src));
document.head.appendChild(s);
});
}
function initMap(){
const box = document.getElementById("mapCanvas");
let ready = false;
// если стиль так и не пришёл — говорим об этом прямо, а не оставляем пустой прямоугольник
const guard = setTimeout(() => {
if (ready) return;
box.classList.remove("loading");
box.innerHTML = `<div style="padding:16px;color:#8b98b8;font-size:13px;line-height:1.5">
Карта не загрузилась. Нужен интернет: стиль и тайлы VK Карт грузятся из сети.</div>`;
}, 25000);
Promise.all([loadScript("route.js"), loadScript("mmr-gl.js")])
.then(() => {
linkCss("mmr-gl.css");
const mmrgl = window.mmrgl;
mmrgl.accessToken = VK_MAP_KEY; // ключ добавится к стилю, тайлам, шрифтам
KAND_TO_PZ = measure(LEG_KP);
PZ_TO_ZASHEEK = measure(LEG_PZ);
PZ_TO_ZASHEEK_BACK = reverse(PZ_TO_ZASHEEK);
KAND_TO_PZ_BACK = reverse(KAND_TO_PZ);
const map = new mmrgl.Map({
container: box,
style: MAP_STYLE,
center: [32.479, 67.279],
zoom: 10,
attributionControl: true
});
MAPREF = map;
map.on("load", () => {
ready = true;
clearTimeout(guard);
box.classList.remove("loading");
// линия маршрута целиком: Кандалакша → ПЗ → Зашеек
map.addSource("route", {type:"geojson", data:{
type:"Feature",
geometry:{type:"LineString", coordinates: LEG_KP.concat(LEG_PZ)},
properties:{}
}});
map.addLayer({id:"route", type:"line", source:"route",
layout:{"line-cap":"round", "line-join":"round"},
paint:{"line-color":"#4f9cff", "line-width":4, "line-opacity":.9}});
// остановки на карте не рисуем: на экране они только мешали разглядеть
// сами автобусы, а куда ехать — написано в списке под картой
// весь маршрут в кадр. Метки привязаны к точке снизу и уходят вверх,
// поэтому сверху отступ больше, иначе крайние метки срезает.
const pts = [STOPS.kandalaksha, STOPS.polyarnye, STOPS.zasheek];
map.fitBounds(
[[Math.min(...pts.map(p=>p[1])), Math.min(...pts.map(p=>p[0]))],
[Math.max(...pts.map(p=>p[1])), Math.max(...pts.map(p=>p[0]))]],
{padding: {top: 84, bottom: 64, left: 56, right: 56}, duration: 0});
startMap(map, mmrgl);
// размер контейнера после отрисовки может уточниться позже
setTimeout(() => map.resize(), 300);
});
map.on("error", e => {
// не показываем шторку ошибок: на телефоне она перекрывает карту
if (e && e.error) console.error("карта:", e.error.message || e.error);
});
})
.catch(err => {
clearTimeout(guard);
box.classList.remove("loading");
box.innerHTML = `<div style="padding:16px;color:#8b98b8;font-size:13px">
Не удалось загрузить карту: ${err.message}<br>Нужен интернет.</div>`;
});
}
// Нижняя шторка карты. За ручку её тянут вверх, короткое касание — переключает.
// Внутри своя прокрутка, поэтому карта под текстом не ездит, а карта
// VK перерисовывается под изменившуюся высоту холста.
function initSheet(){
const box = document.getElementById("mapBox");
const sheet = document.getElementById("mapSheet");
const grab = document.getElementById("sheetGrab");
if (!box || !sheet || !grab) return;
const MIN = 118;
const max = () => Math.max(MIN, Math.round(box.clientHeight * 0.72));
let open = false, h = MIN, startY = 0, startH = 0, moved = 0, dragged = false;
function set(next, expanded){
h = Math.max(MIN, Math.min(max(), Math.round(next)));
sheet.style.height = h + "px";
open = !!expanded;
sheet.classList.toggle("open", open);
if (MAPREF) MAPREF.resize(); // холст карты стал ниже — пересчитать
}
// Захват указателя есть не во всех окружениях (и может не сработать при
// синтетическом событии), поэтому на него не опираемся критично.
const captured = e => { try { return grab.hasPointerCapture(e.pointerId); }
catch(_){ return true; } };
const capture = e => { try { grab.setPointerCapture(e.pointerId); } catch(_){} };
const release = e => { try { grab.releasePointerCapture(e.pointerId); } catch(_){} };
grab.addEventListener("pointerdown", e=>{
startY = e.clientY; startH = h; moved = 0; dragged = false;
capture(e);
});
grab.addEventListener("pointermove", e=>{
if (!captured(e)) return;
moved = startY - e.clientY;
if (Math.abs(moved) > 6) dragged = true;
const want = startH + moved;
set(want, want > MIN + 8);
});
grab.addEventListener("pointerup", e=>{
release(e);
if (!dragged) { set(open ? MIN : max(), !open); return; } // просто тап
const expanded = h > box.clientHeight * 0.4;
set(expanded ? max() : MIN, expanded);
});
grab.addEventListener("pointercancel", ()=>{ set(open ? max() : MIN, open); });
// поворот экрана или смена высоты окна — пересчитать предел раскрытия
window.addEventListener("resize", ()=> set(open ? max() : MIN, open));
set(MIN, false);
}
if (MODE === "map") { initMap(); initSheet(); }
</script>
</body>
</html>

View File

@@ -0,0 +1,150 @@
Copyright (c) 2022, VK
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of MMR GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from maplibre-gl-js v3.6.2 and earlier
Version v3.6.2 of maplibre-gl-js and earlier are licensed under a BSD-3-Clause license
Copyright (c) 2023, MapLibre contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of MapLibre GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from mapbox-gl-js v1.13 and earlier
Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license
Copyright (c) 2020, Mapbox
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of Mapbox GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from glfx.js
Copyright (C) 2011 by Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Contains a portion of d3-color https://github.com/d3/d3-color
Copyright 2010-2016 Mike Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the author nor the names of contributors may be used to
endorse or promote products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
const STOPS = {
kandalaksha: [67.15079, 32.40827],
polyarnye: [67.36592, 32.50103],
zasheek: [67.4066, 32.54991]
};
const LEG_KP = [[32.40830,67.15086],[32.40919,67.15123],[32.40530,67.15211],[32.40164,67.15592],[32.40263,67.15627],[32.40234,67.15671],[32.40365,67.16009],[32.40439,67.16077],[32.40083,67.16153],[32.39347,67.16167],[32.37834,67.16738],[32.39686,67.17485],[32.41082,67.17577],[32.41996,67.18286],[32.42404,67.18468],[32.43127,67.18609],[32.43515,67.19157],[32.43787,67.19296],[32.43467,67.19463],[32.43517,67.20077],[32.44001,67.20499],[32.44209,67.21166],[32.42869,67.22053],[32.42562,67.22192],[32.42445,67.22492],[32.41760,67.22676],[32.41025,67.23310],[32.40865,67.23699],[32.41144,67.24032],[32.42732,67.24995],[32.43369,67.25293],[32.43263,67.25523],[32.43282,67.26107],[32.44726,67.27306],[32.45055,67.27809],[32.45315,67.28023],[32.45327,67.28199],[32.45202,67.28569],[32.44925,67.29019],[32.44998,67.29238],[32.45527,67.29506],[32.46552,67.29702],[32.48543,67.30690],[32.49115,67.31659],[32.49412,67.31822],[32.49437,67.31942],[32.49295,67.32024],[32.47807,67.32475],[32.46672,67.33477],[32.46639,67.33576],[32.46686,67.33634],[32.47102,67.33898],[32.46966,67.34397],[32.46744,67.34585],[32.46606,67.34825],[32.46588,67.35182],[32.46764,67.35447],[32.47301,67.35645],[32.48066,67.36185],[32.48653,67.36436],[32.48729,67.36651],[32.48942,67.36782],[32.49137,67.36657],[32.49723,67.36592],[32.50077,67.36628],[32.50049,67.36658],[32.50172,67.36733],[32.50322,67.36675],[32.50112,67.36633],[32.50144,67.36598]];
const LEG_PZ = [[32.50144,67.36598],[32.50112,67.36633],[32.50322,67.36675],[32.50749,67.37359],[32.50798,67.37512],[32.50647,67.37756],[32.50977,67.38124],[32.51700,67.38728],[32.52852,67.39091],[32.53133,67.39241],[32.53116,67.39391],[32.52513,67.39734],[32.53544,67.39912],[32.54459,67.39914],[32.55436,67.40345],[32.55208,67.40677],[32.54915,67.40679]];

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,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

@@ -0,0 +1,56 @@
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 openMap() {
open(MapActivity.class);
}
@JavascriptInterface
public void openAbout() {
open(AboutActivity.class);
}
/** «2.6.5 (сборка 14)» — чтобы текст не расходился со сборкой. */
@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

@@ -3,9 +3,6 @@ package ru.ua1zbe.bus127;
import android.app.Activity;
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
@@ -13,13 +10,16 @@ public class MainActivity extends Activity {
super.onCreate(savedInstanceState);
// экран не гаснет, пока открыто расписание
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
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);
requestNotificationsPermission();
setContentView(Webs.create(this, Webs.URL_MAIN));
}
// Android 13+ требует явного согласия на показ уведомлений
private void requestNotificationsPermission() {
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,19 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.os.Bundle;
import android.view.WindowManager;
/**
* Карта маршрута с положением автобуса. Рисуется той же страницей расписания
* в режиме #map, поэтому шапка, меню и тема совпадают с остальными экранами.
* Сеть нужна только здесь: стиль и тайлы VK Карт грузятся извне.
*/
public class MapActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
setContentView(Webs.create(this, Webs.URL_MAP));
}
}

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,53 @@
package ru.ua1zbe.bus127;
import android.app.Activity;
import android.util.Log;
import android.webkit.ConsoleMessage;
import android.webkit.WebChromeClient;
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_MAP = "file:///android_asset/index.html#map";
/** Описание приложения и версия. */
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);
// Страница лежит в assets и грузится по file://, а карта (#map) тянет
// стиль, тайлы и шрифты VK Карт с https. Без этого file://-страница
// не может обращаться к внешним адресам, и карта остаётся пустой.
settings.setAllowUniversalAccessFromFileURLs(true);
webView.setWebViewClient(new WebViewClient());
webView.setWebChromeClient(new WebChromeClient() {
// страница — это весь интерфейс, поэтому её ошибки и предупреждения
// полезно видеть в logcat: иначе сбой на экране выглядит как «ничего не работает»
@Override
public boolean onConsoleMessage(ConsoleMessage m) {
if (m.messageLevel() == ConsoleMessage.MessageLevel.ERROR
|| m.messageLevel() == ConsoleMessage.MessageLevel.WARNING) {
Log.w("Bus127JS", m.messageLevel() + " " + m.message()
+ " (" + m.sourceId() + ":" + m.lineNumber() + ")");
return true;
}
return false;
}
});
webView.addJavascriptInterface(new BusBridge(activity), "Bus");
webView.loadUrl(url);
return webView;
}
}

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,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Автобус 127</string>
<string name="push_channel_id">bus127_push</string>
<string name="push_channel_name">Пуш-уведомления</string>
<string name="map_title">Карта</string>
<string name="about_title">О приложении</string>
</resources>

View File

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