40 Commits
v2.6.1 ... main

Author SHA1 Message Date
7d96793c27 Версия 2.6.6, сборка 15 2026-09-30 09:53:49 +00:00
4549506d6d Комментарий к версии: 2.6.6 (сборка 15) 2026-09-30 09:53:48 +00:00
d07d9407cf Системная «назад» спрашивает страницу, а не закрывает приложение 2026-09-30 09:53:47 +00:00
20ed52384a Карта в меню, чистка повторов, подпись разработчика 2026-09-30 09:53:44 +00:00
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 1401 additions and 54 deletions

View File

@@ -12,6 +12,17 @@
- **Автоопределение дня недели** — будни/выходные. - **Автоопределение дня недели** — будни/выходные.
- **Полное расписание** — по кнопке раскрывается вся таблица - **Полное расписание** — по кнопке раскрывается вся таблица
(4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша). (4 остановки: Кандалакша · ПЗ→Зашеек · Зашеек · ПЗ→Кандалакша).
- **Карта с автобусами** — пункт меню «Карта» открывает VK Карты: видно
пройденный участок маршрута и автобусы прямо сейчас (без подписей остановок —
они были бы только шумом). Положение считается **по расписанию** (приложение
не берёт координаты с GPS), поэтому это оценка с точностью до нескольких
минут; внизу перечислены автобусы с направлением и временем прибытия.
Обновление каждые 20 сек.
- **Пуш-уведомления** — через RuStore Push, см. раздел ниже.
- **Меню** — кнопка `⋮` в шапке открывает выпадающее окошко с пунктами
«Карта» и «О приложении». Меню сворачивается после выбора пункта, по нажатию
вне его и по кнопке «‹» (которая в этом случае только закрывает меню,
а не уходит назад).
- Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин). - Тёмная тема, живые часы МСК, подсветка ближайших рейсов (< 15 мин).
## Технические данные ## Технические данные
@@ -24,6 +35,65 @@
| Сборка | 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` при старте. Отдельного создания топика
в консоли не требуется — публикация в него проходит и для несуществующего.
- Если в запросе есть секция `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 │ ├── build.gradle
│ └── src/main/ │ └── src/main/
│ ├── AndroidManifest.xml │ ├── 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/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 │ └── res/values/strings.xml
└── settings.gradle, build.gradle, gradle.properties └── settings.gradle, build.gradle, gradle.properties
web/index.html # веб-версия (та же страница, для браузера) 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" applicationId "ru.ua1zbe.bus127"
minSdk 26 minSdk 26
targetSdk 34 targetSdk 34
versionCode 10 versionCode 15
versionName "2.6.1" versionName "2.6.6"
} }
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,22 +1,58 @@
<?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"
android:theme="@android:style/Theme.Material.NoActionBar"
android:configChanges="orientation|screenSize|keyboardHidden"> android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>

View File

