diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74f2c6e..cd0a432 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,8 +129,18 @@ jobs: - name: Verify App Bundle signature run: | - jarsigner -verify -strict -verbose:summary \ - build/app/outputs/bundle/release/app-release.aab | tail -20 + # No `-strict`, and no pipe. An app-signing certificate is self-signed + # by definition, which -strict counts as a severe warning and exits + # non-zero for; and piping into `tail` handed the step tail's exit + # code, so this check could never fail either way. + jarsigner -verify -verbose:summary \ + build/app/outputs/bundle/release/app-release.aab > /tmp/jarsig.txt + tail -20 /tmp/jarsig.txt + grep -q "jar verified" /tmp/jarsig.txt \ + || { echo "::error::jarsigner did not report the bundle as verified"; exit 1; } + if grep -qi "CN=Android Debug" /tmp/jarsig.txt; then + echo "::error::bundle was signed with the debug key"; exit 1 + fi - name: Report signing details run: | diff --git a/.github/workflows/sync-content.yml b/.github/workflows/sync-content.yml index b9c4c7d..a3c7e1b 100644 --- a/.github/workflows/sync-content.yml +++ b/.github/workflows/sync-content.yml @@ -1,10 +1,11 @@ name: Sync content -# Re-scrapes the three bundled datasets once a week so the offline copies do not +# Re-scrapes the four bundled datasets once a week so the offline copies do not # drift from their sources: # # assets/data/guide.json <- srb.guide (74 articles) # assets/data/places.json <- stats.srb.guide/map (relocant businesses) +# assets/data/smoking.json <- Google My Maps KML (non-smoking venues) # assets/data/tg_chats.json <- stats.srb.guide (chat directory) # # Everything is validated before anything is committed — see @@ -30,6 +31,7 @@ env: FLUTTER_VERSION: "3.47.2" GUIDE: assets/data/guide.json PLACES: assets/data/places.json + SMOKING: assets/data/smoking.json CHATS: assets/data/tg_chats.json jobs: @@ -52,6 +54,7 @@ jobs: run: | cp "$GUIDE" /tmp/guide-baseline.json cp "$PLACES" /tmp/places-baseline.json + cp "$SMOKING" /tmp/smoking-baseline.json cp "$CHATS" /tmp/chats-baseline.json - name: Scrape srb.guide @@ -63,6 +66,9 @@ jobs: - name: Scrape the places catalogue run: dart run tool/sync_places.dart + - name: Scrape the non-smoking map + run: dart run tool/sync_smoking.dart + - name: Scrape the chat directory run: dart run tool/sync_chats.dart @@ -81,6 +87,10 @@ jobs: places_after=$(jq -S -c '.places' "$PLACES" | sha256sum | cut -d' ' -f1) [ "$places_before" != "$places_after" ] && changed="$changed $PLACES" + smoking_before=$(jq -S -c '.places' /tmp/smoking-baseline.json | sha256sum | cut -d' ' -f1) + smoking_after=$(jq -S -c '.places' "$SMOKING" | sha256sum | cut -d' ' -f1) + [ "$smoking_before" != "$smoking_after" ] && changed="$changed $SMOKING" + chats_before=$(jq -S -c '.' /tmp/chats-baseline.json | sha256sum | cut -d' ' -f1) chats_after=$(jq -S -c '.' "$CHATS" | sha256sum | cut -d' ' -f1) [ "$chats_before" != "$chats_after" ] && changed="$changed $CHATS" @@ -98,6 +108,7 @@ jobs: { echo "articles=$(jq '[.ru[].items[]] | length' "$GUIDE")" echo "places=$(jq '.places | length' "$PLACES")" + echo "smoking=$(jq '.places | length' "$SMOKING")" echo "chats=$(jq 'length' "$CHATS")" } >> "$GITHUB_OUTPUT" @@ -111,6 +122,7 @@ jobs: echo "|---|---|" echo "| guide.json | ${{ steps.diff.outputs.articles }} articles |" echo "| places.json | ${{ steps.diff.outputs.places }} places |" + echo "| smoking.json | ${{ steps.diff.outputs.smoking }} venues |" echo "| tg_chats.json | ${{ steps.diff.outputs.chats }} chats |" echo "" echo "Changed: \`${{ steps.diff.outputs.files }}\`" @@ -118,7 +130,7 @@ jobs: - name: Discard a no-op scrape if: steps.diff.outputs.changed != 'true' - run: git checkout -- "$GUIDE" "$PLACES" "$CHATS" + run: git checkout -- "$GUIDE" "$PLACES" "$SMOKING" "$CHATS" - name: Commit to master if: steps.diff.outputs.changed == 'true' && inputs.open_pull_request != true @@ -133,6 +145,7 @@ jobs: Automated weekly scrape. guide ${{ steps.diff.outputs.articles }} articles · places ${{ steps.diff.outputs.places }} · + smoking ${{ steps.diff.outputs.smoking }} · chats ${{ steps.diff.outputs.chats }}." git push @@ -150,6 +163,7 @@ jobs: |---|---| | `guide.json` | ${{ steps.diff.outputs.articles }} articles | | `places.json` | ${{ steps.diff.outputs.places }} places | + | `smoking.json` | ${{ steps.diff.outputs.smoking }} venues | | `tg_chats.json` | ${{ steps.diff.outputs.chats }} chats | The guide was validated by `tool/validate_guide.dart` against the @@ -157,4 +171,5 @@ jobs: add-paths: | ${{ env.GUIDE }} ${{ env.PLACES }} + ${{ env.SMOKING }} ${{ env.CHATS }} diff --git a/.gitignore b/.gitignore index 24476c5..a771f28 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Signed artifacts produced by tool/build_release.ps1 +/dist/ diff --git a/README.md b/README.md index bfa0cc7..c65b025 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,22 @@ Serbian Railways' own site is awkward on a phone. The app talks to the same endp JSON station lookup, plus route search and a per-station departure/arrival board. Stations you have used are remembered, so the usual trip takes two taps. -### Map of relocant-run businesses -362 places — cafés, shops, salons, garages — from the stats.srb.guide catalogue, on an -**OpenStreetMap** map. OSM needs no API key and no billing account, unlike the Google Maps SDK. -The catalogue is bundled, so the list and filters work offline; only the tiles need a connection. -Each place links out to Google Maps for directions. +The lookup only understands Latin — its own front end strips anything else out of the term, and an +empty term is answered with the entire network. So the app asks for that list once (396 stations, +15 KB), caches it for a week, and matches on device: Cyrillic is transliterated, diacritics are +folded, and each word gets one character of slack, so **Белград** finds `BEOGRAD CENTAR` and +`centar` matches inside the name. + +### Map of relocant-run businesses and non-smoking venues +362 places — cafés, shops, salons, garages — from the stats.srb.guide catalogue, plus 145 +non-smoking venues from the *Lokali bez dima* map, on one **OpenStreetMap** map. OSM needs no API +key and no billing account, unlike the Google Maps SDK. Both catalogues are bundled, so the list +and filters work offline; only the tiles need a connection. Each place links out to Google Maps +for directions. + +Filter chips cover the smoking policy (banned outright, or smokeless devices only) alongside the +business categories. A venue that is on both lists is matched by name and proximity and shown as +one pin, not two. ### Visa-free stay calculator Enter your entry date and the app tracks the remaining days of the 29-day visa-free window, shows @@ -102,12 +113,6 @@ Address registration means filling in the same form by hand every time you move. your data once and renders a ready-to-print `.docx` from a bundled template (`assets/template/cardboard.docx`) using `docx_template`, then hands it to the system share sheet. -### Map of useful places -A curated set of Google Maps links — exchange offices, cafés, expat-friendly venues — defined in -`assets/data/locations.json` and shown in an embedded `webview_flutter` view with a dropdown. -Requests that the page hands off to a native app (`intent://`, `geo:`) are opened through the -platform instead of failing inside the web view. - ### Telegram directory 432 relocation chats and channels (`assets/data/tg_chats.json`), synced weekly from the stats.srb.guide catalogue along with their topic, size and whether they are still active, and @@ -135,7 +140,7 @@ Russian, which is the language the guide itself is written in. | Scraping | `http` + `html` — exchange offices, the railway timetable, the sync tools | | Reminders | `flutter_local_notifications`, `timezone`, `flutter_timezone` | | Documents | `docx_template` + `xml`, `path_provider`, `open_file` / `share_plus` to export | -| Integrations | `add_2_calendar`, `url_launcher`, `webview_flutter`, `photo_view` | +| Integrations | `add_2_calendar`, `url_launcher`, `photo_view` | | CI | GitHub Actions — checks, weekly content sync, signed release, daily parser health | --- @@ -165,10 +170,10 @@ lib/ │ ├── deadlines.dart # reminders │ ├── exchange_rate.dart # best rate, NBS reference, converter │ ├── trains.dart # route search + station board -│ ├── places.dart # OpenStreetMap map of the catalogue +│ ├── places.dart # OpenStreetMap map: businesses + non-smoking │ ├── calculator.dart # visa-free day counter + calendar export │ ├── white_cardboard.dart # .docx form generation -│ ├── services.dart, map.dart, tg_chats.dart +│ ├── services.dart, tg_chats.dart │ └── author.dart, settings.dart ├── service/ │ ├── exchange_rate_service.dart # parallel fetch, offline cache, best rate @@ -185,6 +190,7 @@ lib/ tool/ ├── sync_guide.dart # srb.guide -> assets/data/guide.json ├── sync_places.dart # map catalogue -> assets/data/places.json +├── sync_smoking.dart # non-smoking map-> assets/data/smoking.json ├── sync_chats.dart # chat directory -> assets/data/tg_chats.json └── validate_guide.dart # sanity gate before content is committed test/ # unit tests + network-tagged live checks @@ -222,7 +228,16 @@ dart run tool/sync_places.dart # 362 map places dart run tool/sync_chats.dart # 432 Telegram chats ``` -Release builds and signing are documented in [`docs/RELEASE.md`](docs/RELEASE.md). +Build a signed, Play-ready bundle (Windows): + +```powershell +.\tool\create_upload_key.ps1 # once, if there is no upload key yet +.\tool\build_release.ps1 # -> dist/srbguide-.aab and .apk +``` + +Signing, the checks that guard it and the Play upload procedure are in +[`docs/RELEASE.md`](docs/RELEASE.md); what changed in each release is in +[`docs/CHANGELOG.md`](docs/CHANGELOG.md). --- @@ -231,7 +246,7 @@ Release builds and signing are documented in [`docs/RELEASE.md`](docs/RELEASE.md | Workflow | Trigger | What it does | |---|---|---| | `ci.yml` | push / PR | format, analyze, tests, guide validation, debug build | -| `sync-content.yml` | weekly | re-scrapes all three datasets, validates, commits real changes | +| `sync-content.yml` | weekly | re-scrapes all four datasets, validates, commits real changes | | `release.yml` | tag `v*` | signed AAB + APK, verifies the signature, draft release | | `parsers.yml` | daily | runs the parsers against the live sites, opens an issue on failure | diff --git a/android/.gitignore b/android/.gitignore index 6f56801..4116c85 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,3 +11,6 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# Kotlin Gradle plugin scratch dir (session locks, compiler error logs) +/.kotlin/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 780fda7..edecb98 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -34,7 +34,11 @@ android { applicationId = "com.alakey.serbiaguide" minSdk = flutter.minSdkVersion targetSdk = 36 - versionCode = 11 + // Both come from `version:` in pubspec.yaml (`versionName+versionCode`) + // so there is a single place to bump. `flutter build` can override them + // with --build-name / --build-number; tool/build_release.ps1 always + // passes both explicitly. + versionCode = flutter.versionCode versionName = flutter.versionName } diff --git a/assets/data/locations.json b/assets/data/locations.json deleted file mode 100644 index d2d0b9d..0000000 --- a/assets/data/locations.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "iconPath": "usd.png", - "url": "https://www.google.com/maps/search/Мењачница", - "title": "Обменники", - "section": "Карты" - }, - { - "iconPath": "angry.png", - "url": "https://t.ly/YAx6", - "title": "Черный список квартир", - "section": "Карты" - }, - { - "iconPath": "no-smoking.png", - "url": "https://www.google.com/maps/d/viewer?mid=1DhbU4mNbi0OVkoRSpKBqBmWqeRXU5vo&usp=sharing", - "title": "Не курящие", - "section": "Карты" - }, - { - "iconPath": "tea.png", - "url": "https://www.google.com/maps/d/u/0/viewer?mid=12l4BVYg_FV0d9CMeEWEtnJDQioL9804&ll=sharing", - "title": "Русские заведения", - "section": "Карты" - } -] diff --git a/assets/data/smoking.json b/assets/data/smoking.json new file mode 100644 index 0000000..2974fc0 --- /dev/null +++ b/assets/data/smoking.json @@ -0,0 +1,1747 @@ +{ + "source": "https://www.google.com/maps/d/viewer?mid=1DhbU4mNbi0OVkoRSpKBqBmWqeRXU5vo", + "upstream": "https://lokalibezdima.rs", + "syncedAt": "2026-09-06T13:43:04.675541Z", + "places": [ + { + "id": "smoke-ba805bd5", + "name": "Ananda", + "description": "", + "lat": 45.2493376, + "lng": 19.8395276, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2493376,19.8395276", + "smoking": "none" + }, + { + "id": "smoke-64e87574", + "name": "April bar", + "description": "", + "lat": 44.8168485, + "lng": 20.4657284, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8168485,20.4657284", + "smoking": "none" + }, + { + "id": "smoke-294acc15", + "name": "ArkaBarka floating hostel & apartments", + "description": "", + "lat": 44.822369, + "lng": 20.4310824, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.822369,20.4310824", + "smoking": "alternative" + }, + { + "id": "smoke-0d023bdc", + "name": "Artist Specialty Coffee", + "description": "", + "lat": 44.8149429, + "lng": 20.4654855, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8149429,20.4654855", + "smoking": "none" + }, + { + "id": "smoke-c4c8fbeb", + "name": "BIG Novi Sad", + "description": "", + "lat": 45.276412, + "lng": 19.826218, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.276412,19.826218", + "smoking": "none" + }, + { + "id": "smoke-1b3f15cb", + "name": "Bad Sushi", + "description": "", + "lat": 44.8159897, + "lng": 20.466228, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8159897,20.466228", + "smoking": "none" + }, + { + "id": "smoke-56ded63a", + "name": "Block 32", + "description": "", + "lat": 45.2603789, + "lng": 19.8302549, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2603789,19.8302549", + "smoking": "none" + }, + { + "id": "smoke-2a25ffa2", + "name": "Bloom", + "description": "", + "lat": 44.8208969, + "lng": 20.4593476, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8208969,20.4593476", + "smoking": "none" + }, + { + "id": "smoke-2ae06735", + "name": "Bon Vivant", + "description": "", + "lat": 44.820286, + "lng": 20.4650778, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.820286,20.4650778", + "smoking": "none" + }, + { + "id": "smoke-fad1ee05", + "name": "Bookastore", + "description": "", + "lat": 44.8179982, + "lng": 20.4544675, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8179982,20.4544675", + "smoking": "none" + }, + { + "id": "smoke-fa77e0bd", + "name": "Branč", + "description": "", + "lat": 44.8216604, + "lng": 20.4563429, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8216604,20.4563429", + "smoking": "none" + }, + { + "id": "smoke-5e8517cf", + "name": "Bruno bar", + "description": "", + "lat": 45.2422504, + "lng": 19.8252851, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2422504,19.8252851", + "smoking": "none" + }, + { + "id": "smoke-34645bd7", + "name": "Bulevar Books", + "description": "", + "lat": 45.2531798, + "lng": 19.8453733, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2531798,19.8453733", + "smoking": "alternative" + }, + { + "id": "smoke-7c851656", + "name": "Burrito Madre", + "description": "", + "lat": 44.8131067, + "lng": 20.461389, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8131067,20.461389", + "smoking": "none" + }, + { + "id": "smoke-6070297f", + "name": "Cafe \"Zona Industriale\"", + "description": "", + "lat": 44.8025388, + "lng": 20.470947, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8025388,20.470947", + "smoking": "none" + }, + { + "id": "smoke-2f5c01c1", + "name": "Cafe & Factory 11", + "description": "", + "lat": 44.7813329, + "lng": 20.4169093, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7813329,20.4169093", + "smoking": "alternative" + }, + { + "id": "smoke-e6d9f7cc", + "name": "Caffe SANTANA", + "description": "", + "lat": 43.8792079, + "lng": 20.3572178, + "category": "", + "city": "cacak", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=43.8792079,20.3572178", + "smoking": "alternative" + }, + { + "id": "smoke-b1010ab0", + "name": "Cecina CAFFE poslastičarnica", + "description": "", + "lat": 46.1019171, + "lng": 19.6622355, + "category": "", + "city": "subotica", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=46.1019171,19.6622355", + "smoking": "none" + }, + { + "id": "smoke-03993dd4", + "name": "City Poslastičarnica", + "description": "", + "lat": 45.2545942, + "lng": 19.8434065, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2545942,19.8434065", + "smoking": "none" + }, + { + "id": "smoke-d2062848", + "name": "Coffee Shop", + "description": "", + "lat": 44.7986352, + "lng": 20.4875252, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7986352,20.4875252", + "smoking": "none" + }, + { + "id": "smoke-09b104a2", + "name": "Coffeedream", + "description": "", + "lat": 44.8188686, + "lng": 20.4549917, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8188686,20.4549917", + "smoking": "none" + }, + { + "id": "smoke-70bad525", + "name": "Coffeedream", + "description": "", + "lat": 45.2491428, + "lng": 19.8392051, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2491428,19.8392051", + "smoking": "alternative" + }, + { + "id": "smoke-7a20179f", + "name": "Crna Ovca", + "description": "", + "lat": 44.8214195, + "lng": 20.4572955, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8214195,20.4572955", + "smoking": "none" + }, + { + "id": "smoke-144b1996", + "name": "Crni Ovan", + "description": "", + "lat": 45.2560476, + "lng": 19.8482954, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2560476,19.8482954", + "smoking": "none" + }, + { + "id": "smoke-15595aa4", + "name": "Crowne Plaza Belgrade", + "description": "", + "lat": 44.809397, + "lng": 20.4342642, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.809397,20.4342642", + "smoking": "alternative" + }, + { + "id": "smoke-11184d56", + "name": "Cruise Cocktail Bar", + "description": "", + "lat": 44.7959796, + "lng": 20.3995614, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7959796,20.3995614", + "smoking": "alternative" + }, + { + "id": "smoke-1fd47474", + "name": "Curry Souls", + "description": "", + "lat": 44.8162377, + "lng": 20.4568724, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8162377,20.4568724", + "smoking": "alternative" + }, + { + "id": "smoke-84c800c0", + "name": "D'oliva bar", + "description": "", + "lat": 44.8212249, + "lng": 20.46581, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8212249,20.46581", + "smoking": "alternative" + }, + { + "id": "smoke-5841be1d", + "name": "D59B | Belgrade", + "description": "", + "lat": 44.8219191, + "lng": 20.4582529, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8219191,20.4582529", + "smoking": "none" + }, + { + "id": "smoke-a018dbc4", + "name": "DRIP Specialty Coffee", + "description": "", + "lat": 44.8192136, + "lng": 20.461999, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8192136,20.461999", + "smoking": "none" + }, + { + "id": "smoke-d7d4c276", + "name": "DVA MEDVEDA", + "description": "", + "lat": 44.8152671, + "lng": 20.456029, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8152671,20.456029", + "smoking": "none" + }, + { + "id": "smoke-af486641", + "name": "Delicent", + "description": "", + "lat": 44.823535, + "lng": 20.4622363, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.823535,20.4622363", + "smoking": "none" + }, + { + "id": "smoke-186bef7c", + "name": "Demokratija cocktails&coffee bar", + "description": "", + "lat": 44.8171251, + "lng": 20.4651504, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8171251,20.4651504", + "smoking": "none" + }, + { + "id": "smoke-38f82c8d", + "name": "Diavolino", + "description": "", + "lat": 44.7986381, + "lng": 20.4779352, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7986381,20.4779352", + "smoking": "alternative" + }, + { + "id": "smoke-8c7a1f5a", + "name": "Dom kulture Studentski grad", + "description": "", + "lat": 44.8248772, + "lng": 20.4006511, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8248772,20.4006511", + "smoking": "none" + }, + { + "id": "smoke-c42dd245", + "name": "ENDORFIN", + "description": "", + "lat": 44.8186623, + "lng": 20.4601451, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8186623,20.4601451", + "smoking": "none" + }, + { + "id": "smoke-f3ceb6b4", + "name": "Eatalian Food Bar Dorćol", + "description": "", + "lat": 44.8193513, + "lng": 20.4622299, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8193513,20.4622299", + "smoking": "alternative" + }, + { + "id": "smoke-9285ba6c", + "name": "FENIKS Gelato Saloon", + "description": "", + "lat": 44.8154382, + "lng": 20.4587198, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8154382,20.4587198", + "smoking": "none" + }, + { + "id": "smoke-7cb007eb", + "name": "FREESHKA COFFEE Co.", + "description": "", + "lat": 45.2469744, + "lng": 19.839275, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2469744,19.839275", + "smoking": "none" + }, + { + "id": "smoke-1d93ef7d", + "name": "Fameli coffee bar and play", + "description": "", + "lat": 44.797843, + "lng": 20.4761563, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.797843,20.4761563", + "smoking": "alternative" + }, + { + "id": "smoke-f68fa8e4", + "name": "Family Cup", + "description": "", + "lat": 44.8128023, + "lng": 20.4293876, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8128023,20.4293876", + "smoking": "none" + }, + { + "id": "smoke-cb6821ce", + "name": "Fat Boys Food co.", + "description": "", + "lat": 44.8246333, + "lng": 20.4560706, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8246333,20.4560706", + "smoking": "alternative" + }, + { + "id": "smoke-43788704", + "name": "Ferdinand Dumplings", + "description": "", + "lat": 44.8126059, + "lng": 20.4297963, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8126059,20.4297963", + "smoking": "none" + }, + { + "id": "smoke-e9895c09", + "name": "Ferdinand knedle", + "description": "", + "lat": 44.8170045, + "lng": 20.4553833, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8170045,20.4553833", + "smoking": "alternative" + }, + { + "id": "smoke-d5dcc133", + "name": "Filipenko Coffee Bar", + "description": "", + "lat": 45.2568858, + "lng": 19.8434286, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2568858,19.8434286", + "smoking": "none" + }, + { + "id": "smoke-ab9528f4", + "name": "Fine Sushi", + "description": "", + "lat": 44.8033771, + "lng": 20.4778752, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8033771,20.4778752", + "smoking": "none" + }, + { + "id": "smoke-ac48f8f3", + "name": "Fini", + "description": "", + "lat": 44.7985102, + "lng": 20.4715454, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7985102,20.4715454", + "smoking": "none" + }, + { + "id": "smoke-de7c856d", + "name": "Fit Bar", + "description": "", + "lat": 44.8137855, + "lng": 20.4615573, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8137855,20.4615573", + "smoking": "none" + }, + { + "id": "smoke-ffcd50e6", + "name": "Forum", + "description": "", + "lat": 44.820605, + "lng": 20.4692783, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.820605,20.4692783", + "smoking": "none" + }, + { + "id": "smoke-dbb899ce", + "name": "GIR Café", + "description": "", + "lat": 44.8251472, + "lng": 20.4176994, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8251472,20.4176994", + "smoking": "none" + }, + { + "id": "smoke-641c686d", + "name": "Gabby Caffe & Bar", + "description": "", + "lat": 45.2566629, + "lng": 19.8484749, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2566629,19.8484749", + "smoking": "none" + }, + { + "id": "smoke-d0fd34ed", + "name": "Galito's", + "description": "", + "lat": 44.803779, + "lng": 20.4452948, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.803779,20.4452948", + "smoking": "none" + }, + { + "id": "smoke-2df642ca", + "name": "Giros Land", + "description": "", + "lat": 45.2569187, + "lng": 19.8447314, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2569187,19.8447314", + "smoking": "alternative" + }, + { + "id": "smoke-b9333fe9", + "name": "Go Sushi", + "description": "", + "lat": 44.8059909, + "lng": 20.4665366, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8059909,20.4665366", + "smoking": "none" + }, + { + "id": "smoke-71c00f49", + "name": "Gostoprimnica", + "description": "", + "lat": 43.9026007, + "lng": 22.2744054, + "category": "", + "city": "", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=43.9026007,22.2744054", + "smoking": "none" + }, + { + "id": "smoke-b6887494", + "name": "Greenet Stari Grad", + "description": "", + "lat": 44.8137759, + "lng": 20.461531, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8137759,20.461531", + "smoking": "alternative" + }, + { + "id": "smoke-65cd6a30", + "name": "Gurme", + "description": "", + "lat": 44.809553, + "lng": 20.4760718, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.809553,20.4760718", + "smoking": "alternative" + }, + { + "id": "smoke-9fc927b4", + "name": "Hilton Belgrade", + "description": "", + "lat": 44.8040303, + "lng": 20.4661686, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8040303,20.4661686", + "smoking": "alternative" + }, + { + "id": "smoke-7ddfdfed", + "name": "Hotel Prag", + "description": "", + "lat": 44.810841, + "lng": 20.459772, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.810841,20.459772", + "smoking": "none" + }, + { + "id": "smoke-1078b9d0", + "name": "Hyatt Regency Belgrade", + "description": "", + "lat": 44.813059, + "lng": 20.4340388, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.813059,20.4340388", + "smoking": "alternative" + }, + { + "id": "smoke-6545e947", + "name": "Ispeci pa reci", + "description": "", + "lat": 44.8204973, + "lng": 20.4690126, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8204973,20.4690126", + "smoking": "none" + }, + { + "id": "smoke-e0586f03", + "name": "Ispeci pa reci Zvezdara", + "description": "", + "lat": 44.7964361, + "lng": 20.4931559, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7964361,20.4931559", + "smoking": "none" + }, + { + "id": "smoke-b6b3e5de", + "name": "Izlet", + "description": "", + "lat": 45.2560558, + "lng": 19.8516777, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2560558,19.8516777", + "smoking": "alternative" + }, + { + "id": "smoke-7ac2c49c", + "name": "JaM", + "description": "", + "lat": 44.8189518, + "lng": 20.4716991, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8189518,20.4716991", + "smoking": "alternative" + }, + { + "id": "smoke-678da813", + "name": "Kafetea", + "description": "", + "lat": 45.2615304, + "lng": 19.8521021, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2615304,19.8521021", + "smoking": "none" + }, + { + "id": "smoke-2d039afd", + "name": "Kafeterija Košutnjak", + "description": "", + "lat": 44.7667377, + "lng": 20.4270799, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7667377,20.4270799", + "smoking": "none" + }, + { + "id": "smoke-5d908322", + "name": "Kafeterija „Bukmarker“ | Lagunin klub čitalaca", + "description": "", + "lat": 44.8190466, + "lng": 20.455038, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8190466,20.455038", + "smoking": "alternative" + }, + { + "id": "smoke-8ca2c5d0", + "name": "Kafić BeWell energy", + "description": "", + "lat": 44.7974816, + "lng": 20.492269, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7974816,20.492269", + "smoking": "alternative" + }, + { + "id": "smoke-13f3b939", + "name": "Kaži Važi", + "description": "", + "lat": 44.8095823, + "lng": 20.4579601, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8095823,20.4579601", + "smoking": "none" + }, + { + "id": "smoke-bd617a47", + "name": "Keg&Krigla", + "description": "", + "lat": 43.8869231, + "lng": 20.3506779, + "category": "", + "city": "cacak", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=43.8869231,20.3506779", + "smoking": "alternative" + }, + { + "id": "smoke-e8690b2d", + "name": "Klein House Social Bar and Art Gallery", + "description": "", + "lat": 46.1010974, + "lng": 19.6684838, + "category": "", + "city": "subotica", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=46.1010974,19.6684838", + "smoking": "alternative" + }, + { + "id": "smoke-9af26806", + "name": "Komuna Gastro Bar", + "description": "", + "lat": 43.3186575, + "lng": 21.8941198, + "category": "", + "city": "nis", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=43.3186575,21.8941198", + "smoking": "alternative" + }, + { + "id": "smoke-c01b8926", + "name": "Koppa specialty coffee", + "description": "", + "lat": 44.7995499, + "lng": 20.4699495, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7995499,20.4699495", + "smoking": "none" + }, + { + "id": "smoke-4ee66959", + "name": "Kurtoš kolač NS", + "description": "", + "lat": 45.2566554, + "lng": 19.848729, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2566554,19.848729", + "smoking": "none" + }, + { + "id": "smoke-f3990fee", + "name": "Kuća Umetnica", + "description": "", + "lat": 44.8208265, + "lng": 20.4594723, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8208265,20.4594723", + "smoking": "none" + }, + { + "id": "smoke-8169adf3", + "name": "LUFF GELATO \"West 65\"", + "description": "", + "lat": 44.8132917, + "lng": 20.4009041, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8132917,20.4009041", + "smoking": "none" + }, + { + "id": "smoke-af6fbb11", + "name": "LUFF GELATO Dorćol", + "description": "", + "lat": 44.8207292, + "lng": 20.4560944, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8207292,20.4560944", + "smoking": "none" + }, + { + "id": "smoke-e3d00699", + "name": "LUFF GELATO TC \"Ada Mall\"", + "description": "", + "lat": 44.786713, + "lng": 20.4185736, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.786713,20.4185736", + "smoking": "none" + }, + { + "id": "smoke-0e0dfe10", + "name": "LUFF GELATO TC \"Galerija\"", + "description": "", + "lat": 44.8032376, + "lng": 20.4452358, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8032376,20.4452358", + "smoking": "none" + }, + { + "id": "smoke-22ae25bb", + "name": "LUFF GELATO Vračar", + "description": "", + "lat": 44.8037848, + "lng": 20.4691259, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8037848,20.4691259", + "smoking": "none" + }, + { + "id": "smoke-24845b41", + "name": "La la kafe", + "description": "", + "lat": 44.7982191, + "lng": 20.4832724, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7982191,20.4832724", + "smoking": "none" + }, + { + "id": "smoke-5f31471d", + "name": "Lava Nova", + "description": "", + "lat": 44.8016751, + "lng": 20.4561152, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8016751,20.4561152", + "smoking": "alternative" + }, + { + "id": "smoke-13149305", + "name": "Le Bol", + "description": "", + "lat": 45.2558185, + "lng": 19.8491643, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2558185,19.8491643", + "smoking": "alternative" + }, + { + "id": "smoke-97a674ce", + "name": "Ljiljan cocktail bar", + "description": "", + "lat": 46.0880927, + "lng": 19.6716677, + "category": "", + "city": "subotica", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=46.0880927,19.6716677", + "smoking": "alternative" + }, + { + "id": "smoke-29a42300", + "name": "Loft Biciklana", + "description": "", + "lat": 45.2399804, + "lng": 19.8474436, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2399804,19.8474436", + "smoking": "none" + }, + { + "id": "smoke-9bb5147f", + "name": "Loft Boulevard", + "description": "", + "lat": 45.2574251, + "lng": 19.8343345, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2574251,19.8343345", + "smoking": "none" + }, + { + "id": "smoke-10ad4981", + "name": "Macchiato Liman", + "description": "", + "lat": 45.2396011, + "lng": 19.8379077, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2396011,19.8379077", + "smoking": "alternative" + }, + { + "id": "smoke-0d9b57a6", + "name": "Maro's Pastelaria", + "description": "", + "lat": 45.2554392, + "lng": 19.8364141, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2554392,19.8364141", + "smoking": "none" + }, + { + "id": "smoke-15b3efe4", + "name": "Mercator Center", + "description": "", + "lat": 45.2430556, + "lng": 19.8408333, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2430556,19.8408333", + "smoking": "none" + }, + { + "id": "smoke-2799aade", + "name": "Mipl", + "description": "", + "lat": 45.2562713, + "lng": 19.842266, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2562713,19.842266", + "smoking": "none" + }, + { + "id": "smoke-de8ebda6", + "name": "Moritz Eis Belgrade", + "description": "", + "lat": 44.8173141, + "lng": 20.4553407, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8173141,20.4553407", + "smoking": "none" + }, + { + "id": "smoke-8471ebb2", + "name": "Nekrasova The Bar", + "description": "", + "lat": 44.8053527, + "lng": 20.4735437, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8053527,20.4735437", + "smoking": "alternative" + }, + { + "id": "smoke-e7c7ad96", + "name": "Nevski Hotel", + "description": "", + "lat": 44.8200496, + "lng": 20.4685502, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8200496,20.4685502", + "smoking": "none" + }, + { + "id": "smoke-e435a1cb", + "name": "OVO bistro", + "description": "", + "lat": 44.8142784, + "lng": 20.4677872, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8142784,20.4677872", + "smoking": "none" + }, + { + "id": "smoke-7fcca112", + "name": "Organic factory", + "description": "", + "lat": 45.2399028, + "lng": 19.8373791, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2399028,19.8373791", + "smoking": "none" + }, + { + "id": "smoke-a435b44b", + "name": "Pane E Vino, WEST 65", + "description": "", + "lat": 44.8125021, + "lng": 20.3997288, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8125021,20.3997288", + "smoking": "none" + }, + { + "id": "smoke-d49593e5", + "name": "Panuša Palačinkarnica", + "description": "", + "lat": 45.2546316, + "lng": 19.831763, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2546316,19.831763", + "smoking": "none" + }, + { + "id": "smoke-ce74d953", + "name": "Paradise food", + "description": "", + "lat": 44.7992946, + "lng": 20.4722345, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7992946,20.4722345", + "smoking": "none" + }, + { + "id": "smoke-fc68cb69", + "name": "Petrus Caffe", + "description": "", + "lat": 45.2543649, + "lng": 19.84639, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2543649,19.84639", + "smoking": "alternative" + }, + { + "id": "smoke-3e4c67f4", + "name": "Pietra Pizzeria & Cocktail Bar", + "description": "", + "lat": 44.8053314, + "lng": 20.4732625, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8053314,20.4732625", + "smoking": "none" + }, + { + "id": "smoke-1db2ddde", + "name": "Pizzeria Adrijana", + "description": "", + "lat": 45.2558334, + "lng": 19.8463962, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2558334,19.8463962", + "smoking": "alternative" + }, + { + "id": "smoke-fd29a3df", + "name": "Poslastičarnica Anči Kolači", + "description": "", + "lat": 44.8728741, + "lng": 20.6549366, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8728741,20.6549366", + "smoking": "none" + }, + { + "id": "smoke-7fa0be13", + "name": "Poslastičarnica Šuma (The Forest)", + "description": "", + "lat": 44.8171408, + "lng": 20.4634805, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8171408,20.4634805", + "smoking": "none" + }, + { + "id": "smoke-3e1d3eb0", + "name": "Pričica Coffee Bar", + "description": "", + "lat": 44.8145846, + "lng": 20.4509601, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8145846,20.4509601", + "smoking": "alternative" + }, + { + "id": "smoke-8bf965c8", + "name": "Promenada", + "description": "", + "lat": 45.2444471, + "lng": 19.8424522, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2444471,19.8424522", + "smoking": "none" + }, + { + "id": "smoke-eb3a40fb", + "name": "Pržionica kafe Kafograf", + "description": "", + "lat": 44.8171929, + "lng": 20.4740927, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8171929,20.4740927", + "smoking": "none" + }, + { + "id": "smoke-c6edfe35", + "name": "Radio Caffe", + "description": "", + "lat": 44.6206967, + "lng": 21.1832036, + "category": "", + "city": "", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.6206967,21.1832036", + "smoking": "alternative" + }, + { + "id": "smoke-a14637bc", + "name": "Rai Urban Vege", + "description": "", + "lat": 44.8265836, + "lng": 20.4565475, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8265836,20.4565475", + "smoking": "alternative" + }, + { + "id": "smoke-1a2282e3", + "name": "Rice Kings Sutlijaš bar", + "description": "", + "lat": 44.8162582, + "lng": 20.4563426, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8162582,20.4563426", + "smoking": "alternative" + }, + { + "id": "smoke-d95462d2", + "name": "Rosetto", + "description": "", + "lat": 45.240711, + "lng": 19.8129023, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.240711,19.8129023", + "smoking": "alternative" + }, + { + "id": "smoke-2d771251", + "name": "S N I P P S T E R", + "description": "", + "lat": 45.256868, + "lng": 19.8195656, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.256868,19.8195656", + "smoking": "alternative" + }, + { + "id": "smoke-04e69d21", + "name": "SMASH BURGERS", + "description": "", + "lat": 44.8149278, + "lng": 20.475142, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8149278,20.475142", + "smoking": "none" + }, + { + "id": "smoke-755dfc52", + "name": "Saint Ten Hotel", + "description": "", + "lat": 44.8013135, + "lng": 20.4671585, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8013135,20.4671585", + "smoking": "alternative" + }, + { + "id": "smoke-6fbc5c6e", + "name": "Salon de thé by Small Tree", + "description": "", + "lat": 44.8012702, + "lng": 20.4671757, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8012702,20.4671757", + "smoking": "none" + }, + { + "id": "smoke-1c299a2d", + "name": "Samo Dobra Kafa", + "description": "", + "lat": 44.7582055, + "lng": 19.6921476, + "category": "", + "city": "", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7582055,19.6921476", + "smoking": "none" + }, + { + "id": "smoke-365542a3", + "name": "Sendvič store Novi Beograd", + "description": "", + "lat": 44.8126819, + "lng": 20.3909278, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8126819,20.3909278", + "smoking": "alternative" + }, + { + "id": "smoke-654e013d", + "name": "Sheraton Novi Sad", + "description": "", + "lat": 45.2481158, + "lng": 19.8162036, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2481158,19.8162036", + "smoking": "none" + }, + { + "id": "smoke-52384571", + "name": "Sojer Caffe Bar", + "description": "", + "lat": 44.6204283, + "lng": 21.1836508, + "category": "", + "city": "", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.6204283,21.1836508", + "smoking": "none" + }, + { + "id": "smoke-32488856", + "name": "Space Land", + "description": "", + "lat": 45.383028, + "lng": 20.3942825, + "category": "", + "city": "zrenjanin", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.383028,20.3942825", + "smoking": "none" + }, + { + "id": "smoke-d81f2560", + "name": "Starbucks Rajićeva", + "description": "", + "lat": 44.8199455, + "lng": 20.4539868, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8199455,20.4539868", + "smoking": "none" + }, + { + "id": "smoke-57949a78", + "name": "Stories", + "description": "", + "lat": 44.8232058, + "lng": 20.4555566, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8232058,20.4555566", + "smoking": "none" + }, + { + "id": "smoke-70eb2ba7", + "name": "Sunshine bagel", + "description": "", + "lat": 44.8055991, + "lng": 20.4660427, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8055991,20.4660427", + "smoking": "alternative" + }, + { + "id": "smoke-514bfb73", + "name": "Sweet & green", + "description": "", + "lat": 45.2559413, + "lng": 19.8467556, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2559413,19.8467556", + "smoking": "none" + }, + { + "id": "smoke-88fc0768", + "name": "TAO Thai restoran", + "description": "", + "lat": 44.8072842, + "lng": 20.4670377, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8072842,20.4670377", + "smoking": "alternative" + }, + { + "id": "smoke-42515a83", + "name": "Thyme - StreetFood & Breakfast", + "description": "", + "lat": 44.839264, + "lng": 20.4158154, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.839264,20.4158154", + "smoking": "none" + }, + { + "id": "smoke-e7f2cf1d", + "name": "Tokio Sushi", + "description": "", + "lat": 45.2574002, + "lng": 19.8475502, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2574002,19.8475502", + "smoking": "alternative" + }, + { + "id": "smoke-7f8eee6e", + "name": "Toto Healthy Street Food", + "description": "", + "lat": 44.8169267, + "lng": 20.4590406, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8169267,20.4590406", + "smoking": "none" + }, + { + "id": "smoke-71dcd413", + "name": "Valentina i Karanfil", + "description": "", + "lat": 44.8222945, + "lng": 20.4564396, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8222945,20.4564396", + "smoking": "none" + }, + { + "id": "smoke-9a8c9a3c", + "name": "VegANGELov", + "description": "", + "lat": 44.8200707, + "lng": 20.4622201, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8200707,20.4622201", + "smoking": "none" + }, + { + "id": "smoke-b1b459d2", + "name": "Vege Vegan", + "description": "", + "lat": 45.2539024, + "lng": 19.8397541, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2539024,19.8397541", + "smoking": "none" + }, + { + "id": "smoke-f9b61516", + "name": "Waffle Heart", + "description": "", + "lat": 44.7988284, + "lng": 20.4776707, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7988284,20.4776707", + "smoking": "none" + }, + { + "id": "smoke-7d3b5a53", + "name": "Walter Promenada", + "description": "", + "lat": 45.2445524, + "lng": 19.8419568, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2445524,19.8419568", + "smoking": "alternative" + }, + { + "id": "smoke-e661b7f8", + "name": "Way Cup kafeterija", + "description": "", + "lat": 44.7922926, + "lng": 20.4995532, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.7922926,20.4995532", + "smoking": "none" + }, + { + "id": "smoke-5842db29", + "name": "X.WANG's kitchen", + "description": "", + "lat": 44.8128667, + "lng": 20.4582251, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8128667,20.4582251", + "smoking": "none" + }, + { + "id": "smoke-71c785c1", + "name": "Zenit books", + "description": "", + "lat": 45.2570533, + "lng": 19.8429177, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2570533,19.8429177", + "smoking": "alternative" + }, + { + "id": "smoke-a1e36b0a", + "name": "jazzayoga", + "description": "", + "lat": 44.8083424, + "lng": 20.469175, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8083424,20.469175", + "smoking": "none" + }, + { + "id": "smoke-99f63d10", + "name": "Ćao Šećeru", + "description": "", + "lat": 44.824206, + "lng": 20.4573513, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.824206,20.4573513", + "smoking": "alternative" + }, + { + "id": "smoke-4f9f9ab1", + "name": "Ćevabdžinica Savčić Vračar", + "description": "", + "lat": 44.8041093, + "lng": 20.4665885, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8041093,20.4665885", + "smoking": "none" + }, + { + "id": "smoke-a1419fa3", + "name": "Šećernema", + "description": "", + "lat": 45.2489528, + "lng": 19.8392648, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2489528,19.8392648", + "smoking": "none" + }, + { + "id": "smoke-dc93d020", + "name": "Štrik kafe knjižara", + "description": "", + "lat": 44.8126898, + "lng": 20.4646965, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8126898,20.4646965", + "smoking": "none" + }, + { + "id": "smoke-8d61d85b", + "name": "Кофилин Kofilin", + "description": "", + "lat": 44.823363, + "lng": 20.4605254, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.823363,20.4605254", + "smoking": "none" + }, + { + "id": "smoke-733524a3", + "name": "Орашац", + "description": "", + "lat": 44.8037935, + "lng": 20.478627, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8037935,20.478627", + "smoking": "alternative" + }, + { + "id": "smoke-e0531e6f", + "name": "Отель Москва", + "description": "", + "lat": 44.8130277, + "lng": 20.4604305, + "category": "", + "city": "beograd", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=44.8130277,20.4604305", + "smoking": "alternative" + }, + { + "id": "smoke-f0a44780", + "name": "Плава Фрајла", + "description": "", + "lat": 45.2475454, + "lng": 19.8439561, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2475454,19.8439561", + "smoking": "alternative" + }, + { + "id": "smoke-7df87d56", + "name": "Сценарио", + "description": "", + "lat": 45.2517751, + "lng": 19.8247352, + "category": "", + "city": "novi-sad", + "opstina": "", + "mapUrl": "https://www.google.com/maps/search/?api=1&query=45.2517751,19.8247352", + "smoking": "alternative" + } + ] +} \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..306c273 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,167 @@ +# Changelog + +Versions are `versionName+versionCode`, both from `version:` in `pubspec.yaml`. +The short "What's new" texts that go into the Play Console live in +[docs/play/](play/). + +## 2.0.0+14 — unreleased + +The first release since January 2024. The app was rebuilt around Android 16, +the guide it ships was re-scraped from srb.guide, and four screens are new. +Everything below is what a user of 1.0.0 (versionCode 12) will notice. + +### The guide + +- **74 articles across 8 sections**, up from 5 articles. Re-synced from + srb.guide with `tool/sync_guide.dart`; each article now carries its source URL + and last-modified date, and credits the original on screen. +- **Articles render correctly.** The old reader split the text on newlines and + built a separate Markdown widget per line, which broke every construct that + spans lines — lists, tables, quotes, fenced code. +- **Search understands Russian inflection.** The query is stemmed, so + "документы" also finds "документов". Adds a section filter, ranking, and a + snippet of the matching text under each hit. +- **The guide updates itself between releases.** Once a day the app asks GitHub + whether the bundled guide changed, conditional on an ETag, so an unchanged + guide costs a single 304. A download is validated before it is accepted and + the bundled copy stays the fallback, so a bad fetch cannot leave the app with + no content. Settings has a manual "check now". +- **Favourites survive guide updates.** They store the article id instead of a + copy of the text, so a saved article follows the guide when it is re-synced. + Existing favourites are migrated by title. + +### New: visa-free calculator (reworked) + +The old screen assumed everyone gets 30 days from a single entry date, and its +remaining-days arithmetic truncated — at 10:00 the day before the deadline it +said zero days left when two were still available. + +- **Three regimes**, as the guide's own calculator article describes them: + 30 days per entry (Russia, Belarus, China, Kazakhstan), 90 days in any 180 + (EU, USA, Ukraine and most others), 30 days in any 365 (Bahamas, Barbados, + Colombia and five more). +- A rolling window cannot be answered from one date, so the screen keeps a + **list of trips**. The single date the old screen stored is carried over on + first run. +- Day counter with a progress ring, the must-leave-by date, and an overstay + warning. Both the entry and the exit day count, as the guide states. Calendar + days are anchored to UTC midnight, so adding days across the March clock + change no longer loses one. +- The deadline can become a reminder or a calendar event. +- The arithmetic lives in `lib/data/visa_rule.dart` under 16 tests. + +### New: deadlines with reminders + +Local notifications for visa runs, residence-permit renewal, paušal tax, eco +tax, insurance and document expiry. Dates are derived from the obligation, and +the recurring ones roll forward on their own. Scheduling is deliberately +inexact — exact alarms need a Play-restricted permission this app does not +qualify for. + +### New: "Мой путь" / My path + +A 24-step relocation checklist across six stages — before the move, first days, +setting up a business, banks, temporary residence, and after — with every step +linked to the guide article that explains it. Progress is stored by article +slug, so it survives a guide re-sync. + +### New: train timetable + +Route search and a per-station departure/arrival board over the Serbian +Railways timetable at w3.srbvoz.rs, with recently used stations remembered. + +**Stations can be searched in Russian.** The operator's lookup only understands +Latin: its own front end strips everything else out of the term, and an empty +term is answered with all 396 stations — so typing "Белград" produced an +alphabetical dump of the whole network and looked like the search had stopped +working. The list is now fetched once, cached for a week and matched on device: +Cyrillic is transliterated, diacritics are folded, each word is allowed one +character of slack (Белград/Beograd, Крагуевац/Kragujevac), and the match runs +inside the name, so "centar" finds BEOGRAD CENTAR — which the server itself +could not do. The picker also stops answering a network failure with "nothing +found". + +### New: one map, replacing the old "Карты" screen + +362 businesses from the stats.srb.guide catalogue plus 145 non-smoking venues +from the *Lokali bez dima* map, together on one OpenStreetMap map. Both +catalogues are bundled, so the list and filters work offline and only the tiles +need a connection. OSM needs no API key and no billing account, unlike the +Google Maps SDK. + +- Filter chips for the smoking policy — smoking banned outright, or smokeless + devices only — next to the business categories, and a badge on the venue. +- A venue on both lists is matched by name and proximity, so it gets one pin + rather than two. +- **The old "Карты" screen is gone.** It was a dropdown of Google My Maps links + opened in a WebView: the Russian-venues map it pointed at is the same + catalogue this screen already shows, the non-smoking map is now bundled as + pins, and the exchange-office search moved into the map's toolbar. The + "black list of apartments" link had been returning a Google 403 for a while + and is dropped. `webview_flutter` goes with it. + +### Exchange rates + +- **Two of the four parsers had been silently returning nothing for months.** + funta.rs renumbered its columns and dropped `tbody.row-hover`; + menjacnicegaga.rs replaced its table with a div ticker. promonet.rs assigned + rows by index parity, so the CHF row was overwriting the RUB quote. All + parsers now match rows by currency code rather than by position. +- **The National Bank of Serbia is a fifth source** and the only one with a real + API. It replaces the previous reference rate, which was scraped out of one + office's ticker. Reference sources are flagged and left out of the "best rate" + comparison — the NBS is not a counter you can walk up to. +- Offices are queried in parallel, each card reports its own failure, the best + rate is highlighted, the last good response is cached for offline use, and + there is a converter. +- A daily CI job runs the parsers against the live sites and opens an issue when + an office changes its markup, so a break is visible before users hit it. + +### Telegram chats + +432 chats from the catalogue, with topic, size and activity, replacing 340 +hand-maintained entries that had drifted. Refreshed weekly along with the guide, +the places catalogue and the non-smoking map. + +### Design + +- Material 3 throughout, from one colour scheme. The seed used to be the Serbian + flag red and Material 3 tints every surface with the seed, so backgrounds and + cards came out pink; the palette is now a blue accent over a neutral surface + ramp. Colour marks actions, the active tab and an urgent deadline, nothing + else. +- A `NavigationBar` shell replaces the navigation drawer and the nested bottom + bars. +- The default language follows the device instead of always starting in English. + +### Under the hood + +- **targetSdk 36 (Android 16)**, which Google Play now requires. AGP 9.1, + Gradle 9.3.1, Kotlin 2.4, Java 17, the Kotlin DSL. `minSdk` moves 21 → 24 + (Flutter's floor), so Android 5.x devices no longer receive updates. +- Release signing is conditional on `android/key.properties`; the previous + config threw at configuration time whenever that file was absent. +- `versionCode` and `versionName` now both come from `pubspec.yaml`. +- Search no longer re-lowercases every article on every keystroke (1.5 MB per + search); the lowercased forms are computed once per article. +- The guide is parsed once and cached rather than re-read per screen. +- Seven `use_build_context_synchronously` violations fixed; the analyzer is + clean and CI keeps it that way. +- 5.6 MB of orphaned images dropped from the repository. +- CI on every push: formatting, analysis, tests, guide validation, debug APK. + A signed-release workflow that refuses to publish anything not signed with a + SHA-256 upload key. MIT licence, and a NOTICE recording that it does not cover + the guide content, which belongs to the authors of srb.guide and is used with + their permission. + +### Known limitations + +- Article images are fetched from srb.guide, so article text is available + offline but its images are not. +- iOS is unmaintained: the `ios/` project has no Podfile and still declares an + iOS 11 deployment target, below what the current plugins require. + +## 1.0.0+12 — 2024-01-21 + +Guide, visa-run calculator, white-card (beli karton) form, exchange rates, +Telegram chat directory, maps, favourites, RU/EN localisation. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 3fc1ac0..e3dd00b 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,7 +1,30 @@ # Release & signing -The `Release` workflow builds a signed App Bundle and APK and refuses to publish -anything that is not signed with a real SHA-256 upload key. +Two ways to produce the same artifact under the same checks: + +| | | +|---|---| +| locally, on Windows | `.\tool\build_release.ps1` | +| in CI, on a `v*` tag | the `Release` workflow | + +Both refuse to publish anything that is not signed with a real SHA-256 upload +key. What Play asks for on the upload screen — the "What's new" text — is +in [play/](play/); the full account of the release is in +[CHANGELOG.md](CHANGELOG.md). + +## The short version + +```powershell +# once, ever +.\tool\create_upload_key.ps1 # creates the key, prints what Play asks for +.\tool\show_upload_key.ps1 # prints it again later, if you need it + +# bump `version:` in pubspec.yaml, then, for every release +.\tool\build_release.ps1 +``` + +The bundle lands in `dist/srbguide-+.aab`. Upload that; +`dist/srbguide-+.apk` is for sideloading and manual QA. ## 1. Create the upload key (once) @@ -9,6 +32,14 @@ Google Play requires RSA 2048+ and a validity that runs past 2033. `-sigalg SHA256withRSA` is what makes the certificate itself SHA-256; the workflow verifies this and fails if the key uses an older algorithm. +```powershell +.\tool\create_upload_key.ps1 +``` + +It prompts for a password, runs the `keytool` invocation below, checks the +certificate really is SHA-256, and writes `android/key.properties` for you. +`-PrintBase64` also dumps the value for the CI secret. + ```bash keytool -genkeypair \ -alias upload \ @@ -20,9 +51,34 @@ keytool -genkeypair \ -dname "CN=Ilia Alakov, O=Serbia Guide, C=RS" ``` -Keep `upload-keystore.jks` and its passwords somewhere safe and offline. If you -lose them you cannot ship an update to the existing listing — you would have to -ask Play support to reset the upload key. +Keep `upload-keystore.jks` and its passwords somewhere safe and offline. A +SHA-256 fingerprint is a hash of the certificate, so a fingerprint you wrote +down somewhere is not a spare copy of the key — nothing can be signed with it. + +## 1a. Registering the key with Play + +Play needs to know the public half of whichever key you sign uploads with. It +asks for it in one of two forms depending on the screen: + +```powershell +.\tool\show_upload_key.ps1 +``` + +That prints the **SHA-256 fingerprint** to paste into "Добавьте открытый ключ, +указав его цифровой отпечаток сертификата SHA-256", and writes a **PEM +certificate** next to the keystore for the screens that take a file instead. +Both are public; the keystore and its password never leave the machine. + +This matters because the app signing key and the upload key are different +things. Under Play App Signing, Google holds the app signing key that end users +verify, and it re-signs every release — so replacing the upload key does not +break updates for anyone who already has the app installed. The upload key only +proves that an upload came from you. Losing it is recoverable; losing the app +signing key would not be, and you never had it. + +Compare what Play shows under **App integrity → App signing → Upload key +certificate** with what `show_upload_key.ps1` prints. If they differ, the build +will be rejected at upload with "the APK was signed with the wrong key". ## 2. Add the repository secrets @@ -39,30 +95,65 @@ On macOS use `base64 -i upload-keystore.jks | tr -d '\n'`. ## 3. Building locally -The same key can be used locally through `android/key.properties`, which is -git-ignored: +`tool/build_release.ps1` does everything the workflow does. It resolves the +Flutter SDK, JDK and Android build-tools by itself (SETUP.md records how they +are pinned on this machine), so no PATH setup is needed. + +```powershell +.\tool\build_release.ps1 # version from pubspec.yaml +.\tool\build_release.ps1 -VersionName 2.0.1 -BuildNumber 14 +.\tool\build_release.ps1 -BundleOnly # skip the APK +.\tool\build_release.ps1 -SkipChecks -SkipClean # fast rebuild, never for upload +``` + +It stops before spending a build on: + +- a missing or incomplete `android/key.properties`, or a keystore that is not + where that file says it is; +- an upload key that is not `SHA256withRSA`; +- a `versionCode` Play has already seen (`-PublishedBuildNumber`, currently + `12`; raise the default in the script after each accepted upload). + +Then it runs `flutter analyze`, the unit tests and `tool/validate_guide.dart`, +builds both artifacts at an explicit version, verifies the signatures, asserts +the merged manifest still targets SDK 36, and copies the results into `dist/` +with their SHA-256 sums. `dist/` is git-ignored. + +The key is read from `android/key.properties`, which is git-ignored: ```properties -storeFile=/absolute/path/to/upload-keystore.jks +storeFile=D:\\path\\to\\upload-keystore.jks storePassword=... keyAlias=upload keyPassword=... ``` -```bash -flutter build appbundle --release -``` +Gradle loads that file as a `java.util.Properties`, so on Windows the +backslashes have to be doubled; an absolute path avoids a second trap, since +Gradle resolves a relative one against `android/app`. +`create_upload_key.ps1` writes both correctly. Without that file the release build falls back to the debug signing config so `flutter run --release` still works. **A debug-signed bundle will be rejected by -Play** — the workflow fails the build rather than letting one through. +Play** — both the script and the workflow fail the build rather than letting +one through. ## 4. Cutting a release +`versionName` and `versionCode` both come from one line in `pubspec.yaml`: + +```yaml +version: 2.0.0+14 +``` + +`android/app/build.gradle.kts` reads them from there through +`flutter.versionCode` / `flutter.versionName`, and `build_release.ps1` passes +both to `flutter build` explicitly, so what ships is what the log printed. + ```bash -# bump `versionCode`/`versionName` in android/app/build.gradle.kts first -git tag v1.1.0 -git push origin v1.1.0 +# bump `version:` in pubspec.yaml first +git tag v2.0.0 +git push origin v2.0.0 ``` The tag triggers `Release`, which: @@ -73,7 +164,7 @@ The tag triggers `Release`, which: 4. runs `apksigner verify` and requires **APK Signature Scheme v2** plus a SHA-256 certificate digest, and fails if the artifact carries the Android debug certificate; -5. verifies the AAB with `jarsigner -verify -strict`; +5. verifies the AAB with `jarsigner -verify`; 6. asserts the merged manifest still has `targetSdkVersion="36"`; 7. uploads both artifacts and opens a **draft** GitHub Release. @@ -82,6 +173,24 @@ to compare against "Upload key certificate" in the Play Console. ## 5. Before uploading to Play -- `versionCode` must be higher than the published one (currently `11`). +- `versionCode` must be higher than the published one: production is on + `12` (1.0.0), published 21 January 2024. - Play requires `targetSdk` 36 (Android 16); step 6 above guards this. - `minSdk` is 24, so Android 5.x devices no longer receive updates. + +Upload `dist/srbguide-+.aab` under **Production → Create new +release**, and paste the release notes from: + +| Play language | File | +|---|---| +| Russian | `docs/play/whats-new-ru-RU.txt` | +| English | `docs/play/whats-new-en-US.txt` | + +Both fit Play's 500-character limit for the "What's new" field. The unabridged +version of the same release is [CHANGELOG.md](CHANGELOG.md), which is for the +repository rather than for the listing. + +The bundle is around 60 MB, which is not what anyone downloads: half of it is +the native debug symbols Play keeps for crash symbolication, and the rest +carries all three ABIs. Play delivers one ABI per device, so the install is far +smaller — the Play Console shows the real figure once the bundle is processed. diff --git a/docs/play/whats-new-en-US.txt b/docs/play/whats-new-en-US.txt new file mode 100644 index 0000000..b94e7d1 --- /dev/null +++ b/docs/play/whats-new-en-US.txt @@ -0,0 +1,10 @@ +The first update since 2024. + +• Guide: 74 articles in 8 sections, up from 5, with better search +• Visa-free calculator: 3 regimes (30 per entry, 90/180, 30/365), trip list +• Deadlines with reminders: visa run, residence permit, tax, insurance +• My path — a 24-step relocation checklist +• Serbian Railways timetable, searchable in Russian +• One map: 362 relocant businesses and 145 non-smoking venues +• Exchange rates: 5 sources including the National Bank +• Redesigned for Android 16 diff --git a/docs/play/whats-new-ru-RU.txt b/docs/play/whats-new-ru-RU.txt new file mode 100644 index 0000000..c2853e3 --- /dev/null +++ b/docs/play/whats-new-ru-RU.txt @@ -0,0 +1,10 @@ +Большое обновление, первое с 2024 года. + +• Гайд: 74 статьи в 8 разделах вместо 5, поиск с учётом падежей +• Калькулятор визарана: 3 режима (30 дней, 90/180, 30/365), список поездок +• Дедлайны: визаран, ВНЖ, паушал, эко-налог, страховка +• «Мой путь» — чек-лист переезда из 24 шагов +• Расписание поездов Србвоз: станции ищутся и по-русски +• Одна карта: 362 бизнеса релокантов и 145 заведений без курения +• Курсы валют: 5 источников, включая НБС +• Новый дизайн и Android 16 diff --git a/lib/data/place.dart b/lib/data/place.dart index 524d0f1..3c2732e 100644 --- a/lib/data/place.dart +++ b/lib/data/place.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; /// A relocant-run business from the stats.srb.guide catalogue. @@ -20,6 +22,11 @@ class Place { /// Google Maps link for turn-by-turn directions. final String mapUrl; + /// Smoking policy when it is known: `none` for a venue where smoking is + /// banned, `alternative` where only smokeless devices are allowed. Empty for + /// the businesses catalogue, which does not track it. + final String smoking; + const Place({ required this.id, required this.name, @@ -30,6 +37,7 @@ class Place { required this.city, required this.opstina, required this.mapUrl, + this.smoking = '', }); factory Place.fromJson(Map json) => Place( @@ -42,6 +50,20 @@ class Place { city: (json['city'] ?? '') as String, opstina: (json['opstina'] ?? '') as String, mapUrl: (json['mapUrl'] ?? '') as String, + smoking: (json['smoking'] ?? '') as String, + ); + + Place withSmoking(String value) => Place( + id: id, + name: name, + description: description, + lat: lat, + lng: lng, + category: category, + city: city, + opstina: opstina, + mapUrl: mapUrl, + smoking: value, ); bool get isValid => name.isNotEmpty && lat != 0 && lng != 0; @@ -78,6 +100,50 @@ class PlaceCatalogue { ); } + /// Folds a second catalogue into this one. + /// + /// The lists overlap: a handful of relocant-run cafés are also on the + /// non-smoking map. Those are matched by name and proximity and marked in + /// place, so the map does not end up with two pins on the same doorstep. + PlaceCatalogue mergedWith(PlaceCatalogue other) { + if (other.places.isEmpty) return this; + + final List merged = List.of(places); + final Map> byName = >{}; + for (int i = 0; i < merged.length; i++) { + byName.putIfAbsent(_nameKey(merged[i].name), () => []).add(i); + } + + for (final Place p in other.places) { + int? at; + for (final int i in byName[_nameKey(p.name)] ?? const []) { + if (_metresBetween(merged[i], p) <= 250) { + at = i; + break; + } + } + if (at == null) { + merged.add(p); + } else if (merged[at].smoking.isEmpty && p.smoking.isNotEmpty) { + merged[at] = merged[at].withSmoking(p.smoking); + } + } + + return PlaceCatalogue( + source: source, + syncedAt: syncedAt, + places: merged, + ); + } + + /// Smoking policies present, ordered as the filter row shows them. + List get smokingPolicies { + const List order = ['none', 'alternative']; + return order + .where((String v) => places.any((Place p) => p.smoking == v)) + .toList(); + } + /// Categories present, ordered by how many places use them. List get categories { final Map counts = {}; @@ -91,6 +157,20 @@ class PlaceCatalogue { } } +/// Name reduced to letters and digits, so `Kaži Važi` and `Kazi Vazi!` are one +/// venue rather than two. +String _nameKey(String name) => + name.toLowerCase().replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ''); + +/// Straight-line distance in metres. Fine at this scale, and no trigonometry +/// beyond one cosine. +double _metresBetween(Place a, Place b) { + final double dLat = (a.lat - b.lat) * 111320; + final double dLng = + (a.lng - b.lng) * 111320 * math.cos(a.lat * math.pi / 180); + return math.sqrt(dLat * dLat + dLng * dLng); +} + /// Icon and colour per catalogue category, so the map reads at a glance. ({IconData icon, Color color}) placeStyle(String category) { switch (category) { @@ -127,5 +207,23 @@ class PlaceCatalogue { } } +/// Icon and colour for a smoking policy. +({IconData icon, Color color}) smokingStyle(String smoking) => + smoking == 'alternative' + ? (icon: Icons.air, color: const Color(0xFF0C8599)) + : (icon: Icons.smoke_free, color: const Color(0xFF2F9E44)); + +/// How a place is drawn on the map and in the list. +/// +/// The non-smoking map carries no category, so those venues would otherwise +/// all be grey pins; their policy is the useful thing to show instead. +({IconData icon, Color color}) placeMarkerStyle(Place place) => + place.category.isEmpty && place.smoking.isNotEmpty + ? smokingStyle(place.smoking) + : placeStyle(place.category); + /// Localization key for a category label. String placeCategoryKey(String category) => 'place_cat_$category'; + +/// Localization key for a smoking policy label. +String placeSmokingKey(String smoking) => 'place_smoking_$smoking'; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f4ad1dc..8773164 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -10,7 +10,6 @@ "need_make_visa_run_by": "Need to make visa run by", "create_whiteboard": "Create whiteboard", "flat_tax_calculator": "Flat tax calculator", - "maps": "Maps", "guide": "Guide", "tg_chats": "Telegram chats", "settings": "Settings", @@ -181,12 +180,12 @@ "place_cat_sport": "Sport", "place_cat_misc": "Misc", "places": "Places", - "places_subtitle": "Relocant-run businesses on the map", + "places_subtitle": "Relocant-run businesses and non-smoking venues", "map_view": "Map", "list_view": "List", "open_in_maps": "Open in maps", "search_places": "Search by name", - "places_source": "Data: stats.srb.guide · tiles © OpenStreetMap", + "places_source": "Data: stats.srb.guide and lokalibezdima.rs · tiles © OpenStreetMap", "no_places_found": "Nothing found", "places_count": "places", "reset_filters": "Reset", @@ -216,5 +215,9 @@ "overstay_body": "The allowance has run out. Overstaying means a fine and possibly an entry ban — talk to a lawyer.", "how_days_counted": "How days are counted", "how_days_counted_body": "The entry day and the exit day both count: arrive on 1 January, leave on 2 January, and that is two days.\n\nUnder a 30-days-per-entry rule the counter restarts every time you cross the border. Under a rolling window it does not — every day spent in the country during the window counts, however many trips it took.", - "calc_disclaimer": "A guide, not legal advice. The officer at the border decides." + "calc_disclaimer": "A guide, not legal advice. The officer at the border decides.", + "places_group": "Places and transport", + "place_smoking_none": "Non-smoking", + "place_smoking_alternative": "Smokeless only", + "exchange_offices": "Exchange offices nearby" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f8e723c..c1be319 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -10,7 +10,6 @@ "need_make_visa_run_by": "Нужно сделать визаран до", "create_whiteboard": "Создание белого картона", "flat_tax_calculator": "Калькулятор паушального налога", - "maps": "Карты", "guide": "Гайд", "tg_chats": "Телеграм чаты", "settings": "Настройки", @@ -180,12 +179,12 @@ "place_cat_sport": "Спорт", "place_cat_misc": "Разное", "places": "Места", - "places_subtitle": "Бизнесы релокантов на карте", + "places_subtitle": "Места релокантов и заведения без курения", "map_view": "Карта", "list_view": "Список", "open_in_maps": "Открыть в картах", "search_places": "Поиск по названию", - "places_source": "Данные: stats.srb.guide · тайлы © OpenStreetMap", + "places_source": "Данные: stats.srb.guide и lokalibezdima.rs · тайлы © OpenStreetMap", "no_places_found": "Ничего не найдено", "places_count": "мест", "reset_filters": "Сбросить", @@ -215,5 +214,9 @@ "overstay_body": "Безвизовый срок закончился. Просрочка — это штраф и возможный запрет на въезд, лучше обратиться к юристу.", "how_days_counted": "Как считаются дни", "how_days_counted_body": "День въезда и день выезда считаются оба: въехали 1 января, выехали 2 января — это два дня.\n\nПри правиле «30 дней на въезд» счётчик обнуляется при каждом пересечении границы. При скользящем окне — нет: считаются все дни в стране за это окно, сколько бы поездок ни было.", - "calc_disclaimer": "Это ориентир, а не юридическая консультация. Решение принимает офицер на границе." + "calc_disclaimer": "Это ориентир, а не юридическая консультация. Решение принимает офицер на границе.", + "places_group": "Места и транспорт", + "place_smoking_none": "Без курения", + "place_smoking_alternative": "Бездымная альтернатива", + "exchange_offices": "Обменники рядом" } diff --git a/lib/screens/map.dart b/lib/screens/map.dart deleted file mode 100644 index dfaa8ca..0000000 --- a/lib/screens/map.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:srbguide/localization/app_localizations.dart'; -import 'package:srbguide/service/url_launcher_helper.dart'; -import 'package:srbguide/widget/app_bar.dart'; -import 'package:srbguide/widget/themed/themed_icon.dart'; -import 'package:webview_flutter/webview_flutter.dart'; - -class MapScreen extends StatefulWidget { - const MapScreen({super.key}); - - @override - State createState() => _MapScreenState(); -} - -class _MapScreenState extends State { - List> locations = []; - late Map selectedLocation = {}; - late final WebViewController _webViewController; - late String selectedUrl = ""; - - @override - void initState() { - super.initState(); - _webViewController = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setNavigationDelegate( - NavigationDelegate( - // Map providers redirect to intent:// / geo:// to hand the route off - // to a native app. The WebView can't load those schemes and shows - // ERR_UNKNOWN_URL_SCHEME, so send them to the platform instead. - onNavigationRequest: (NavigationRequest request) { - final Uri? uri = Uri.tryParse(request.url); - if (uri != null && uri.scheme != 'http' && uri.scheme != 'https') { - UrlLauncherHelper.launchURL(request.url); - return NavigationDecision.prevent; - } - return NavigationDecision.navigate; - }, - ), - ); - loadLocations(); - } - - Future loadLocations() async { - String data = await DefaultAssetBundle.of(context) - .loadString('assets/data/locations.json'); - setState(() { - locations = List>.from(json.decode(data)); - if (locations.isNotEmpty) { - selectedLocation = locations[0]; - selectedUrl = selectedLocation['url'] ?? ''; - } - }); - if (selectedUrl.isNotEmpty) { - await _webViewController.loadRequest(Uri.parse(selectedUrl)); - } - } - - @override - Widget build(BuildContext context) { - if (locations.isEmpty) { - return Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ); - } - - return Scaffold( - appBar: CustomAppBar( - title: AppLocalizations.of(context)!.translate('maps'), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: double.infinity, - child: DropdownButton>( - value: selectedLocation, - onChanged: (newValue) { - setState(() { - selectedLocation = newValue!; - selectedUrl = newValue['url'] ?? ''; - }); - if (selectedUrl.isNotEmpty) { - _webViewController.loadRequest(Uri.parse(selectedUrl)); - } - }, - items: locations.map>>( - (location) { - return DropdownMenuItem>( - value: location, - child: Row( - children: [ - ThemedIcon( - iconPath: - 'assets/icons_24x24/${location['iconPath']}', - size: 24.0, - ), - SizedBox(width: 8), - Text(location['title'] ?? ''), - ], - ), - ); - }, - ).toList(), - icon: ThemedIcon( - iconPath: 'assets/icons_24x24/caret-down.png', - size: 24.0, - ), - ), - ), - SizedBox(height: 10.0), - Expanded( - child: selectedUrl.isNotEmpty - ? WebViewWidget(controller: _webViewController) - : Center( - child: Text('No URL selected'), - ), - ), - Card( - margin: EdgeInsets.all(8.0), - child: ListTile( - contentPadding: EdgeInsets.symmetric(horizontal: 16.0), - title: Center( - child: Text( - AppLocalizations.of(context)! - .translate('open_selected_map'), - style: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - ), - onTap: () { - if (selectedUrl.isNotEmpty) { - UrlLauncherHelper.launchURL(selectedUrl); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('No URL selected')), - ); - } - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/places.dart b/lib/screens/places.dart index 2348cb2..53d7cc8 100644 --- a/lib/screens/places.dart +++ b/lib/screens/places.dart @@ -10,11 +10,14 @@ import 'package:srbguide/localization/app_localizations.dart'; import 'package:srbguide/service/url_launcher_helper.dart'; import 'package:srbguide/widget/guide_tiles.dart'; -/// Map of relocant-run businesses. +/// Map of relocant-run businesses and non-smoking venues. /// /// Tiles come from OpenStreetMap, which needs no API key or billing account — -/// the Google Maps SDK would need both. The catalogue itself is bundled, so the +/// the Google Maps SDK would need both. Both catalogues are bundled, so the /// list works with no connection and only the tiles need one. +/// +/// This screen replaced the old "Maps" screen, which was a dropdown of Google +/// My Maps links in a WebView: the venues it pointed at are now pins here. class PlacesScreen extends StatefulWidget { const PlacesScreen({super.key}); @@ -29,8 +32,14 @@ class _PlacesScreenState extends State { final MapController _map = MapController(); final TextEditingController _search = TextEditingController(); + /// Google Maps search the old maps screen linked to. There is no bundled + /// list of exchange offices — the search finds the ones that are open now. + static const String _exchangeOfficesUrl = + 'https://www.google.com/maps/search/Мењачница'; + PlaceCatalogue _catalogue = PlaceCatalogue.empty; String? _category; + String? _smoking; bool _mapView = true; bool _loading = true; @@ -49,13 +58,13 @@ class _PlacesScreenState extends State { Future _load() async { try { - final String raw = await rootBundle.loadString('assets/data/places.json'); - final PlaceCatalogue catalogue = PlaceCatalogue.fromJson( - json.decode(raw) as Map, - ); + final PlaceCatalogue businesses = await _asset('places.json'); + // The non-smoking map is a separate feed with its own refresh cycle, so + // a failure there must not cost us the businesses. + final PlaceCatalogue smoking = await _asset('smoking.json'); if (!mounted) return; setState(() { - _catalogue = catalogue; + _catalogue = businesses.mergedWith(smoking); _loading = false; }); } catch (_) { @@ -64,10 +73,20 @@ class _PlacesScreenState extends State { } } + Future _asset(String name) async { + try { + final String raw = await rootBundle.loadString('assets/data/$name'); + return PlaceCatalogue.fromJson(json.decode(raw) as Map); + } catch (_) { + return PlaceCatalogue.empty; + } + } + List get _visible { final String q = _search.text.trim().toLowerCase(); return _catalogue.places.where((Place p) { if (_category != null && p.category != _category) return false; + if (_smoking != null && p.smoking != _smoking) return false; if (q.isNotEmpty && !p.searchIndex.contains(q)) return false; return true; }).toList(); @@ -98,6 +117,11 @@ class _PlacesScreenState extends State { icon: Icon(_mapView ? Icons.list : Icons.map_outlined), onPressed: () => setState(() => _mapView = !_mapView), ), + IconButton( + tooltip: l10n.translate('exchange_offices'), + icon: const Icon(Icons.currency_exchange), + onPressed: () => UrlLauncherHelper.launchURL(_exchangeOfficesUrl), + ), ], ), body: _loading @@ -135,8 +159,31 @@ class _PlacesScreenState extends State { padding: const EdgeInsets.only(right: 8), child: FilterChip( label: Text(l10n.translate('all')), - selected: _category == null, - onSelected: (_) => setState(() => _category = null), + selected: _category == null && _smoking == null, + onSelected: (_) => setState(() { + _category = null; + _smoking = null; + }), + ), + ), + // Smoking first: it is the one filter people come to the + // map with rather than browse by. + ..._catalogue.smokingPolicies.map( + (String v) => Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + avatar: Icon( + smokingStyle(v).icon, + size: 16, + color: smokingStyle(v).color, + ), + label: Text(l10n.translate(placeSmokingKey(v))), + selected: _smoking == v, + onSelected: (bool on) => setState(() { + _smoking = on ? v : null; + _category = null; + }), + ), ), ), ..._catalogue.categories.map( @@ -150,8 +197,10 @@ class _PlacesScreenState extends State { ), label: Text(l10n.translate(placeCategoryKey(c))), selected: _category == c, - onSelected: (bool on) => - setState(() => _category = on ? c : null), + onSelected: (bool on) => setState(() { + _category = on ? c : null; + _smoking = null; + }), ), ), ), @@ -260,7 +309,7 @@ class _Pin extends StatelessWidget { @override Widget build(BuildContext context) { - final ({IconData icon, Color color}) style = placeStyle(place.category); + final ({IconData icon, Color color}) style = placeMarkerStyle(place); return GestureDetector( onTap: onTap, child: Container( @@ -306,7 +355,7 @@ class _ListView extends StatelessWidget { separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (BuildContext context, int i) { final Place p = places[i]; - final ({IconData icon, Color color}) style = placeStyle(p.category); + final ({IconData icon, Color color}) style = placeMarkerStyle(p); return Card( child: InkWell( onTap: () => onTap(p), @@ -347,6 +396,10 @@ class _ListView extends StatelessWidget { ), ), ], + if (p.smoking.isNotEmpty) ...[ + const SizedBox(height: 6), + _SmokingBadge(smoking: p.smoking), + ], ], ), ), @@ -369,7 +422,7 @@ class _PlaceSheet extends StatelessWidget { Widget build(BuildContext context) { final ColorScheme scheme = Theme.of(context).colorScheme; final AppLocalizations l10n = AppLocalizations.of(context)!; - final ({IconData icon, Color color}) style = placeStyle(place.category); + final ({IconData icon, Color color}) style = placeMarkerStyle(place); return Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 28), @@ -415,6 +468,10 @@ class _PlaceSheet extends StatelessWidget { ), ], ), + if (place.smoking.isNotEmpty) ...[ + const SizedBox(height: 14), + _SmokingBadge(smoking: place.smoking), + ], if (place.description.isNotEmpty) ...[ const SizedBox(height: 14), Text( @@ -435,3 +492,39 @@ class _PlaceSheet extends StatelessWidget { ); } } + +/// Marks what the non-smoking map says about a venue. +class _SmokingBadge extends StatelessWidget { + final String smoking; + + const _SmokingBadge({required this.smoking}); + + @override + Widget build(BuildContext context) { + final AppLocalizations l10n = AppLocalizations.of(context)!; + final ({IconData icon, Color color}) style = smokingStyle(smoking); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: style.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(style.icon, size: 14, color: style.color), + const SizedBox(width: 5), + Text( + l10n.translate(placeSmokingKey(smoking)), + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: style.color, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/services.dart b/lib/screens/services.dart index c9d77c1..c2fdfae 100644 --- a/lib/screens/services.dart +++ b/lib/screens/services.dart @@ -6,7 +6,6 @@ import 'package:srbguide/screens/calculator.dart'; import 'package:srbguide/screens/deadlines.dart'; import 'package:srbguide/screens/exchange_rate.dart'; import 'package:srbguide/screens/journey.dart'; -import 'package:srbguide/screens/map.dart'; import 'package:srbguide/screens/places.dart'; import 'package:srbguide/screens/settings.dart'; import 'package:srbguide/screens/tg_chats.dart'; @@ -14,7 +13,7 @@ import 'package:srbguide/screens/trains.dart'; import 'package:srbguide/screens/white_cardboard.dart'; import 'package:srbguide/service/url_launcher_helper.dart'; -/// Everything that is not the guide itself: calculators, rates, maps, chats +/// Everything that is not the guide itself: calculators, rates, places, chats /// and the external links that used to live in the navigation drawer. class ServicesScreen extends StatelessWidget { const ServicesScreen({super.key}); @@ -63,11 +62,6 @@ class ServicesScreen extends StatelessWidget { title: l10n.translate('places'), push: (_) => const PlacesScreen(), ), - _Entry( - icon: Icons.map_outlined, - title: l10n.translate('maps'), - push: (_) => const MapScreen(), - ), _Entry( icon: Icons.train_outlined, title: l10n.translate('trains'), @@ -113,7 +107,7 @@ class ServicesScreen extends StatelessWidget { delegate: SliverChildListDelegate([ _Group(title: l10n.translate('quick_actions'), entries: tools), const SizedBox(height: 20), - _Group(title: l10n.translate('maps'), entries: places), + _Group(title: l10n.translate('places_group'), entries: places), const SizedBox(height: 20), _Group(title: l10n.translate('help'), entries: about), ]), diff --git a/lib/screens/trains.dart b/lib/screens/trains.dart index 1deb5ce..c910862 100644 --- a/lib/screens/trains.dart +++ b/lib/screens/trains.dart @@ -574,6 +574,7 @@ class _StationPickerState extends State<_StationPicker> { List _recent = const []; Timer? _debounce; bool _loading = false; + String? _error; @override void initState() { @@ -598,9 +599,21 @@ class _StationPickerState extends State<_StationPicker> { setState(() => _loading = true); try { final List r = await _service.searchStations(value); - if (mounted) setState(() => _results = r); - } catch (_) { - if (mounted) setState(() => _results = const []); + if (mounted) { + setState(() { + _results = r; + _error = null; + }); + } + } catch (e) { + // The station list comes over the network the first time, so a failure + // here is "we could not look", not "there is no such station". + if (mounted) { + setState(() { + _results = const []; + _error = e.toString(); + }); + } } finally { if (mounted) setState(() => _loading = false); } @@ -666,14 +679,18 @@ class _StationPickerState extends State<_StationPicker> { child: shown.isEmpty ? Padding( padding: const EdgeInsets.symmetric(vertical: 28), - child: Center( - child: Text( - typing - ? l10n.translate('nothing_found') - : l10n.translate('search_station'), - style: TextStyle(color: scheme.onSurfaceVariant), - ), - ), + child: _error != null + ? _ErrorNote( + message: '${l10n.translate('load_error')}: $_error', + ) + : Center( + child: Text( + typing + ? l10n.translate('nothing_found') + : l10n.translate('search_station'), + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ), ) : ListView.separated( shrinkWrap: true, diff --git a/lib/service/srbijavoz_service.dart b/lib/service/srbijavoz_service.dart index d576581..5ff90df 100644 --- a/lib/service/srbijavoz_service.dart +++ b/lib/service/srbijavoz_service.dart @@ -6,6 +6,7 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import 'package:srbguide/data/train.dart'; +import 'package:srbguide/utils/station_search.dart'; /// Reads the Serbian Railways timetable at w3.srbvoz.rs. /// @@ -14,6 +15,11 @@ import 'package:srbguide/data/train.dart'; /// therefore clean JSON; the timetables have to be read out of the result /// tables, which is why the row layouts are pinned down in one place here /// rather than scattered through the UI. +/// +/// The station lookup only understands Latin, so it is used the way the site's +/// own front end uses it for a term it cannot send — once, with no term, which +/// returns the whole network. Matching then happens on device, where a Russian +/// spelling can be transliterated. See `utils/station_search.dart`. class SrbijavozService { SrbijavozService._(); @@ -21,6 +27,12 @@ class SrbijavozService { static const String _base = 'https://w3.srbvoz.rs/redvoznje'; static const String _recentKey = 'recentTrainStations'; + static const String _stationsKey = 'trainStations'; + static const String _stationsAtKey = 'trainStationsFetchedAt'; + + /// The network barely changes, and a stale list still resolves to the same + /// codes, so this only guards against a station being added and never seen. + static const Duration _stationsTtl = Duration(days: 7); static const Map _headers = { 'User-Agent': @@ -28,22 +40,89 @@ class SrbijavozService { 'Accept-Language': 'sr,en;q=0.8', }; - /// Cache for the station lookup — the field re-queries on every keystroke - /// and the station list barely changes. - final Map> _stationCache = - >{}; + /// The whole network, once it has been fetched. + List? _stations; - /// Autocomplete over station names. + /// Autocomplete over station names, matched on device. Future> searchStations(String term) async { final String q = term.trim(); if (q.length < 2) return const []; + return matchStations(await stations(), q); + } + + /// Every station the timetable knows, from memory, storage or the network. + /// + /// 396 rows and 15 KB, so it is worth holding: the picker then answers every + /// keystroke without a request, and works while the connection is flaky. + Future> stations() async { + final List? held = _stations; + if (held != null) return held; + + final ({List stations, bool fresh}) stored = + await _storedStations(); + if (stored.stations.isNotEmpty && stored.fresh) { + return _stations = stored.stations; + } + + try { + final List fetched = await _fetchStations(); + if (fetched.isNotEmpty) { + await _storeStations(fetched); + return _stations = fetched; + } + } catch (e) { + // An expired list still names every station people search for. + if (stored.stations.isEmpty) { + throw TrainServiceException('network error ($e)'); + } + } + + if (stored.stations.isEmpty) { + throw const TrainServiceException('station list unavailable'); + } + return _stations = stored.stations; + } - final String key = q.toLowerCase(); - final List? cached = _stationCache[key]; - if (cached != null) return cached; + /// The stored copy and whether it is still within its TTL. Storage failing — + /// as it does in a plain test binding — only costs us the cache. + Future<({List stations, bool fresh})> _storedStations() async { + try { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final DateTime? fetchedAt = + DateTime.tryParse(prefs.getString(_stationsAtKey) ?? ''); + return ( + stations: _decodeStations( + prefs.getStringList(_stationsKey) ?? const [], + ), + fresh: fetchedAt != null && + DateTime.now().difference(fetchedAt) < _stationsTtl, + ); + } catch (_) { + return (stations: const [], fresh: false); + } + } + + Future _storeStations(List stations) async { + try { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _stationsKey, + stations.map((TrainStation s) => json.encode(s.toJson())).toList(), + ); + await prefs.setString( + _stationsAtKey, + DateTime.now().toIso8601String(), + ); + } catch (_) { + // Nothing to do — the list is held in memory for this run either way. + } + } + /// Asks the lookup with no term, which is what the operator's own front end + /// does with a term it cannot encode, and is answered with every station. + Future> _fetchStations() async { final Uri uri = Uri.parse('$_base/api/stanica/') - .replace(queryParameters: {'term': q}); + .replace(queryParameters: {'term': ''}); final http.Response response = await http.get(uri, headers: { ..._headers, @@ -56,16 +135,25 @@ class SrbijavozService { final List decoded = json.decode(utf8.decode(response.bodyBytes)) as List; - final List stations = decoded + return decoded .cast>() .map(TrainStation.fromJson) .where((TrainStation s) => s.isValid) .toList(); - - _stationCache[key] = stations; - return stations; } + List _decodeStations(List raw) => raw + .map((String s) { + try { + return TrainStation.fromJson(json.decode(s) as Map); + } catch (_) { + return null; + } + }) + .whereType() + .where((TrainStation s) => s.isValid) + .toList(); + /// Direct services between two stations on [date]. Future> connections({ required TrainStation from, @@ -177,20 +265,7 @@ class SrbijavozService { Future> recentStations() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); - final List raw = prefs.getStringList(_recentKey) ?? []; - return raw - .map((String s) { - try { - return TrainStation.fromJson( - json.decode(s) as Map, - ); - } catch (_) { - return null; - } - }) - .whereType() - .where((TrainStation s) => s.isValid) - .toList(); + return _decodeStations(prefs.getStringList(_recentKey) ?? []); } Future rememberStation(TrainStation station) async { diff --git a/lib/utils/station_search.dart b/lib/utils/station_search.dart new file mode 100644 index 0000000..f8cc101 --- /dev/null +++ b/lib/utils/station_search.dart @@ -0,0 +1,197 @@ +/// Matching for the railway station picker. +/// +/// The timetable's own lookup only understands Latin. Its front end strips +/// anything else out of the term before sending it, so "Белград" reaches the +/// server as an empty term — and an empty term is answered with all 396 +/// stations. The picker showed that dump as if it were the search result, +/// which is why typing a station in Russian looked like the search was broken. +/// +/// The whole list is 15 KB, so it is fetched once and matched here instead. +/// Russian and Serbian spell the same place differently — Белград/Beograd, +/// Крагуевац/Kragujevac — so on top of transliteration each word of the query +/// is allowed one character of slack against the station name. +library; + +import 'package:srbguide/data/train.dart'; + +/// Cyrillic to Latin, in the convention Serbian itself uses (ж → z, я → ja), +/// extended with the Russian letters Serbian does not have. +const Map _cyrillicToLatin = { + 'а': 'a', + 'б': 'b', + 'в': 'v', + 'г': 'g', + 'д': 'd', + 'ђ': 'dj', + 'е': 'e', + 'ё': 'jo', + 'ж': 'z', + 'з': 'z', + 'и': 'i', + 'й': 'j', + 'ј': 'j', + 'к': 'k', + 'л': 'l', + 'љ': 'lj', + 'м': 'm', + 'н': 'n', + 'њ': 'nj', + 'о': 'o', + 'п': 'p', + 'р': 'r', + 'с': 's', + 'т': 't', + 'ћ': 'c', + 'у': 'u', + 'ф': 'f', + 'х': 'h', + 'ц': 'c', + 'ч': 'c', + 'џ': 'dz', + 'ш': 's', + 'щ': 'sc', + 'ъ': '', + 'ы': 'i', + 'ь': '', + 'э': 'e', + 'ю': 'ju', + 'я': 'ja', +}; + +/// Serbian Latin diacritics, folded so "nis" finds "NIŠ". +const Map _diacriticsToAscii = { + 'š': 's', + 'č': 'c', + 'ć': 'c', + 'ž': 'z', + 'đ': 'dj', +}; + +/// Lower-cased, transliterated, stripped of everything but letters and digits. +/// +/// `NIŠ` and `Ниш` both come out as `nis`, so the two alphabets meet. +String normalizeStation(String value) { + final StringBuffer out = StringBuffer(); + bool pendingSpace = false; + + for (final int rune in value.toLowerCase().runes) { + final String ch = String.fromCharCode(rune); + final String mapped = _cyrillicToLatin[ch] ?? _diacriticsToAscii[ch] ?? ch; + + final bool plain = RegExp(r'^[a-z0-9]*$').hasMatch(mapped); + if (!plain || mapped.isEmpty) { + // Hyphens, dots and anything else become a single separator: the + // timetable writes both "NOVI SAD" and "PANČEVO-VAROŠ". + if (!plain) pendingSpace = out.isNotEmpty; + continue; + } + if (pendingSpace) { + out.write(' '); + pendingSpace = false; + } + out.write(mapped); + } + return out.toString(); +} + +/// Stations matching [query], best first. +/// +/// Ranked exact, prefix, substring, then one-typo — so "beo" puts BEOGRAD +/// CENTAR above NOVI BEOGRAD, and "Белград" still finds both. +List matchStations( + List stations, + String query, { + int limit = 40, +}) { + final String q = normalizeStation(query); + if (q.isEmpty) return const []; + final List words = q.split(' '); + + final List<_Hit> hits = <_Hit>[]; + + for (final TrainStation station in stations) { + final String name = normalizeStation(station.name); + final int rank; + int at = 0; + if (name == q) { + rank = 0; + } else if (name.startsWith(q)) { + rank = 1; + } else if (name.contains(q)) { + rank = 2; + } else { + final int? typoAt = _typoMatch(name, words); + if (typoAt == null) continue; + rank = 3; + at = typoAt; + } + hits.add(_Hit(rank, at, name.length, station)); + } + + // Rank, then how early in the name the match sits — "Белград" should offer + // BEOGRAD CENTAR before NOVI BEOGRAD — then the shorter name. + hits.sort((_Hit a, _Hit b) { + if (a.rank != b.rank) return a.rank.compareTo(b.rank); + if (a.at != b.at) return a.at.compareTo(b.at); + if (a.length != b.length) return a.length.compareTo(b.length); + return a.station.name.compareTo(b.station.name); + }); + + return hits.take(limit).map((_Hit h) => h.station).toList(); +} + +class _Hit { + final int rank; + final int at; + final int length; + final TrainStation station; + + const _Hit(this.rank, this.at, this.length, this.station); +} + +/// Where the query first lands in [name] when every one of its words matches a +/// word of the name give or take one character, and null when one does not. +/// +/// Short words are left out of the slack: at three letters one edit matches +/// half the network. +int? _typoMatch(String name, List words) { + final List nameWords = name.split(' '); + int? first; + + for (final String word in words) { + final int at = nameWords.indexWhere((String w) => + word.length < 5 ? w.startsWith(word) : _withinOneEdit(w, word)); + if (at == -1) return null; + first ??= at; + } + return first ?? 0; +} + +/// Levenshtein distance of at most one, without building the matrix. +bool _withinOneEdit(String a, String b) { + if ((a.length - b.length).abs() > 1) return false; + + int i = 0; + int j = 0; + bool edited = false; + while (i < a.length && j < b.length) { + if (a[i] == b[j]) { + i++; + j++; + continue; + } + if (edited) return false; + edited = true; + // Substitution when the lengths match, otherwise skip the longer side. + if (a.length == b.length) { + i++; + j++; + } else if (a.length > b.length) { + i++; + } else { + j++; + } + } + // A leftover character on either side is the edit itself. + return !(edited && (i < a.length || j < b.length)); +} diff --git a/pubspec.lock b/pubspec.lock index 25d395c..14fb0a5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -922,38 +922,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - webview_flutter: - dependency: "direct main" - description: - name: webview_flutter - sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 - url: "https://pub.dev" - source: hosted - version: "4.14.1" - webview_flutter_android: - dependency: transitive - description: - name: webview_flutter_android - sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964" - url: "https://pub.dev" - source: hosted - version: "4.14.1" - webview_flutter_platform_interface: - dependency: transitive - description: - name: webview_flutter_platform_interface - sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" - url: "https://pub.dev" - source: hosted - version: "2.15.1" - webview_flutter_wkwebview: - dependency: transitive - description: - name: webview_flutter_wkwebview - sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730 - url: "https://pub.dev" - source: hosted - version: "3.26.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4d0489f..b4fa4b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,10 @@ description: A new Flutter project. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.0.0+1 +# versionName+versionCode for the Android build (android/app/build.gradle.kts +# reads both from here). versionCode 12 is what is published on Google Play +# (release 12 (1.0.0), 21 Jan 2024), so every upload has to raise it. +version: 2.0.0+14 environment: sdk: '>=3.5.0 <4.0.0' @@ -17,7 +20,6 @@ dependencies: cupertino_icons: ^1.0.2 xml: ^6.5.0 docx_template: ^0.4.0 - webview_flutter: ^4.14.0 url_launcher: ^6.3.1 shared_preferences: ^2.5.3 add_2_calendar: ^3.1.1 diff --git a/test/place_merge_test.dart b/test/place_merge_test.dart new file mode 100644 index 0000000..0294d85 --- /dev/null +++ b/test/place_merge_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:srbguide/data/place.dart'; + +Place _place( + String name, { + double lat = 44.8125, + double lng = 20.4612, + String category = 'food', + String smoking = '', +}) => + Place( + id: name, + name: name, + description: '', + lat: lat, + lng: lng, + category: category, + city: 'beograd', + opstina: '', + mapUrl: '', + smoking: smoking, + ); + +PlaceCatalogue _catalogue(List places) => PlaceCatalogue( + source: 'test', + syncedAt: null, + places: places, + ); + +void main() { + group('mergedWith', () { + test('marks the same venue instead of adding a second pin', () { + final PlaceCatalogue merged = + _catalogue([_place('Le Bol')]).mergedWith(_catalogue([ + _place('Le Bol', category: '', smoking: 'none'), + ])); + + expect(merged.places, hasLength(1)); + expect(merged.places.single.category, 'food'); + expect(merged.places.single.smoking, 'none'); + }); + + test('matches through punctuation and case', () { + final PlaceCatalogue merged = _catalogue([_place('Kaži Važi')]) + .mergedWith(_catalogue([ + _place('KAŽI VAŽI!', category: '', smoking: 'alternative'), + ])); + + expect(merged.places, hasLength(1)); + expect(merged.places.single.smoking, 'alternative'); + }); + + test('keeps a namesake in another city as its own place', () { + // Same name, ~90 km away: two different venues. + final PlaceCatalogue merged = + _catalogue([_place('Kofilin')]).mergedWith(_catalogue([ + _place( + 'Kofilin', + lat: 45.2551, + lng: 19.8452, + category: '', + smoking: 'none', + ), + ])); + + expect(merged.places, hasLength(2)); + expect(merged.places.first.smoking, isEmpty); + }); + + test('never downgrades a policy already on the catalogue', () { + final PlaceCatalogue merged = + _catalogue([_place('Gurme', smoking: 'none')]) + .mergedWith(_catalogue([ + _place('Gurme', category: '', smoking: 'alternative'), + ])); + + expect(merged.places, hasLength(1)); + expect(merged.places.single.smoking, 'none'); + }); + + test('keeps the source and timestamp of the base catalogue', () { + final PlaceCatalogue base = PlaceCatalogue( + source: 'https://stats.srb.guide/map', + syncedAt: DateTime.utc(2026, 9, 5), + places: [_place('Sonder')], + ); + final PlaceCatalogue merged = base + .mergedWith(_catalogue([_place('Ananda', smoking: 'none')])); + + expect(merged.source, 'https://stats.srb.guide/map'); + expect(merged.syncedAt, DateTime.utc(2026, 9, 5)); + expect(merged.places, hasLength(2)); + }); + + test('an empty second catalogue changes nothing', () { + final PlaceCatalogue base = _catalogue([_place('Sonder')]); + expect(base.mergedWith(PlaceCatalogue.empty), same(base)); + }); + }); + + group('smokingPolicies', () { + test('lists only the policies present, strictest first', () { + final PlaceCatalogue catalogue = _catalogue([ + _place('a', smoking: 'alternative'), + _place('b'), + _place('c', smoking: 'none'), + ]); + expect(catalogue.smokingPolicies, ['none', 'alternative']); + }); + + test('is empty for a catalogue that tracks no policy', () { + expect(_catalogue([_place('a')]).smokingPolicies, isEmpty); + }); + }); + + group('placeMarkerStyle', () { + test('falls back to the smoking policy when there is no category', () { + final Place p = _place('a', category: '', smoking: 'none'); + expect(placeMarkerStyle(p), smokingStyle('none')); + }); + + test('keeps the category icon when the venue has one', () { + final Place p = _place('a', smoking: 'none'); + expect(placeMarkerStyle(p), placeStyle('food')); + }); + }); +} diff --git a/test/srbijavoz_live_test.dart b/test/srbijavoz_live_test.dart index c10482c..8e4b29b 100644 --- a/test/srbijavoz_live_test.dart +++ b/test/srbijavoz_live_test.dart @@ -23,6 +23,21 @@ void main() { expect(stations.every((TrainStation s) => s.isValid), isTrue); }, timeout: const Timeout(Duration(seconds: 60))); + test('the whole network is fetched, and searchable in Russian', () async { + final List all = await service.stations(); + // ignore: avoid_print + print('network: ${all.length} stations'); + // The lookup answers a term it cannot spell with everything it has; if that + // ever stops being true, the picker is back to one request per keystroke. + expect(all.length, greaterThan(300)); + + final List found = await service.searchStations('Белград'); + // ignore: avoid_print + print('Белград -> ${found.map((TrainStation s) => s.name).join(', ')}'); + expect(found, isNotEmpty); + expect(found.first.name, contains('BEOGRAD')); + }, timeout: const Timeout(Duration(seconds: 60))); + test('direct connections parse', () async { final List from = await service.searchStations('beograd centar'); diff --git a/test/station_search_test.dart b/test/station_search_test.dart new file mode 100644 index 0000000..dace4a0 --- /dev/null +++ b/test/station_search_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:srbguide/data/train.dart'; +import 'package:srbguide/utils/station_search.dart'; + +/// A slice of the real timetable, spelled the way it spells them. +final List _network = [ + const TrainStation(name: 'BEOGRAD CENTAR', code: '16052'), + const TrainStation(name: 'NOVI BEOGRAD', code: '16003'), + const TrainStation(name: 'NOVI SAD', code: '16808'), + const TrainStation(name: 'NOVI SAD RANŽIRNA', code: '16870'), + const TrainStation(name: 'NIŠ', code: '12551'), + const TrainStation(name: 'NIŠEVAC', code: '14008'), + const TrainStation(name: 'KRAGUJEVAC', code: '13217'), + const TrainStation(name: 'SUBOTICA', code: '23450'), + const TrainStation(name: 'VRŠAC', code: '22101'), + const TrainStation(name: 'UŽICE', code: '15501'), + const TrainStation(name: 'ZRENJANIN', code: '22506'), + const TrainStation(name: 'PANČEVO-VAROŠ', code: '21005'), + const TrainStation(name: 'ŠID', code: '17008'), +]; + +List _names(String query) => + matchStations(_network, query).map((TrainStation s) => s.name).toList(); + +void main() { + group('normalizeStation', () { + test('brings both alphabets to the same spelling', () { + expect(normalizeStation('Ниш'), normalizeStation('NIŠ')); + expect(normalizeStation('NIŠ'), 'nis'); + expect(normalizeStation('Суботица'), 'subotica'); + expect(normalizeStation('Ужице'), normalizeStation('UŽICE')); + }); + + test('treats punctuation as a word break', () { + expect(normalizeStation('PANČEVO-VAROŠ'), 'pancevo varos'); + expect(normalizeStation('BEOGRAD CENTAR'), 'beograd centar'); + expect( + normalizeStation('SUBOTICA JAV.SKLADIŠTA'), 'subotica jav skladista'); + }); + + test('is empty for a query with nothing to match on', () { + expect(normalizeStation(' ... '), ''); + }); + }); + + group('matchStations', () { + test('finds a station typed in Russian', () { + // The bug this exists for: the timetable answered "Белград" with all 396 + // stations, so the picker looked like it had stopped searching. + expect(_names('Белград'), ['BEOGRAD CENTAR', 'NOVI BEOGRAD']); + expect(_names('Ниш'), ['NIŠ', 'NIŠEVAC']); + expect(_names('Нови Сад'), ['NOVI SAD', 'NOVI SAD RANŽIRNA']); + expect(_names('Суботица'), ['SUBOTICA']); + }); + + test('absorbs the spelling differences between the two languages', () { + // Крагуевац/Kragujevac and Белград/Beograd differ by one letter. + expect(_names('Крагуевац'), ['KRAGUJEVAC']); + expect(_names('Зренянин'), ['ZRENJANIN']); + expect(_names('Вршац'), ['VRŠAC']); + expect(_names('Панчево'), ['PANČEVO-VAROŠ']); + }); + + test('still works in Latin, with or without diacritics', () { + expect(_names('nis'), ['NIŠ', 'NIŠEVAC']); + expect(_names('niš'), ['NIŠ', 'NIŠEVAC']); + expect(_names('uzice'), ['UŽICE']); + expect(_names('novi sad'), ['NOVI SAD', 'NOVI SAD RANŽIRNA']); + }); + + test('matches inside the name, which the server could not', () { + // The timetable's lookup is a prefix match: "centar" returned nothing. + expect(_names('centar'), ['BEOGRAD CENTAR']); + expect(_names('ranžirna'), ['NOVI SAD RANŽIRNA']); + }); + + test('puts the closest match first', () { + expect(_names('beograd').first, 'BEOGRAD CENTAR'); + expect(_names('novi sad').first, 'NOVI SAD'); + expect(_names('niš').first, 'NIŠ'); + }); + + test('answers an unknown station with nothing at all', () { + expect(_names('zzzz'), isEmpty); + expect(_names('Мадрид'), isEmpty); + expect(_names('щщщщщ'), isEmpty); + }); + + test('does not let one typo match half the network', () { + // Three letters plus an edit would reach NIŠ, ŠID and more. + expect(_names('nid'), isEmpty); + }); + + test('honours the limit', () { + expect(matchStations(_network, 'n', limit: 2), hasLength(lessThan(3))); + }); + }); +} diff --git a/tool/build_release.ps1 b/tool/build_release.ps1 new file mode 100644 index 0000000..a924731 --- /dev/null +++ b/tool/build_release.ps1 @@ -0,0 +1,323 @@ +<# +.SYNOPSIS + Builds the signed App Bundle (for Google Play) and APK (for sideloading), + then proves the signature is what Play expects. + +.DESCRIPTION + Mirrors .github/workflows/release.yml so a local build and a tagged CI build + produce the same artifact under the same checks: + + 1. resolve the toolchain (Flutter, JDK, Android SDK build-tools); + 2. refuse to run without android/key.properties -- a debug-signed upload + is rejected by Play and cannot be upgraded to a real key afterwards; + 3. verify the upload key itself is SHA256withRSA; + 4. flutter pub get / analyze / test / validate the bundled guide; + 5. build the AAB and the APK at an explicit version; + 6. apksigner: APK Signature Scheme v2, a SHA-256 certificate digest, and + not the Android debug certificate; + 7. jarsigner -verify on the AAB; + 8. assert the merged manifest still targets SDK 36; + 9. copy both artifacts into dist/ under their version, with SHA-256 sums. + + Versions come from `version:` in pubspec.yaml unless overridden here. + +.EXAMPLE + .\tool\build_release.ps1 + # 2.0.0+14 straight from pubspec.yaml + +.EXAMPLE + .\tool\build_release.ps1 -VersionName 2.0.1 -BuildNumber 14 + +.EXAMPLE + .\tool\build_release.ps1 -SkipChecks -SkipClean + # fast rebuild while iterating; never for the build you actually upload +#> +[CmdletBinding()] +param( + # Play's versionName, e.g. 2.0.0. Defaults to pubspec.yaml. + [string]$VersionName, + # Play's versionCode. Must be higher than anything already uploaded. + [int]$BuildNumber = 0, + # The lowest versionCode Play will accept next. Bump after each upload. + [int]$PublishedBuildNumber = 12, + # Skip analyze/test/guide validation. Not for a build you will upload. + [switch]$SkipChecks, + # Reuse the previous build's intermediates. + [switch]$SkipClean, + # Build only the .aab (what Play wants) and skip the .apk. + [switch]$BundleOnly, + # Build only the .apk (sideloading, manual QA) and skip the .aab. + [switch]$ApkOnly +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot 'release_common.ps1') + +if ($BundleOnly -and $ApkOnly) { throw 'Pass at most one of -BundleOnly / -ApkOnly.' } +$buildBundle = -not $ApkOnly +$buildApk = -not $BundleOnly + +Push-Location $repoRoot +$started = Get-Date +$failure = $null +try { + +# ---------------------------------------------------------------- toolchain -- +Write-Section 'Toolchain' +$flutter = Resolve-FlutterExe -RepoRoot $repoRoot +$jdk = Resolve-JdkHome +$androidSdk = Resolve-AndroidSdk -RepoRoot $repoRoot +$apksigner = Resolve-ApkSigner -AndroidSdk $androidSdk +$dart = Join-Path (Split-Path -Parent $flutter) 'dart.bat' +$keytool = Join-Path $jdk 'bin\keytool.exe' +$jarsigner = Join-Path $jdk 'bin\jarsigner.exe' +foreach ($exe in @($dart, $keytool, $jarsigner)) { + if (-not (Test-Path $exe)) { throw "Not found: $exe" } +} +# apksigner.bat looks for java through JAVA_HOME and fails outright without it. +# Flutter is pinned to its own JDK (SETUP.md), which need not be on PATH, so +# hand the same one to the SDK tools for this process only. +$env:JAVA_HOME = $jdk +Write-Detail 'flutter' $flutter +Write-Detail 'jdk' $jdk +Write-Detail 'android sdk' $androidSdk +Write-Detail 'apksigner' $apksigner + +# ------------------------------------------------------------------ signing -- +Write-Section 'Signing key' +$keyPropsPath = Join-Path $repoRoot 'android\key.properties' +if (-not (Test-Path $keyPropsPath)) { + # Without this file build.gradle.kts falls back to the debug config on + # purpose, so that `flutter run --release` keeps working. That fallback must + # never reach Play. + throw @" +android/key.properties is missing, so a release build here would be signed with +the Android debug key and rejected by Play. + + First release ever: .\tool\create_upload_key.ps1 + Existing key: write android/key.properties as + + storeFile=D:\\path\\to\\upload-keystore.jks + storePassword=... + keyAlias=upload + keyPassword=... +"@ +} +$keyProps = Read-JavaProperties $keyPropsPath +foreach ($required in @('storeFile', 'storePassword', 'keyAlias', 'keyPassword')) { + if (-not $keyProps.ContainsKey($required) -or -not $keyProps[$required]) { + throw "android/key.properties has no value for '$required'." + } +} +$storeFile = $keyProps['storeFile'] +if (-not [IO.Path]::IsPathRooted($storeFile)) { + # Gradle resolves this one through the :app project, so a relative path is + # relative to android/app -- easy to get wrong. Accept it, but say so. + Write-Warn "storeFile is relative; Gradle resolves it against android/app. An absolute path is safer." + $storeFile = Join-Path (Join-Path $repoRoot 'android\app') $storeFile +} +if (-not (Test-Path $storeFile)) { throw "Keystore not found: $storeFile" } +Write-Detail 'keystore' $storeFile +Write-Detail 'alias' $keyProps['keyAlias'] + +$keyInfo = Invoke-Capture $keytool @( + '-list', '-v', + '-keystore', $storeFile, + '-alias', $keyProps['keyAlias'], + '-storepass', $keyProps['storePassword'] +) 'keytool -list' +if (-not ($keyInfo | Select-String -Pattern 'Signature algorithm name:\s*SHA256with')) { + throw 'The upload key is not SHA256withRSA. Regenerate it (see docs/RELEASE.md); Play will not accept it.' +} +$keyFingerprint = ($keyInfo | Select-String -Pattern '^\s+SHA256:\s*(\S+)' | Select-Object -First 1) +$keyFingerprintValue = '' +Write-Ok 'key certificate is SHA-256' +if ($keyFingerprint) { + $keyFingerprintValue = $keyFingerprint.Matches[0].Groups[1].Value + Write-Detail 'fingerprint' $keyFingerprintValue +} +$expiry = ($keyInfo | Select-String -Pattern 'until:' | Select-Object -First 1) +if ($expiry) { Write-Detail 'validity' $expiry.Line.Trim() } + +# ------------------------------------------------------------------ version -- +Write-Section 'Version' +$pubspecPath = Join-Path $repoRoot 'pubspec.yaml' +$pubspecVersion = (Select-String -Path $pubspecPath -Pattern '^version:\s*(\S+)\s*$' | + Select-Object -First 1) +if (-not $pubspecVersion) { throw 'No version: line in pubspec.yaml.' } +$pubspecValue = $pubspecVersion.Matches[0].Groups[1].Value +$parts = $pubspecValue.Split('+') +if (-not $VersionName) { $VersionName = $parts[0] } +if ($BuildNumber -le 0) { + if ($parts.Count -lt 2) { throw "pubspec version '$pubspecValue' has no +buildNumber; pass -BuildNumber." } + $BuildNumber = [int]$parts[1] +} +Write-Detail 'pubspec' $pubspecValue +Write-Detail 'versionName' $VersionName +Write-Detail 'versionCode' $BuildNumber +if ($BuildNumber -le $PublishedBuildNumber) { + # Play rejects a duplicate versionCode after the upload, several minutes in. + throw "versionCode $BuildNumber is not above $PublishedBuildNumber, which is already on Play. Bump the version: line in pubspec.yaml, or pass -PublishedBuildNumber if that number is stale." +} + +# ------------------------------------------------------------------- checks -- +if (-not $SkipClean) { + Write-Section 'Clean' + Invoke-Checked $flutter @('clean') 'flutter clean' +} + +Write-Section 'Dependencies' +Invoke-Checked $flutter @('pub', 'get') 'flutter pub get' + +if ($SkipChecks) { + Write-Section 'Checks' + Write-Warn 'skipped by -SkipChecks; do not upload this build' +} else { + Write-Section 'Analyze' + Invoke-Checked $flutter @('analyze') 'flutter analyze' + + Write-Section 'Test' + # `live` tests hit the real exchange-office sites; a third-party outage + # should not block a release. parsers.yml runs them daily instead. + Invoke-Checked $flutter @('test', '--exclude-tags', 'live') 'flutter test' + + Write-Section 'Guide content' + Invoke-Checked $dart @('run', 'tool/validate_guide.dart') 'validate_guide' +} + +$versionArgs = @('--release', "--build-name=$VersionName", "--build-number=$BuildNumber") +$aabPath = Join-Path $repoRoot 'build\app\outputs\bundle\release\app-release.aab' +$apkPath = Join-Path $repoRoot 'build\app\outputs\flutter-apk\app-release.apk' + +# -------------------------------------------------------------------- build -- +if ($buildBundle) { + Write-Section 'Build App Bundle' + Invoke-Checked $flutter (@('build', 'appbundle') + $versionArgs) 'flutter build appbundle' + if (-not (Test-Path $aabPath)) { throw "Expected $aabPath" } +} +if ($buildApk) { + Write-Section 'Build APK' + Invoke-Checked $flutter (@('build', 'apk') + $versionArgs) 'flutter build apk' + if (-not (Test-Path $apkPath)) { throw "Expected $apkPath" } +} + +# ------------------------------------------------------------------- verify -- +$certLines = @() +if ($buildApk) { + Write-Section 'Verify APK signature' + $sig = Invoke-Capture $apksigner @('verify', '--verbose', '--print-certs', $apkPath) 'apksigner verify' + $sig | ForEach-Object { Write-Host " $_" } + + # v2/v3 are the SHA-256 whole-file schemes; v1 alone is the old JAR + # signing Play no longer accepts on its own. + if (-not ($sig | Select-String -SimpleMatch 'Verified using v2 scheme (APK Signature Scheme v2): true')) { + throw 'APK is not signed with APK Signature Scheme v2.' + } + if (-not ($sig | Select-String -Pattern 'Signer #1 certificate SHA-256 digest')) { + throw 'apksigner reported no SHA-256 certificate digest.' + } + if ($sig | Select-String -Pattern 'CN=Android Debug') { + throw 'APK was signed with the Android debug key. Check android/key.properties.' + } + $certLines = @($sig | Select-String -Pattern 'Verified using|Signer #1 certificate (DN|SHA-256)' | + ForEach-Object { $_.Line.Trim() }) + Write-Ok 'v2 scheme, SHA-256 certificate, not the debug key' +} + +if ($buildBundle) { + Write-Section 'Verify App Bundle signature' + # An .aab is a JAR, so jarsigner is the right tool -- apksigner does not + # read bundles. `-strict` is deliberately absent: an app-signing certificate + # is self-signed by definition, which -strict counts as a severe warning and + # exits non-zero for, so with it the check could only ever fail. + $bundleSig = & $jarsigner '-verify' '-verbose:summary' $aabPath + $jarsignerExit = $LASTEXITCODE + $bundleSig | Select-Object -Last 10 | ForEach-Object { Write-Host " $_" } + if ($jarsignerExit -ne 0) { throw "jarsigner -verify failed with exit code $jarsignerExit" } + if (-not ($bundleSig | Select-String -SimpleMatch 'jar verified')) { + throw 'jarsigner did not report the bundle as verified.' + } + if ($bundleSig | Select-String -Pattern 'CN=Android Debug') { + throw 'The bundle was signed with the Android debug key.' + } + Write-Ok 'bundle verifies' +} + +Write-Section 'Manifest' +$manifest = Join-Path $repoRoot 'build\app\intermediates\packaged_manifests\release\processReleaseManifestForPackage\AndroidManifest.xml' +if (Test-Path $manifest) { + $manifestText = Get-Content -Raw -Path $manifest + foreach ($m in ([regex]'android:(minSdkVersion|targetSdkVersion|versionCode|versionName)="[^"]*"').Matches($manifestText)) { + Write-Host " $($m.Value)" + } + if ($manifestText -notmatch 'android:targetSdkVersion="36"') { + throw 'The merged manifest does not target SDK 36, which Play requires.' + } + Write-Ok 'targets Android 16 (API 36)' +} else { + Write-Warn "merged manifest not found at $manifest; skipped the targetSdk check" +} + +# --------------------------------------------------------------------- dist -- +Write-Section 'Artifacts' +$distDir = Join-Path $repoRoot 'dist' +if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir | Out-Null } +$stamp = "$VersionName+$BuildNumber" +$copied = @() +if ($buildBundle) { $copied += @{ From = $aabPath; To = Join-Path $distDir "srbguide-$stamp.aab" } } +if ($buildApk) { $copied += @{ From = $apkPath; To = Join-Path $distDir "srbguide-$stamp.apk" } } +foreach ($item in $copied) { + Copy-Item -Path $item.From -Destination $item.To -Force + $hash = (Get-FileHash -Path $item.To -Algorithm SHA256).Hash + "$hash $(Split-Path -Leaf $item.To)" | Set-Content -Path "$($item.To).sha256" -Encoding ascii + $size = '{0:N1} MB' -f ((Get-Item $item.To).Length / 1MB) + Write-Detail (Split-Path -Leaf $item.To) $size +} + +Write-Section 'Done' +Write-Detail 'elapsed' ('{0:mm\:ss}' -f ([TimeSpan]((Get-Date) - $started))) +Write-Detail 'output' $distDir +if ($certLines.Count -gt 0) { + Write-Host '' + $certLines | ForEach-Object { Write-Host " $_" } +} +if ($keyFingerprintValue) { + # The Play Console shows the registered upload key in this colon-separated + # form, so print it the same way rather than making you re-derive it. + Write-Host '' + Write-Host ' Upload key SHA-256 — must match "Upload key certificate" in the Play Console:' + Write-Host " $keyFingerprintValue" +} +Write-Host @" + + Upload dist\srbguide-$stamp.aab to Play Console -> Production -> Create new release. + The APK is for sideloading and manual QA; Play takes the bundle. + + What's new, ready to paste: + docs/play/whats-new-ru-RU.txt + docs/play/whats-new-en-US.txt + + After Play accepts the upload: + - raise -PublishedBuildNumber's default in tool/build_release.ps1 to $BuildNumber + - git tag v$VersionName; git push origin v$VersionName +"@ +if ($SkipChecks) { Write-Warn 'built with -SkipChecks: analyze/tests/guide validation did not run' } + +} catch { + # These are all "you got the setup wrong" errors; a PowerShell stack trace + # buries the sentence that says which one. + $failure = $_ +} finally { + Pop-Location +} + +if ($failure) { + Write-Host '' + Write-Host "BUILD FAILED" -ForegroundColor Red + Write-Host $failure.Exception.Message -ForegroundColor Red + Write-Host '' + exit 1 +} diff --git a/tool/create_upload_key.ps1 b/tool/create_upload_key.ps1 new file mode 100644 index 0000000..a29b88a --- /dev/null +++ b/tool/create_upload_key.ps1 @@ -0,0 +1,148 @@ +<# +.SYNOPSIS + Creates the Google Play upload key and wires it into android/key.properties. + +.DESCRIPTION + Run this ONCE. The keystore it produces is the only thing that lets you ship + an update to the existing Play listing -- back it up somewhere offline before + you upload anything. If it is lost, only Play support can reset the upload + key. + + Play requires RSA 2048 or larger and a certificate that stays valid past + 2033. `-sigalg SHA256withRSA` is what makes the certificate itself SHA-256; + tool/build_release.ps1 and the Release workflow both refuse to ship a key + that is not. + +.EXAMPLE + .\tool\create_upload_key.ps1 + +.EXAMPLE + .\tool\create_upload_key.ps1 -Keystore D:\keys\srbguide-upload.jks -Alias upload +#> +[CmdletBinding()] +param( + # Where the .jks goes. Keep it OUT of the repository. + [string]$Keystore = (Join-Path $env:USERPROFILE 'keys\srbguide-upload.jks'), + [string]$Alias = 'upload', + [string]$Dname = 'CN=Ilia Alakov, O=Serbia Guide, C=RS', + # ~27 years. Play rejects certificates that expire before 2033. + [int]$ValidityDays = 10000, + # Write the base64 of the keystore next to it, for the GitHub secret. + [switch]$PrintBase64 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Every failure here is "you got the setup wrong"; a PowerShell stack trace +# buries the sentence that says which. +trap { + Write-Host '' + Write-Host 'FAILED' -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host '' + exit 1 +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot 'release_common.ps1') + +Write-Section 'Toolchain' +$jdk = Resolve-JdkHome +$keytool = Join-Path $jdk 'bin\keytool.exe' +if (-not (Test-Path $keytool)) { throw "keytool not found at $keytool" } +Write-Detail 'keytool' $keytool + +Write-Section 'Keystore' +if (Test-Path $Keystore) { + # Overwriting is unrecoverable: the old certificate is the only one Play + # accepts for this listing. + throw "$Keystore already exists. Refusing to touch it -- pass -Keystore for a different path, or reuse this one via android/key.properties." +} +$keystoreDir = Split-Path -Parent $Keystore +if ($keystoreDir -and -not (Test-Path $keystoreDir)) { + New-Item -ItemType Directory -Path $keystoreDir | Out-Null +} +Write-Detail 'path' $Keystore +Write-Detail 'alias' $Alias +Write-Detail 'subject' $Dname + +$secure1 = Read-Host -Prompt 'Keystore password (min 6 characters)' -AsSecureString +$secure2 = Read-Host -Prompt 'Repeat the password' -AsSecureString +$pass1 = ConvertFrom-SecureStringPlain $secure1 +$pass2 = ConvertFrom-SecureStringPlain $secure2 +if ($pass1 -ne $pass2) { throw 'The two passwords do not match.' } +if ($pass1.Length -lt 6) { throw 'keytool requires at least 6 characters.' } + +# One password for both the store and the key: keytool -genkeypair with +# -storetype PKCS12 does not support a separate key password anyway. +Invoke-Checked $keytool @( + '-genkeypair', + '-alias', $Alias, + '-keyalg', 'RSA', + '-keysize', '4096', + '-sigalg', 'SHA256withRSA', + '-validity', "$ValidityDays", + '-keystore', $Keystore, + '-storetype', 'PKCS12', + '-dname', $Dname, + '-storepass', $pass1, + '-keypass', $pass1 +) 'keytool -genkeypair' + +Write-Section 'Certificate' +$info = & $keytool -list -v -keystore $Keystore -alias $Alias -storepass $pass1 +if ($LASTEXITCODE -ne 0) { throw 'keytool -list failed' } +$info | Select-String -Pattern 'Signature algorithm name:|Valid from:|^\s+SHA256:' | ForEach-Object { Write-Host " $($_.Line.Trim())" } +if (-not ($info | Select-String -Pattern 'Signature algorithm name:\s*SHA256with')) { + throw 'The generated certificate is not SHA-256. Play will reject it.' +} + +Write-Section 'android/key.properties' +$propsPath = Join-Path $repoRoot 'android\key.properties' +if (Test-Path $propsPath) { + throw "$propsPath already exists; not overwriting. Point it at the new keystore by hand if that is what you want." +} +# Gradle reads this file as a java.util.Properties, where `\` starts an escape +# and `:` can separate a key from its value. The replacement string is taken +# literally, so '\\' here means the two characters Properties decodes back into +# one backslash. +$escapedStore = $Keystore -replace '\\', '\\' -replace ':', '\:' +$propsText = @( + "storeFile=$escapedStore", + "storePassword=$pass1", + "keyAlias=$Alias", + "keyPassword=$pass1" +) -join "`n" +# Properties.load(InputStream) decodes ISO-8859-1, so a UTF-8 BOM would be read +# as three characters glued to the first key and `storeFile` would come back +# null. Write plain ASCII, escaping anything above it the way Properties expects. +$ascii = -join ($propsText.ToCharArray() | ForEach-Object { + if ([int]$_ -lt 128) { $_ } else { '\u{0:x4}' -f [int]$_ } +}) +[IO.File]::WriteAllText($propsPath, $ascii + "`n", (New-Object Text.UTF8Encoding $false)) +Write-Detail 'written' $propsPath +Write-Host ' (git-ignored; it holds the passwords in clear text)' + +if ($PrintBase64) { + Write-Section 'ANDROID_KEYSTORE_BASE64' + $b64Path = "$Keystore.base64.txt" + [Convert]::ToBase64String([IO.File]::ReadAllBytes($Keystore)) | Set-Content -Path $b64Path -Encoding ascii -NoNewline + Write-Detail 'written' $b64Path + Write-Host ' Paste its contents into the GitHub secret, then delete the file.' +} + +# Play asks for the key one of two ways depending on the screen: a SHA-256 +# fingerprint to paste, or a PEM certificate to upload. Produce both. +Write-Section 'For the Play Console' +& (Join-Path $PSScriptRoot 'show_upload_key.ps1') -Keystore $Keystore -Alias $Alias -StorePassword $pass1 -NoHeader + +Write-Section 'Next' +Write-Host @" + 1. Back up $Keystore and its password offline. Losing them means you can + never update the existing Play listing. + 2. Register the key with Play (fingerprint or PEM, printed above). + 3. Build a release: .\tool\build_release.ps1 + 4. For CI, add the four repository secrets listed in docs/RELEASE.md + (re-run this script with -PrintBase64 to get ANDROID_KEYSTORE_BASE64). +"@ diff --git a/tool/release_common.ps1 b/tool/release_common.ps1 new file mode 100644 index 0000000..754babc --- /dev/null +++ b/tool/release_common.ps1 @@ -0,0 +1,181 @@ +<# + Shared helpers for tool/build_release.ps1 and tool/create_upload_key.ps1. + Dot-source it; it defines functions only. + + Targets Windows PowerShell 5.1, so: no `&&`/`||`, no ternary, no `??`. +#> + +function Write-Section { + param([Parameter(Mandatory)][string]$Title) + Write-Host '' + Write-Host "== $Title" -ForegroundColor Cyan +} + +function Write-Detail { + param([string]$Label, [string]$Value) + Write-Host (" {0,-14} {1}" -f $Label, $Value) +} + +function Write-Ok { + param([string]$Message) + Write-Host " OK $Message" -ForegroundColor Green +} + +function Write-Warn { + param([string]$Message) + Write-Host " !! $Message" -ForegroundColor Yellow +} + +<# + Runs a native executable and throws on a non-zero exit code. + + stderr is deliberately left unredirected: `2>&1` on a native command in + PowerShell 5.1 wraps each stderr line in an ErrorRecord and reports failure + even when the exit code was 0. +#> +function Invoke-Checked { + param( + [Parameter(Mandatory)][string]$Exe, + [string[]]$Arguments = @(), + [string]$What + ) + if (-not $What) { $What = Split-Path -Leaf $Exe } + & $Exe @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$What failed with exit code $LASTEXITCODE" + } +} + +# Same, but returns stdout as a string array instead of streaming it. +function Invoke-Capture { + param( + [Parameter(Mandatory)][string]$Exe, + [string[]]$Arguments = @(), + [string]$What + ) + if (-not $What) { $What = Split-Path -Leaf $Exe } + $output = & $Exe @Arguments + if ($LASTEXITCODE -ne 0) { + $output | ForEach-Object { Write-Host $_ } + throw "$What failed with exit code $LASTEXITCODE" + } + return @($output) +} + +function ConvertFrom-SecureStringPlain { + param([Parameter(Mandatory)][System.Security.SecureString]$Secure) + $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) + try { + return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) + } finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) + } +} + +# Reads a java.util.Properties-style file into a hashtable. Enough for +# local.properties and key.properties: `key=value`, `#`/`!` comments, +# backslash escapes in the value. +function Read-JavaProperties { + param([Parameter(Mandatory)][string]$Path) + $map = @{} + foreach ($line in (Get-Content -Path $Path -Encoding utf8)) { + $trimmed = $line.Trim() + if (-not $trimmed) { continue } + if ($trimmed.StartsWith('#') -or $trimmed.StartsWith('!')) { continue } + $sep = $trimmed.IndexOf('=') + if ($sep -lt 1) { continue } + $key = $trimmed.Substring(0, $sep).Trim() + $value = $trimmed.Substring($sep + 1).Trim() + # Undo the escaping Gradle's Properties loader would apply. + $value = $value -replace '\\:', ':' -replace '\\\\', '\' + $map[$key] = $value + } + return $map +} + +<# + Finds the JDK that owns keytool/jarsigner, preferring the one Flutter is + pinned to. Android Studio's bundled JBR is deliberately not used -- see + SETUP.md. +#> +function Resolve-JdkHome { + $candidates = New-Object System.Collections.Generic.List[string] + + $settingsPath = Join-Path $env:USERPROFILE '.flutter_settings' + if (Test-Path $settingsPath) { + try { + $settings = Get-Content -Raw -Path $settingsPath | ConvertFrom-Json + if ($settings.PSObject.Properties.Name -contains 'jdk-dir') { + $candidates.Add($settings.'jdk-dir') + } + } catch { + # A malformed settings file is not worth failing the build over. + } + } + if ($env:JAVA_HOME) { $candidates.Add($env:JAVA_HOME) } + $candidates.Add('D:\dev\jdk21') + + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path (Join-Path $candidate 'bin\keytool.exe'))) { + return $candidate + } + } + $onPath = Get-Command keytool.exe -ErrorAction SilentlyContinue + if ($onPath) { return (Split-Path -Parent (Split-Path -Parent $onPath.Source)) } + + throw 'No JDK found. Set JAVA_HOME, or run `flutter config --jdk-dir ` (SETUP.md pins Temurin 21).' +} + +function Resolve-FlutterExe { + param([string]$RepoRoot) + + $localProps = Join-Path $RepoRoot 'android\local.properties' + if (Test-Path $localProps) { + $props = Read-JavaProperties $localProps + if ($props.ContainsKey('flutter.sdk')) { + $fromProps = Join-Path ($props['flutter.sdk'] -replace '/', '\') 'bin\flutter.bat' + if (Test-Path $fromProps) { return $fromProps } + } + } + $onPath = Get-Command flutter.bat -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + if (Test-Path 'D:\dev\flutter\bin\flutter.bat') { return 'D:\dev\flutter\bin\flutter.bat' } + + throw 'flutter.bat not found. Put the Flutter SDK on PATH or set flutter.sdk in android/local.properties.' +} + +function Resolve-AndroidSdk { + param([string]$RepoRoot) + + $candidates = New-Object System.Collections.Generic.List[string] + $localProps = Join-Path $RepoRoot 'android\local.properties' + if (Test-Path $localProps) { + $props = Read-JavaProperties $localProps + if ($props.ContainsKey('sdk.dir')) { $candidates.Add(($props['sdk.dir'] -replace '/', '\')) } + } + if ($env:ANDROID_SDK_ROOT) { $candidates.Add($env:ANDROID_SDK_ROOT) } + if ($env:ANDROID_HOME) { $candidates.Add($env:ANDROID_HOME) } + $candidates.Add((Join-Path $env:LOCALAPPDATA 'Android\Sdk')) + + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path (Join-Path $candidate 'build-tools'))) { return $candidate } + } + throw 'Android SDK not found. Set ANDROID_SDK_ROOT or sdk.dir in android/local.properties.' +} + +# Newest build-tools directory, by version rather than by name -- 36.0.0 has to +# sort above 9.0.0. +function Resolve-ApkSigner { + param([Parameter(Mandatory)][string]$AndroidSdk) + + $buildTools = @(Get-ChildItem -Path (Join-Path $AndroidSdk 'build-tools') -Directory | + Sort-Object -Property @{ Expression = { + $parsed = $null + if ([Version]::TryParse($_.Name, [ref]$parsed)) { $parsed } else { [Version]'0.0.0' } + } }) + for ($i = $buildTools.Count - 1; $i -ge 0; $i--) { + $candidate = Join-Path $buildTools[$i].FullName 'apksigner.bat' + if (Test-Path $candidate) { return $candidate } + } + throw "No apksigner.bat under $AndroidSdk\build-tools. Install build-tools with sdkmanager." +} diff --git a/tool/show_upload_key.ps1 b/tool/show_upload_key.ps1 new file mode 100644 index 0000000..7a0ab14 --- /dev/null +++ b/tool/show_upload_key.ps1 @@ -0,0 +1,113 @@ +<# +.SYNOPSIS + Prints what the Play Console asks for when you register an upload key: the + SHA-256 certificate fingerprint, and a PEM export of the certificate. + +.DESCRIPTION + Play has two ways of taking an upload key, and which one you get depends on + the screen you are on: + + "Добавьте открытый ключ ... цифровой отпечаток сертификата SHA-256" + -> paste the SHA-256 line this prints. + + Upload key reset / "Upload a certificate" + -> upload the .pem file this writes. + + Neither is a secret: a fingerprint is a hash of the certificate and the PEM + holds only the public half. The keystore and its password are the secret, + and this script never copies either. + + Reads android/key.properties by default, so after create_upload_key.ps1 it + needs no arguments. + +.EXAMPLE + .\tool\show_upload_key.ps1 + +.EXAMPLE + .\tool\show_upload_key.ps1 -Keystore D:\keys\upload.jks -Alias upload +#> +[CmdletBinding()] +param( + # Defaults to storeFile from android/key.properties. + [string]$Keystore, + # Defaults to keyAlias from android/key.properties. + [string]$Alias, + # Defaults to storePassword from android/key.properties; prompts if neither. + [string]$StorePassword, + # Where to write the PEM. Defaults to next to the keystore. + [string]$PemPath, + # Suppress the banner when called from create_upload_key.ps1. + [switch]$NoHeader +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +trap { + Write-Host '' + Write-Host 'FAILED' -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host '' + exit 1 +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +. (Join-Path $PSScriptRoot 'release_common.ps1') + +if (-not $Keystore -or -not $Alias -or -not $StorePassword) { + $propsPath = Join-Path $repoRoot 'android\key.properties' + if (-not (Test-Path $propsPath)) { + throw "android/key.properties not found, and -Keystore / -Alias / -StorePassword were not all given. Create a key first: .\tool\create_upload_key.ps1" + } + $props = Read-JavaProperties $propsPath + if (-not $Keystore) { $Keystore = $props['storeFile'] } + if (-not $Alias) { $Alias = $props['keyAlias'] } + if (-not $StorePassword) { $StorePassword = $props['storePassword'] } +} +if (-not (Test-Path $Keystore)) { throw "Keystore not found: $Keystore" } + +$jdk = Resolve-JdkHome +$keytool = Join-Path $jdk 'bin\keytool.exe' +if (-not (Test-Path $keytool)) { throw "keytool not found at $keytool" } + +if (-not $NoHeader) { + Write-Section 'Upload key' + Write-Detail 'keystore' $Keystore + Write-Detail 'alias' $Alias +} + +$info = Invoke-Capture $keytool @( + '-list', '-v', + '-keystore', $Keystore, + '-alias', $Alias, + '-storepass', $StorePassword +) 'keytool -list' + +foreach ($pattern in @('^Owner:', 'Signature algorithm name:', 'Valid from:')) { + $line = $info | Select-String -Pattern $pattern | Select-Object -First 1 + if ($line) { Write-Host " $($line.Line.Trim())" } +} + +$sha256 = $info | Select-String -Pattern '^\s+SHA256:\s*(\S+)' | Select-Object -First 1 +if (-not $sha256) { throw 'keytool did not report a SHA-256 fingerprint.' } +$fingerprint = $sha256.Matches[0].Groups[1].Value + +Write-Host '' +Write-Host ' SHA-256 fingerprint (paste this into the Play Console):' +Write-Host '' +Write-Host " $fingerprint" -ForegroundColor Cyan +Write-Host '' + +if (-not $PemPath) { $PemPath = [IO.Path]::ChangeExtension($Keystore, 'pem') } +# -rfc is the base64 PEM form; without it keytool writes DER, which the upload +# form rejects. +Invoke-Checked $keytool @( + '-exportcert', '-rfc', + '-keystore', $Keystore, + '-alias', $Alias, + '-storepass', $StorePassword, + '-file', $PemPath +) 'keytool -exportcert' +Write-Detail 'certificate' $PemPath +Write-Host ' Upload that file on screens that ask for a certificate rather than' +Write-Host ' a fingerprint. It is the public half only, safe to send to Google.' diff --git a/tool/sync_smoking.dart b/tool/sync_smoking.dart new file mode 100644 index 0000000..1cd2e1b --- /dev/null +++ b/tool/sync_smoking.dart @@ -0,0 +1,190 @@ +// Regenerates `assets/data/smoking.json` from the "Lokali bez dima" map. +// +// dart run tool/sync_smoking.dart +// dart run tool/sync_smoking.dart --out assets/data/smoking.json +// +// The non-smoking venues used to be a Google My Maps link opened in a WebView. +// My Maps exports KML for free, so the points are pulled out here and bundled +// like the rest of the catalogue: the places screen then shows them as pins +// next to the relocant businesses, offline and without loading Google. +// +// Two layers are published on that map, and they mean different things: +// Nepušački lokal — smoking is banned outright -> smoking: none +// Bezdimna alternativa — only smokeless devices allowed -> smoking: alternative + +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:xml/xml.dart'; + +const String kMapId = '1DhbU4mNbi0OVkoRSpKBqBmWqeRXU5vo'; +const String kSource = 'https://www.google.com/maps/d/viewer?mid=$kMapId'; +const String kUpstream = 'https://lokalibezdima.rs'; +const String kDefaultOut = 'assets/data/smoking.json'; + +/// Layer name on the map -> the value stored on each place. +const Map kLayers = { + 'Nepušački lokal': 'none', + 'Bezdimna alternativa': 'alternative', +}; + +/// City centres used to label a point, with the radius that still counts as +/// that city. Only for the city filter — a point outside them all keeps `''`. +const List<({String slug, double lat, double lng, double km})> kCities = + <({String slug, double lat, double lng, double km})>[ + (slug: 'beograd', lat: 44.8125, lng: 20.4612, km: 25), + (slug: 'novi-sad', lat: 45.2551, lng: 19.8452, km: 20), + (slug: 'nis', lat: 43.3209, lng: 21.8958, km: 15), + (slug: 'subotica', lat: 46.1000, lng: 19.6650, km: 15), + (slug: 'kragujevac', lat: 44.0128, lng: 20.9114, km: 15), + (slug: 'zrenjanin', lat: 45.3836, lng: 20.3819, km: 15), + (slug: 'pancevo', lat: 44.8708, lng: 20.6403, km: 12), + (slug: 'cacak', lat: 43.8914, lng: 20.3497, km: 15), +]; + +Future main(List args) async { + final String out = _argValue(args, '--out') ?? kDefaultOut; + + stdout.writeln('Fetching the KML export of $kSource …'); + final String kml = await _get( + 'https://www.google.com/maps/d/kml?mid=$kMapId&forcekml=1', + ); + + final List> places = _extractPlaces(kml); + if (places.isEmpty) { + stderr.writeln('FAIL: no placemarks found — the map or its layers changed'); + exit(1); + } + + final Map payload = { + 'source': kSource, + 'upstream': kUpstream, + 'syncedAt': DateTime.now().toUtc().toIso8601String(), + 'places': places, + }; + + final File file = File(out); + await file.parent.create(recursive: true); + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(payload), + encoding: utf8, + ); + + stdout.writeln('Wrote $out — ${places.length} venues'); + for (final MapEntry layer in kLayers.entries) { + final int n = places.where((Map p) { + return p['smoking'] == layer.value; + }).length; + stdout.writeln(' ${n.toString().padLeft(4)} ${layer.key}'); + } +} + +/// Reads the placemarks of every known layer out of the KML document. +List> _extractPlaces(String kml) { + final XmlDocument doc = XmlDocument.parse(kml); + final List> places = >[]; + final Set seen = {}; + + for (final XmlElement folder in doc.findAllElements('Folder')) { + final String layer = _text(folder, 'name'); + final String? smoking = kLayers[layer]; + if (smoking == null) { + stderr.writeln('Skipping unknown layer "$layer"'); + continue; + } + + for (final XmlElement placemark in folder.findElements('Placemark')) { + final String name = _text(placemark, 'name'); + final ({double lat, double lng})? point = _point(placemark); + if (name.isEmpty || point == null) continue; + + // A venue listed on both layers keeps the stricter one, which is the + // order kLayers is walked in. + final String id = 'smoke-${_hash('$name|${point.lat}|${point.lng}')}'; + if (!seen.add(id)) continue; + + places.add({ + 'id': id, + 'name': name, + 'description': _text(placemark, 'description'), + 'lat': point.lat, + 'lng': point.lng, + // The map says nothing about what kind of venue this is, and guessing + // "food" would put cafés under a filter they may not belong in. + 'category': '', + 'city': _city(point.lat, point.lng), + 'opstina': '', + 'mapUrl': 'https://www.google.com/maps/search/?api=1' + '&query=${point.lat},${point.lng}', + 'smoking': smoking, + }); + } + } + + places.sort((Map a, Map b) => + (a['name']! as String).compareTo(b['name']! as String)); + return places; +} + +/// `lng,lat,alt` of a Point placemark. +({double lat, double lng})? _point(XmlElement placemark) { + final Iterable points = placemark.findAllElements('Point'); + if (points.isEmpty) return null; + final List parts = + _text(points.first, 'coordinates').split(',').map((String s) { + return s.trim(); + }).toList(); + if (parts.length < 2) return null; + + final double? lng = double.tryParse(parts[0]); + final double? lat = double.tryParse(parts[1]); + if (lat == null || lng == null) return null; + // Serbia plus a margin; anything else is a stray pin, not a venue. + if (lat < 41 || lat > 47 || lng < 18 || lng > 23.5) return null; + return (lat: lat, lng: lng); +} + +String _text(XmlElement parent, String tag) { + final Iterable found = parent.findElements(tag); + return found.isEmpty ? '' : found.first.innerText.trim(); +} + +String _city(double lat, double lng) { + for (final ({String slug, double lat, double lng, double km}) c in kCities) { + final double dLat = (lat - c.lat) * 111.32; + final double dLng = (lng - c.lng) * 78.6; + if (dLat * dLat + dLng * dLng <= c.km * c.km) return c.slug; + } + return ''; +} + +/// FNV-1a, so a venue keeps its id across syncs as long as it does not move. +String _hash(String value) { + int hash = 0x811c9dc5; + for (final int unit in utf8.encode(value)) { + hash = ((hash ^ unit) * 0x01000193) & 0xffffffff; + } + return hash.toRadixString(16).padLeft(8, '0'); +} + +Future _get(String url) async { + final http.Response response = await http.get( + Uri.parse(url), + headers: const { + 'User-Agent': + 'srbguide-app-sync/1.0 (+https://github.com/ialakey/srbguide)', + 'Accept-Language': 'sr,en;q=0.8', + }, + ).timeout(const Duration(seconds: 60)); + if (response.statusCode != 200) { + throw StateError('HTTP ${response.statusCode} for $url'); + } + return utf8.decode(response.bodyBytes); +} + +String? _argValue(List args, String flag) { + final int i = args.indexOf(flag); + if (i == -1 || i + 1 >= args.length) return null; + return args[i + 1]; +}