@@ -56,6 +56,122 @@
.fullbtn .chev{float:right;transition:.2s;color:var(--muted)} .fullbtn .chev{float:right;transition:.2s;color:var(--muted)}
.fullbtn.open .chev{transform:rotate(180deg)} .fullbtn.open .chev{transform:rotate(180deg)}
/* кнопка карты — под блоком полного расписания, только в приложении */
#mapBtn{display:none;margin:6px 0 6px}
body.app #mapBtn{display:block}
.mapbtn::before{content:"⌖ ";color:var(--accent2)}
/* строка заголовка: стрелка назад слева, меню справа, название ровно по центру */
.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}
/* но в справке стрелка «назад» нужна и должна быть кликабельной */
body.app.showhelp .iconbtn.back{visibility:visible}
/* троеточие в круглой кнопке приподнято: рамка остаётся на одной линии с названием */
#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}
/* внизу «О приложении» те же даты и источник уже написаны своим текстом,
общая сноска про расписание тут только дублирует их */
body.about footer{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}
.aboutcard p.dev{color:var(--muted);font-size:13px;text-align:center;margin:18px 0 0}
.aboutcard h3{margin:16px 0 6px;font-size:14px;color:var(--accent2)}
.aboutcard a{color:var(--accent2)}
/* «Справка» — тот же экран, что и расписание, просто другой блок */
.helpcard{display:none;background:var(--card);border:1px solid var(--line);
border-radius:14px;padding:16px;margin-bottom:10px}
body.app.showhelp .helpcard{display:block}
body.app.showhelp .pickers,body.app.showhelp .where,body.app.showhelp .dir,
body.app.showhelp .fullbtn,body.app.showhelp #fullTable,
body.app.showhelp #mapBtn,body.app.showhelp footer{display:none}
.helpcard h2{margin:0 0 4px;font-size:17px}
.helpcard h3{margin:16px 0 6px;font-size:14px;color:var(--accent2)}
.helpcard h3:first-of-type{margin-top:12px}
.helpcard p{margin:0 0 10px;font-size:14px;line-height:1.5}
/* экран «Карта»: страница целиком занята картой, шапка остаётся */
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}
/* метки на карте. Корень двигает библиотека, вид задаёт внутренний блок */
.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; .tablecard{background:var(--card);border:1px solid var(--line);border-radius:14px;
overflow:hidden;display:none;margin-bottom:8px} overflow:hidden;display:none;margin-bottom:8px}
/* переключатель типа дня в полном расписании */ /* переключатель типа дня в полном расписании */
@@ -85,7 +201,19 @@
<body> <body>
<div class="wrap"> <div class="wrap">
<header> <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="mMap">Карта маршрута</button>
<button class="menuitem" id="mHelp">Справка</button>
<button class="menuitem" id="mAbout">О приложении</button>
</nav>
</div>
<div class="sub">Кандалакша — Полярные Зори — Зашеек</div> <div class="sub">Кандалакша — Полярные Зори — Зашеек</div>
<div class="clock" id="clock">--:--:-- <small>МСК</small></div> <div class="clock" id="clock">--:--:-- <small>МСК</small></div>
<div class="day" id="dayBadge"></div> <div class="day" id="dayBadge"></div>
@@ -110,37 +238,133 @@
</div> </div>
<div class="thead"> <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><b>Полярные Зори</b>отпр. в Канд.</div>
</div> </div>
<div id="tableBody"></div> <div id="tableBody"></div>
</section> </section>
<footer>Расписание действует с 30.09.2020 · время московское (МСК)</footer> <button class="fullbtn mapbtn" id="mapBtn">Карта маршрута</button>
<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 id="mapContent">
<div class="head" id="mapHead">Сейчас автобусов нет</div>
<div class="rows" id="mapRows"></div>
</div>
</div>
</div>
</section>
<section class="helpcard" id="helpBox">
<h2>Справка</h2>
<h3>Как читать расписание</h3>
<p>Таблица и списки показывают официальное время <b>отправлений</b>.
Время прибытия перевозчик не публикует, поэтому приложение считает его
само: до Зашейка — как выезд с Полярных Зорь плюс 20 минут, до
Кандалакши — плюс 50 минут.</p>
<p>Одна остановка — исключение. Когда автобус приезжает на Полярные Зори,
он сразу уходит дальше, поэтому время прибытия совпадает с отправлением
следующей остановки. В Зашейке автобус задерживается, и там прибытие
считается по времени в пути, а не по расписанию.</p>
<p>Из-за этого интервалы в обе стороны по одной дороге отличаются
на 5–10 минут. Так и есть в официальном расписании, это не ошибка
приложения.</p>
<h3>Время прибытия в карточках</h3>
<p>В списке рейсов под заголовком направления показано время прибытия
в Зашеек или в Кандалакшу — серым под временем отправления. Время
московское (МСК).</p>
<h3>Положение автобусов на карте</h3>
<p>GPS у приложения нет. Положение считается по расписанию: между
известными точками маршрута координаты интерполируются по времени рейса,
доля пути — по длине дороги, поэтому метка идёт по трассе, а не по прямой.
В Зашейке автобус стоит, пока не подошло время обратного выезда.</p>
<p>Точность — до нескольких минут. В пробки реальное отставание больше,
и метка может показывать автобус позади или впереди машины.</p>
<p>Список под картой обновляется каждые 20 секунд: для каждого автобуса
в пути указано направление и время прибытия на следующую остановку.</p>
<h3>Когда обновляется расписание</h3>
<p>Часы, ближайшие рейсы и положение автобусов обновляются автоматически.
Список рейсов — раз в минуту, часы — раз в секунду. В приложении нет
кнопки обновления: перезапускать ничего не нужно.</p>
<h3>Где брать точные данные</h3>
<p>Время отправления перевозчик меняет без предупреждения. Сроки
действия расписания и телефон диспетчерской службы указаны
в разделе «О приложении» — уточнять время лучше там.</p>
</section>
<section class="aboutcard" id="aboutBox">
<h2>О приложении</h2>
<div class="ver" id="aboutVer"></div>
<p>Расписание автобуса №127 по маршруту Кандалакша — Полярные Зори — Зашеек.
Ближайшие рейсы по вашей остановке, полное расписание по будням, субботам
и воскресеньям, а также карта с положением автобусов в пути.</p>
<p>Всё время — московское (МСК). Данные обновляются автоматически,
интернет нужен только для карты.</p>
<h3>Расписание</h3>
<p>Действует летнее расписание <b>с 1 мая по 31 октября 2026 года</b>.
Отправления взяты из данных диспетчерской службы перевозчика,
время прибытия приложение рассчитывает само — подробно в «Справке».</p>
<p>Источник: <a href="https://kandalaksha.org/static/auto.html" target="_blank" rel="noopener">kandalaksha.org/static/auto.html</a><br>
Телефон диспетчерской службы: 8(921) 174-26-00, 8(911) 303-63-28</p>
<h3>Что внутри</h3>
<p>Карта и тайлы — VK Карты, геометрия дороги — OpenStreetMap.
Пуш-уведомления приходят через RuStore Push: для этого нужен
установленный и авторизованный RuStore на устройстве.</p>
<p class="muted">Приложение не использует геолокацию и не передаёт
данные о вашем местоположении: положение автобусов вычисляется
по расписанию на устройстве.</p>
<p class="dev">Разработчик © UA1ZBE</p>
</section>
<footer>Летнее расписание с 01.05.2026 по 31.10.2026 · отправления по данным
диспетчерской службы перевозчика · время московское (МСК)</footer>
</div> </div>
<script> <script>
// Расписание по типам дней недели. // Расписание по типам дней недели. Взято с официального расписания
// Колонки: отправление из Кандалакши · Полярные Зори (в Зашеек) · // (данные диспетчерской службы перевозчика ООО «Гарант-Авто»,
// прибытие в Зашеек · Полярные Зори (обратно). // kandalaksha.org/static/auto.html), летнее расписание
// с 01.05.2026 по 31.10.2026.
// //
// Будни — полная таблица маршрута. По субботе и воскресенью в репозитории // Опубликованы только ОТПРАВЛЕНИЯ, и четыре колонки склеиваются
// хранились только отправления из Полярных Зорь и прибытия в Зашеек // в один круг рейса попарно, 1:1 — проверено, что моменты строго
// (списки в app/src/main/java/ru/ua1zbe/bus127/MainActivity.kt), поэтому // возрастают в каждой строке:
// первая и четвёртая колонки восстановлены по тем же правилам, что и для // кол0 — отправление из Кандалакши
// будней, и требуют сверки с официальным расписанием: // кол1 — отправление из Полярных Зорь в Зашеек
// кол0 = кол1 − 50 мин (Кандалакша → Полярные Зори занимает ровно 50 мин) // кол2 — отправление ИЗ Зашейка (обратно в Полярные Зори)
// кол3 = кол2 + 20 мин (так разность кол3−кол2 выглядит в 8 строках из 13) // кол3 — отправление из Полярных Зорь в Кандалакшу
//
// Важно: кол2 — это выезд из Зашейка, а не прибытие в него.
// Раньше колонка была подписана как прибытие, и из-за этого один и тот же
// автобус «прибывал» и «уезжал» в Зашеек в одну минуту, а интервалы
// туда и обратно выходили разными на одной и той же дороге.
// Прибытие в Зашеек в расписании не публикуется — оно считается
// отдельно, см. arrZasheek ниже.
const SCHEDULES = { const SCHEDULES = {
weekday: [ weekday: [
["05:20","06:20","06:30","06:50"], ["05:20","06:20","06:30","06:50"],
["06:10","07:00","07:25","07:45"], ["06:10","07:00","07:25","07:45"],
["07:40","08:30","09:00","09:20"], ["07:40","08:30","09:00","09:20"],
["08:00","08:50","09:20","09:50"],
["09:45","10:35","11:05","11:25"], ["09:45","10:35","11:05","11:25"],
["11:00","11:50","12:20","12:40"], ["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"], ["13:30","14:20","14:50","15:10"],
["14:00","14:50","15:25","15:50"], ["14:00","14:50","15:25","15:50"],
["15:30","16:20","17:00","17:25"], ["15:30","16:20","17:00","17:25"],
@@ -150,23 +374,23 @@ const SCHEDULES = {
], ],
saturday: [ saturday: [
["06:10","07:00","07:25","07:45"], ["06:10","07:00","07:25","07:45"],
["07:30","08:20","08:45","09:05"], ["07:30","08:20","08:45","09:10"],
["08:45","09:35","10:05","10:25"], ["08:45","09:35","10:05","10:30"],
["10:10","11:00","11:30","11:50"], ["10:10","11:00","11:30","11:55"],
["12:30","13:20","13:50","14:10"], ["12:30","13:20","13:50","14:15"],
["14:00","14:50","15:45","16:05"], ["14:00","14:50","15:45","16:10"],
["15:30","16:20","16:45","17:05"], ["15:30","16:20","16:45","17:10"],
["17:00","17:50","18:20","18:40"], ["17:00","17:50","18:20","18:45"],
["18:10","19:00","19:30","19:50"], ["18:10","19:00","19:30","19:55"],
["20:20","21:10","21:40","22:00"] ["20:20","21:10","21:40","22:05"]
], ],
sunday: [ sunday: [
["06:10","07:00","07:25","07:45"], ["06:10","07:00","07:25","07:45"],
["08:45","09:35","10:05","10:25"], ["08:45","09:35","10:05","10:30"],
["12:30","13:20","13:50","14:10"], ["12:30","13:20","13:50","14:15"],
["14:00","14:50","15:25","15:45"], ["14:00","14:50","15:25","15:50"],
["16:50","17:40","18:10","18:30"], ["16:50","17:40","18:10","18:35"],
["20:20","21:10","21:40","22:00"] ["20:20","21:10","21:40","22:05"]
] ]
}; };
const DAY_KEYS = ["weekday","saturday","sunday"]; const DAY_KEYS = ["weekday","saturday","sunday"];
@@ -176,21 +400,38 @@ const TRIPS_SHOWN = 5; // сколько ближайших рейсов по
// Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет: // Время в пути Полярные Зори ↔ Кандалакша. В таблице расписания его нет:
// в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут // в 12 строках из 13 Кандалакша → ПЗ занимает ровно 50 минут
// (в первой строке 60 — по всей видимости опечатка). // (в первой строке 60 — по всей видимости опечатка в самом расписании).
const RIDE_PZ_KAND = 50; 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 = { const DIRECTIONS = {
// От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка // От Кандалакши и Зашейка все рейсы идут через Полярные Зори — одна карточка
"Кандалакша": [ "Кандалакша": [
{to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори", {to:"Полярные Зори, Зашеек", dep:0, via:"через Полярные Зори",
arrs:[{label:"Полярные Зори", col:1}, arrs:[{label:"Полярные Зори", col:1},
{label:"Зашеек", col:2}]} {label:"Зашеек", arrZ:true}]}
], ],
"Полярные Зори": [ "Полярные Зори": [
{to:"Зашеек", dep:1, via:"≈ 30 мин в пути", {to:"Зашеек", dep:1, via:"≈ 20 мин в пути",
arrs:[{label:"Зашеек", col:2}]}, arrs:[{label:"Зашеек", arrZ:true}]},
{to:"Кандалакша", dep:3, via:"≈ 50 мин в пути", {to:"Кандалакша", dep:3, via:"≈ 50 мин в пути",
arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]} arrs:[{label:"Кандалакша", offset:RIDE_PZ_KAND}]}
], ],
@@ -271,7 +512,9 @@ function render(){
.map((r,ri)=>{ .map((r,ri)=>{
const dep = toMin(r[d.dep]); const dep = toMin(r[d.dep]);
const arrs = d.arrs.map(a=>{ 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 {label:a.label, t:fmtHM(base + (a.offset||0))};
}); });
return {dep, arrs, ri}; return {dep, arrs, ri};
@@ -346,12 +589,424 @@ document.getElementById("fullBtn").addEventListener("click",()=>{
b.classList.toggle("open", !open); 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;
});
// «Справка» — блок на этом же экране. Снимаем её и кнопкой «‹»,
// и аппаратной «назад»: для этого кладём в историю браузера состояние,
// чтобы система отдала его нам, а не закрыла приложение.
const helpOn = () => document.body.classList.contains("showhelp");
const showHelp = on => {
document.body.classList.toggle("showhelp", on);
if (on) window.scrollTo(0, 0);
};
document.getElementById("mMap").addEventListener("click",()=>{ closeMenu(); Bus.openMap(); });
document.getElementById("mHelp").addEventListener("click",()=>{
closeMenu();
showHelp(true);
history.pushState({ help: 1 }, "");
});
window.addEventListener("popstate", () => { if (helpOn()) showHelp(false); });
const hideHelp = () => {
if (!helpOn()) return false;
showHelp(false);
if (history.state && history.state.help) history.back(); // убрать и из истории
return true;
};
// Один шаг «назад»: справка, потом меню. false — значит, уходить из приложения.
const backStep = () => {
if (hideHelp()) return true;
if (!menuBox.hidden) { closeMenu(); return true; }
return false;
};
document.getElementById("navBack").addEventListener("click",()=>{
// «назад» сначала закрывает справку, потом сворачивает меню,
// и только потом уходит назад
if (backStep()) return;
Bus.goBack();
});
// Системная кнопка «назад» WebView не перехватывает, поэтому Android
// спрашивает у страницы то же самое, что и стрелка «‹» (MainActivity).
window.__busBack = backStep;
document.getElementById("mAbout").addEventListener("click",()=>{ closeMenu(); Bus.openAbout(); });
document.getElementById("mapBtn").addEventListener("click",()=>Bus.openMap());
document.addEventListener("click",e=>{
if (!e.target.closest(".menu") && !e.target.closest("#navMenu")) closeMenu();
});
}
let activeDay = todayKey(); // по умолчанию — реальный день недели let activeDay = todayKey(); // по умолчанию — реальный день недели
selectDay(activeDay); selectDay(activeDay);
tickClock(); renderDay(); render(); tickClock(); renderDay(); render();
setInterval(tickClock,1000); setInterval(tickClock,1000);
setInterval(render,30000); setInterval(render,30000);
setInterval(renderDay,60000); 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;
}
// Свёрнутая шторка подстраивается под содержимое, а список автобусов
// обновляется каждые 20 секунд. Ширину/высоту слушаем и у DOM, и у окна:
// первый срабатывает при переносе строк, второй — при смене шрифта.
// Заполняется в initSheet, ниже по файлу.
let setSheetHeight = null;
const refitSheet = () => { if (setSheetHeight) setSheetHeight(null); };
if (typeof ResizeObserver === "function") {
const st = document.getElementById("mapContent");
if (st) new ResizeObserver(refitSheet).observe(st);
}
window.addEventListener("resize", refitSheet);
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>`;
refitSheet();
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("");
refitSheet(); // строк могло стать больше или меньше
}
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 status = sheet.querySelector(".mapstatus");
const content = document.getElementById("mapContent");
const max = () => Math.max(60, Math.round(box.clientHeight * 0.72));
// Свёрнутое состояние — ровно по содержимому: ручка, список автобусов и
// внутренние отступы прокручиваемого блока. Высоту берём у #mapContent,
// а не у .mapstatus: тот растягивается флексом, и его scrollHeight при
// раскрытой шторке равен clientHeight — из-за этого сворачивание
// «по содержимому» заканчивалось бы раздуванием до максимума.
const min = () => {
const cs = getComputedStyle(status);
const pad = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
return Math.min(max(), Math.round(grab.getBoundingClientRect().height
+ content.offsetHeight + pad));
};
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); });
// Высота шторки. next === null — просто пересчитать по текущему содержимому,
// не трогая состояние «раскрыта/свёрнута»: этим пользуется список автобусов.
setSheetHeight = next => {
set(next === null ? (open ? max() : min()) : next, open);
};
// поворот экрана или смена высоты окна — пересчитать предел раскрытия
window.addEventListener("resize", ()=> set(open ? max() : min(), open));
set(min(), false);
}
if (MODE === "map") { initMap(); initSheet(); }
</script> </script>
</body> </body>
</html> </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.6 (сборка 15)» — чтобы текст не расходился со сборкой. */
@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,23 +3,47 @@ package ru.ua1zbe.bus127;
import android.app.Activity; import android.app.Activity;
import android.os.Bundle; import android.os.Bundle;
import android.view.WindowManager; import android.view.WindowManager;
import android.webkit.WebSettings;
import android.webkit.WebView; import android.webkit.WebView;
import android.webkit.WebViewClient;
public class MainActivity extends Activity { public class MainActivity extends Activity {
private WebView web;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
// экран не гаснет, пока открыто расписание // экран не гаснет, пока открыто расписание
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
WebView webView = new WebView(this); requestNotificationsPermission();
WebSettings settings = webView.getSettings(); web = Webs.create(this, Webs.URL_MAIN);
settings.setJavaScriptEnabled(true); setContentView(web);
settings.setDomStorageEnabled(true); }
settings.setSupportZoom(false);
webView.setWebViewClient(new WebViewClient()); /**
webView.loadUrl("file:///android_asset/index.html"); * WebView системную кнопку «назад» не перехватывает — без этого нажатие
setContentView(webView); * сразу закрывало бы приложение, даже когда открыта справка. Спрашиваем
* у страницы, закрыла ли она что-нибудь сама, и выходим только если нет.
*/
@Override
public void onBackPressed() {
if (web == null) {
super.onBackPressed();
return;
}
web.evaluateJavascript(
"(function(){return (window.__busBack && window.__busBack()) ? 1 : 0})()",
value -> {
String v = String.valueOf(value).replace("\"", "").trim();
if (!"1".equals(v)) finish();
});
}
// 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"?> <?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>
<string name="map_title">Карта</string>
<string name="about_title">О приложении</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'