diff --git a/.gitignore b/.gitignore index c780cce..017cce4 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ android/.gradle/ android/build/ android/app/build/ android/local.properties +android/map-style/node_modules/ # Pinned tool binaries (downloaded by `make css`; see Makefile) .tools/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1c2edc5..a5407c5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -39,6 +39,8 @@ android { sourceSets["test"].kotlin.srcDir("src/test/kotlin") sourceSets["androidTest"].kotlin.srcDir("src/androidTest/kotlin") testOptions.unitTests.isReturnDefaultValues = true + // MapAssetsTest reads the committed map assets from disk, so a change to them has to rerun it. + testOptions.unitTests.all { it.inputs.dir("src/main/assets/map") } } dependencies { diff --git a/android/app/src/main/assets/map/LICENSE.md b/android/app/src/main/assets/map/LICENSE.md new file mode 100644 index 0000000..8441b08 --- /dev/null +++ b/android/app/src/main/assets/map/LICENSE.md @@ -0,0 +1,54 @@ +# Code Licenses and Attribution + +# The BSD 3-Clause License (BSD-3-Clause) + +Copyright 2019-2023 Protomaps LLC, Kelso Cartography + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# Visual Design + +The visual design of the Protomaps basemap styles was created by Geraldine Sarmiento for Protomaps LLC and released under a Creative Commons 0 (CC0) license. + +https://creativecommons.org/publicdomain/zero/1.0/ + +Certain map styles may also include derivatives of icons designed for Mapzen and released under an MIT license. + +https://github.com/tangrams/icons/blob/master/LICENSE.md + +# Tile Schema + +The organization of layers and tags is adapted from the Tilezen project. + +https://github.com/tilezen/vector-datasource +https://github.com/tilezen/vector-datasource/blob/master/docs/LICENSE-SCHEMA.md + +# The MIT License (MIT) + +Copyright (c) 2015-2018 Mapzen +Copyright (c) 2019 Linux Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/android/app/src/main/assets/map/fonts/Noto Sans Regular/0-255.pbf b/android/app/src/main/assets/map/fonts/Noto Sans Regular/0-255.pbf new file mode 100644 index 0000000..7f65901 Binary files /dev/null and b/android/app/src/main/assets/map/fonts/Noto Sans Regular/0-255.pbf differ diff --git a/android/app/src/main/assets/map/fonts/Noto Sans Regular/256-511.pbf b/android/app/src/main/assets/map/fonts/Noto Sans Regular/256-511.pbf new file mode 100644 index 0000000..f030288 Binary files /dev/null and b/android/app/src/main/assets/map/fonts/Noto Sans Regular/256-511.pbf differ diff --git a/android/app/src/main/assets/map/fonts/Noto Sans Regular/512-767.pbf b/android/app/src/main/assets/map/fonts/Noto Sans Regular/512-767.pbf new file mode 100644 index 0000000..5cde89d Binary files /dev/null and b/android/app/src/main/assets/map/fonts/Noto Sans Regular/512-767.pbf differ diff --git a/android/app/src/main/assets/map/fonts/Noto Sans Regular/768-1023.pbf b/android/app/src/main/assets/map/fonts/Noto Sans Regular/768-1023.pbf new file mode 100644 index 0000000..a1d0bd9 Binary files /dev/null and b/android/app/src/main/assets/map/fonts/Noto Sans Regular/768-1023.pbf differ diff --git a/android/app/src/main/assets/map/fonts/Noto Sans Regular/7680-7935.pbf b/android/app/src/main/assets/map/fonts/Noto Sans Regular/7680-7935.pbf new file mode 100644 index 0000000..12a4515 Binary files /dev/null and b/android/app/src/main/assets/map/fonts/Noto Sans Regular/7680-7935.pbf differ diff --git a/android/app/src/main/assets/map/fonts/Noto Sans Regular/8192-8447.pbf b/android/app/src/main/assets/map/fonts/Noto Sans Regular/8192-8447.pbf new file mode 100644 index 0000000..9d7bcf0 Binary files /dev/null and b/android/app/src/main/assets/map/fonts/Noto Sans Regular/8192-8447.pbf differ diff --git a/android/app/src/main/assets/map/fonts/Noto Sans Regular/8448-8703.pbf b/android/app/src/main/assets/map/fonts/Noto Sans Regular/8448-8703.pbf new file mode 100644 index 0000000..c5f0c53 Binary files /dev/null and b/android/app/src/main/assets/map/fonts/Noto Sans Regular/8448-8703.pbf differ diff --git a/android/app/src/main/assets/map/fonts/OFL.txt b/android/app/src/main/assets/map/fonts/OFL.txt new file mode 100644 index 0000000..f29e5cd --- /dev/null +++ b/android/app/src/main/assets/map/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/android/app/src/main/assets/map/sprites/dark.json b/android/app/src/main/assets/map/sprites/dark.json new file mode 100644 index 0000000..7b4cdee --- /dev/null +++ b/android/app/src/main/assets/map/sprites/dark.json @@ -0,0 +1 @@ +{"NL:S-road-1char":{"height":21,"pixelRatio":1,"width":21,"x":0,"y":36},"NL:S-road-2char":{"height":21,"pixelRatio":1,"width":22,"x":38,"y":21},"NL:S-road-3char":{"height":21,"pixelRatio":1,"width":25,"x":202,"y":18},"NL:S-road-4char":{"height":21,"pixelRatio":1,"width":29,"x":173,"y":0},"NL:S-road-5char":{"height":21,"pixelRatio":1,"width":32,"x":79,"y":0},"US:I-1char":{"height":21,"pixelRatio":1,"width":20,"x":84,"y":21},"US:I-2char":{"height":21,"pixelRatio":1,"width":23,"x":227,"y":18},"US:I-3char":{"height":21,"pixelRatio":1,"width":30,"x":143,"y":0},"US:I-4char":{"height":21,"pixelRatio":1,"width":32,"x":111,"y":0},"US:I-5char":{"height":21,"pixelRatio":1,"width":34,"x":45,"y":0},"aerodrome":{"height":19,"pixelRatio":1,"width":19,"x":60,"y":39},"animal":{"height":19,"pixelRatio":1,"width":19,"x":21,"y":42},"arrow":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":57},"artwork":{"height":19,"pixelRatio":1,"width":19,"x":40,"y":42},"attraction":{"height":19,"pixelRatio":1,"width":19,"x":104,"y":21},"bar":{"height":19,"pixelRatio":1,"width":19,"x":123,"y":21},"beach":{"height":19,"pixelRatio":1,"width":19,"x":142,"y":21},"beauty":{"height":19,"pixelRatio":1,"width":19,"x":161,"y":21},"bench":{"height":19,"pixelRatio":1,"width":19,"x":180,"y":21},"books":{"height":19,"pixelRatio":1,"width":19,"x":199,"y":39},"building":{"height":17,"pixelRatio":1,"width":17,"x":193,"y":77},"bus_stop":{"height":19,"pixelRatio":1,"width":19,"x":218,"y":39},"cafe":{"height":19,"pixelRatio":1,"width":19,"x":237,"y":39},"capital":{"height":10,"pixelRatio":1,"width":10,"x":210,"y":77},"clothes":{"height":19,"pixelRatio":1,"width":19,"x":104,"y":40},"convenience":{"height":19,"pixelRatio":1,"width":19,"x":79,"y":42},"drinking_water":{"height":19,"pixelRatio":1,"width":19,"x":59,"y":58},"electronics":{"height":19,"pixelRatio":1,"width":19,"x":19,"y":61},"fast_food":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":76},"ferry_terminal":{"height":19,"pixelRatio":1,"width":19,"x":38,"y":61},"forest":{"height":19,"pixelRatio":1,"width":19,"x":123,"y":40},"garden":{"height":19,"pixelRatio":1,"width":19,"x":142,"y":40},"generic_shield-1char":{"height":18,"pixelRatio":1,"width":19,"x":235,"y":0},"generic_shield-2char":{"height":18,"pixelRatio":1,"width":24,"x":60,"y":21},"generic_shield-3char":{"height":18,"pixelRatio":1,"width":33,"x":202,"y":0},"generic_shield-4char":{"height":18,"pixelRatio":1,"width":38,"x":0,"y":18},"generic_shield-5char":{"height":18,"pixelRatio":1,"width":45,"x":0,"y":0},"library":{"height":19,"pixelRatio":1,"width":19,"x":161,"y":40},"marina":{"height":19,"pixelRatio":1,"width":19,"x":180,"y":40},"museum":{"height":19,"pixelRatio":1,"width":19,"x":199,"y":58},"park":{"height":19,"pixelRatio":1,"width":19,"x":218,"y":58},"peak":{"height":19,"pixelRatio":1,"width":19,"x":237,"y":58},"post_office":{"height":19,"pixelRatio":1,"width":19,"x":98,"y":59},"restaurant":{"height":19,"pixelRatio":1,"width":19,"x":78,"y":61},"school":{"height":19,"pixelRatio":1,"width":19,"x":57,"y":77},"stadium":{"height":19,"pixelRatio":1,"width":19,"x":19,"y":80},"supermarket":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":95},"theatre":{"height":19,"pixelRatio":1,"width":19,"x":38,"y":80},"toilets":{"height":19,"pixelRatio":1,"width":19,"x":117,"y":59},"townspot":{"height":10,"pixelRatio":1,"width":10,"x":220,"y":77},"train_station":{"height":19,"pixelRatio":1,"width":19,"x":136,"y":59},"university":{"height":19,"pixelRatio":1,"width":19,"x":155,"y":59},"zoo":{"height":19,"pixelRatio":1,"width":19,"x":174,"y":59}} diff --git a/android/app/src/main/assets/map/sprites/dark.png b/android/app/src/main/assets/map/sprites/dark.png new file mode 100644 index 0000000..62f1e23 Binary files /dev/null and b/android/app/src/main/assets/map/sprites/dark.png differ diff --git a/android/app/src/main/assets/map/sprites/dark@2x.json b/android/app/src/main/assets/map/sprites/dark@2x.json new file mode 100644 index 0000000..01ad28a --- /dev/null +++ b/android/app/src/main/assets/map/sprites/dark@2x.json @@ -0,0 +1 @@ +{"NL:S-road-1char":{"height":42,"pixelRatio":2,"width":42,"x":0,"y":72},"NL:S-road-2char":{"height":42,"pixelRatio":2,"width":44,"x":76,"y":42},"NL:S-road-3char":{"height":42,"pixelRatio":2,"width":50,"x":404,"y":36},"NL:S-road-4char":{"height":42,"pixelRatio":2,"width":58,"x":346,"y":0},"NL:S-road-5char":{"height":42,"pixelRatio":2,"width":64,"x":158,"y":0},"US:I-1char":{"height":42,"pixelRatio":2,"width":40,"x":168,"y":42},"US:I-2char":{"height":42,"pixelRatio":2,"width":46,"x":454,"y":36},"US:I-3char":{"height":42,"pixelRatio":2,"width":60,"x":286,"y":0},"US:I-4char":{"height":42,"pixelRatio":2,"width":64,"x":222,"y":0},"US:I-5char":{"height":42,"pixelRatio":2,"width":68,"x":90,"y":0},"aerodrome":{"height":38,"pixelRatio":2,"width":38,"x":120,"y":78},"animal":{"height":38,"pixelRatio":2,"width":38,"x":42,"y":84},"arrow":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":114},"artwork":{"height":38,"pixelRatio":2,"width":38,"x":80,"y":84},"attraction":{"height":38,"pixelRatio":2,"width":38,"x":208,"y":42},"bar":{"height":38,"pixelRatio":2,"width":38,"x":246,"y":42},"beach":{"height":38,"pixelRatio":2,"width":38,"x":284,"y":42},"beauty":{"height":38,"pixelRatio":2,"width":38,"x":322,"y":42},"bench":{"height":38,"pixelRatio":2,"width":38,"x":360,"y":42},"books":{"height":38,"pixelRatio":2,"width":38,"x":398,"y":78},"building":{"height":34,"pixelRatio":2,"width":34,"x":386,"y":154},"bus_stop":{"height":38,"pixelRatio":2,"width":38,"x":436,"y":78},"cafe":{"height":38,"pixelRatio":2,"width":38,"x":474,"y":78},"capital":{"height":20,"pixelRatio":2,"width":20,"x":420,"y":154},"clothes":{"height":38,"pixelRatio":2,"width":38,"x":208,"y":80},"convenience":{"height":38,"pixelRatio":2,"width":38,"x":158,"y":84},"drinking_water":{"height":38,"pixelRatio":2,"width":38,"x":118,"y":116},"electronics":{"height":38,"pixelRatio":2,"width":38,"x":38,"y":122},"fast_food":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":152},"ferry_terminal":{"height":38,"pixelRatio":2,"width":38,"x":76,"y":122},"forest":{"height":38,"pixelRatio":2,"width":38,"x":246,"y":80},"garden":{"height":38,"pixelRatio":2,"width":38,"x":284,"y":80},"generic_shield-1char":{"height":36,"pixelRatio":2,"width":38,"x":470,"y":0},"generic_shield-2char":{"height":36,"pixelRatio":2,"width":48,"x":120,"y":42},"generic_shield-3char":{"height":36,"pixelRatio":2,"width":66,"x":404,"y":0},"generic_shield-4char":{"height":36,"pixelRatio":2,"width":76,"x":0,"y":36},"generic_shield-5char":{"height":36,"pixelRatio":2,"width":90,"x":0,"y":0},"library":{"height":38,"pixelRatio":2,"width":38,"x":322,"y":80},"marina":{"height":38,"pixelRatio":2,"width":38,"x":360,"y":80},"museum":{"height":38,"pixelRatio":2,"width":38,"x":398,"y":116},"park":{"height":38,"pixelRatio":2,"width":38,"x":436,"y":116},"peak":{"height":38,"pixelRatio":2,"width":38,"x":474,"y":116},"post_office":{"height":38,"pixelRatio":2,"width":38,"x":196,"y":118},"restaurant":{"height":38,"pixelRatio":2,"width":38,"x":156,"y":122},"school":{"height":38,"pixelRatio":2,"width":38,"x":114,"y":154},"stadium":{"height":38,"pixelRatio":2,"width":38,"x":38,"y":160},"supermarket":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":190},"theatre":{"height":38,"pixelRatio":2,"width":38,"x":76,"y":160},"toilets":{"height":38,"pixelRatio":2,"width":38,"x":234,"y":118},"townspot":{"height":20,"pixelRatio":2,"width":20,"x":440,"y":154},"train_station":{"height":38,"pixelRatio":2,"width":38,"x":272,"y":118},"university":{"height":38,"pixelRatio":2,"width":38,"x":310,"y":118},"zoo":{"height":38,"pixelRatio":2,"width":38,"x":348,"y":118}} diff --git a/android/app/src/main/assets/map/sprites/dark@2x.png b/android/app/src/main/assets/map/sprites/dark@2x.png new file mode 100644 index 0000000..e5212ed Binary files /dev/null and b/android/app/src/main/assets/map/sprites/dark@2x.png differ diff --git a/android/app/src/main/assets/map/sprites/light.json b/android/app/src/main/assets/map/sprites/light.json new file mode 100644 index 0000000..7b4cdee --- /dev/null +++ b/android/app/src/main/assets/map/sprites/light.json @@ -0,0 +1 @@ +{"NL:S-road-1char":{"height":21,"pixelRatio":1,"width":21,"x":0,"y":36},"NL:S-road-2char":{"height":21,"pixelRatio":1,"width":22,"x":38,"y":21},"NL:S-road-3char":{"height":21,"pixelRatio":1,"width":25,"x":202,"y":18},"NL:S-road-4char":{"height":21,"pixelRatio":1,"width":29,"x":173,"y":0},"NL:S-road-5char":{"height":21,"pixelRatio":1,"width":32,"x":79,"y":0},"US:I-1char":{"height":21,"pixelRatio":1,"width":20,"x":84,"y":21},"US:I-2char":{"height":21,"pixelRatio":1,"width":23,"x":227,"y":18},"US:I-3char":{"height":21,"pixelRatio":1,"width":30,"x":143,"y":0},"US:I-4char":{"height":21,"pixelRatio":1,"width":32,"x":111,"y":0},"US:I-5char":{"height":21,"pixelRatio":1,"width":34,"x":45,"y":0},"aerodrome":{"height":19,"pixelRatio":1,"width":19,"x":60,"y":39},"animal":{"height":19,"pixelRatio":1,"width":19,"x":21,"y":42},"arrow":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":57},"artwork":{"height":19,"pixelRatio":1,"width":19,"x":40,"y":42},"attraction":{"height":19,"pixelRatio":1,"width":19,"x":104,"y":21},"bar":{"height":19,"pixelRatio":1,"width":19,"x":123,"y":21},"beach":{"height":19,"pixelRatio":1,"width":19,"x":142,"y":21},"beauty":{"height":19,"pixelRatio":1,"width":19,"x":161,"y":21},"bench":{"height":19,"pixelRatio":1,"width":19,"x":180,"y":21},"books":{"height":19,"pixelRatio":1,"width":19,"x":199,"y":39},"building":{"height":17,"pixelRatio":1,"width":17,"x":193,"y":77},"bus_stop":{"height":19,"pixelRatio":1,"width":19,"x":218,"y":39},"cafe":{"height":19,"pixelRatio":1,"width":19,"x":237,"y":39},"capital":{"height":10,"pixelRatio":1,"width":10,"x":210,"y":77},"clothes":{"height":19,"pixelRatio":1,"width":19,"x":104,"y":40},"convenience":{"height":19,"pixelRatio":1,"width":19,"x":79,"y":42},"drinking_water":{"height":19,"pixelRatio":1,"width":19,"x":59,"y":58},"electronics":{"height":19,"pixelRatio":1,"width":19,"x":19,"y":61},"fast_food":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":76},"ferry_terminal":{"height":19,"pixelRatio":1,"width":19,"x":38,"y":61},"forest":{"height":19,"pixelRatio":1,"width":19,"x":123,"y":40},"garden":{"height":19,"pixelRatio":1,"width":19,"x":142,"y":40},"generic_shield-1char":{"height":18,"pixelRatio":1,"width":19,"x":235,"y":0},"generic_shield-2char":{"height":18,"pixelRatio":1,"width":24,"x":60,"y":21},"generic_shield-3char":{"height":18,"pixelRatio":1,"width":33,"x":202,"y":0},"generic_shield-4char":{"height":18,"pixelRatio":1,"width":38,"x":0,"y":18},"generic_shield-5char":{"height":18,"pixelRatio":1,"width":45,"x":0,"y":0},"library":{"height":19,"pixelRatio":1,"width":19,"x":161,"y":40},"marina":{"height":19,"pixelRatio":1,"width":19,"x":180,"y":40},"museum":{"height":19,"pixelRatio":1,"width":19,"x":199,"y":58},"park":{"height":19,"pixelRatio":1,"width":19,"x":218,"y":58},"peak":{"height":19,"pixelRatio":1,"width":19,"x":237,"y":58},"post_office":{"height":19,"pixelRatio":1,"width":19,"x":98,"y":59},"restaurant":{"height":19,"pixelRatio":1,"width":19,"x":78,"y":61},"school":{"height":19,"pixelRatio":1,"width":19,"x":57,"y":77},"stadium":{"height":19,"pixelRatio":1,"width":19,"x":19,"y":80},"supermarket":{"height":19,"pixelRatio":1,"width":19,"x":0,"y":95},"theatre":{"height":19,"pixelRatio":1,"width":19,"x":38,"y":80},"toilets":{"height":19,"pixelRatio":1,"width":19,"x":117,"y":59},"townspot":{"height":10,"pixelRatio":1,"width":10,"x":220,"y":77},"train_station":{"height":19,"pixelRatio":1,"width":19,"x":136,"y":59},"university":{"height":19,"pixelRatio":1,"width":19,"x":155,"y":59},"zoo":{"height":19,"pixelRatio":1,"width":19,"x":174,"y":59}} diff --git a/android/app/src/main/assets/map/sprites/light.png b/android/app/src/main/assets/map/sprites/light.png new file mode 100644 index 0000000..b2b90d9 Binary files /dev/null and b/android/app/src/main/assets/map/sprites/light.png differ diff --git a/android/app/src/main/assets/map/sprites/light@2x.json b/android/app/src/main/assets/map/sprites/light@2x.json new file mode 100644 index 0000000..01ad28a --- /dev/null +++ b/android/app/src/main/assets/map/sprites/light@2x.json @@ -0,0 +1 @@ +{"NL:S-road-1char":{"height":42,"pixelRatio":2,"width":42,"x":0,"y":72},"NL:S-road-2char":{"height":42,"pixelRatio":2,"width":44,"x":76,"y":42},"NL:S-road-3char":{"height":42,"pixelRatio":2,"width":50,"x":404,"y":36},"NL:S-road-4char":{"height":42,"pixelRatio":2,"width":58,"x":346,"y":0},"NL:S-road-5char":{"height":42,"pixelRatio":2,"width":64,"x":158,"y":0},"US:I-1char":{"height":42,"pixelRatio":2,"width":40,"x":168,"y":42},"US:I-2char":{"height":42,"pixelRatio":2,"width":46,"x":454,"y":36},"US:I-3char":{"height":42,"pixelRatio":2,"width":60,"x":286,"y":0},"US:I-4char":{"height":42,"pixelRatio":2,"width":64,"x":222,"y":0},"US:I-5char":{"height":42,"pixelRatio":2,"width":68,"x":90,"y":0},"aerodrome":{"height":38,"pixelRatio":2,"width":38,"x":120,"y":78},"animal":{"height":38,"pixelRatio":2,"width":38,"x":42,"y":84},"arrow":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":114},"artwork":{"height":38,"pixelRatio":2,"width":38,"x":80,"y":84},"attraction":{"height":38,"pixelRatio":2,"width":38,"x":208,"y":42},"bar":{"height":38,"pixelRatio":2,"width":38,"x":246,"y":42},"beach":{"height":38,"pixelRatio":2,"width":38,"x":284,"y":42},"beauty":{"height":38,"pixelRatio":2,"width":38,"x":322,"y":42},"bench":{"height":38,"pixelRatio":2,"width":38,"x":360,"y":42},"books":{"height":38,"pixelRatio":2,"width":38,"x":398,"y":78},"building":{"height":34,"pixelRatio":2,"width":34,"x":386,"y":154},"bus_stop":{"height":38,"pixelRatio":2,"width":38,"x":436,"y":78},"cafe":{"height":38,"pixelRatio":2,"width":38,"x":474,"y":78},"capital":{"height":20,"pixelRatio":2,"width":20,"x":420,"y":154},"clothes":{"height":38,"pixelRatio":2,"width":38,"x":208,"y":80},"convenience":{"height":38,"pixelRatio":2,"width":38,"x":158,"y":84},"drinking_water":{"height":38,"pixelRatio":2,"width":38,"x":118,"y":116},"electronics":{"height":38,"pixelRatio":2,"width":38,"x":38,"y":122},"fast_food":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":152},"ferry_terminal":{"height":38,"pixelRatio":2,"width":38,"x":76,"y":122},"forest":{"height":38,"pixelRatio":2,"width":38,"x":246,"y":80},"garden":{"height":38,"pixelRatio":2,"width":38,"x":284,"y":80},"generic_shield-1char":{"height":36,"pixelRatio":2,"width":38,"x":470,"y":0},"generic_shield-2char":{"height":36,"pixelRatio":2,"width":48,"x":120,"y":42},"generic_shield-3char":{"height":36,"pixelRatio":2,"width":66,"x":404,"y":0},"generic_shield-4char":{"height":36,"pixelRatio":2,"width":76,"x":0,"y":36},"generic_shield-5char":{"height":36,"pixelRatio":2,"width":90,"x":0,"y":0},"library":{"height":38,"pixelRatio":2,"width":38,"x":322,"y":80},"marina":{"height":38,"pixelRatio":2,"width":38,"x":360,"y":80},"museum":{"height":38,"pixelRatio":2,"width":38,"x":398,"y":116},"park":{"height":38,"pixelRatio":2,"width":38,"x":436,"y":116},"peak":{"height":38,"pixelRatio":2,"width":38,"x":474,"y":116},"post_office":{"height":38,"pixelRatio":2,"width":38,"x":196,"y":118},"restaurant":{"height":38,"pixelRatio":2,"width":38,"x":156,"y":122},"school":{"height":38,"pixelRatio":2,"width":38,"x":114,"y":154},"stadium":{"height":38,"pixelRatio":2,"width":38,"x":38,"y":160},"supermarket":{"height":38,"pixelRatio":2,"width":38,"x":0,"y":190},"theatre":{"height":38,"pixelRatio":2,"width":38,"x":76,"y":160},"toilets":{"height":38,"pixelRatio":2,"width":38,"x":234,"y":118},"townspot":{"height":20,"pixelRatio":2,"width":20,"x":440,"y":154},"train_station":{"height":38,"pixelRatio":2,"width":38,"x":272,"y":118},"university":{"height":38,"pixelRatio":2,"width":38,"x":310,"y":118},"zoo":{"height":38,"pixelRatio":2,"width":38,"x":348,"y":118}} diff --git a/android/app/src/main/assets/map/sprites/light@2x.png b/android/app/src/main/assets/map/sprites/light@2x.png new file mode 100644 index 0000000..a6765ac Binary files /dev/null and b/android/app/src/main/assets/map/sprites/light@2x.png differ diff --git a/android/app/src/main/assets/map/style-dark.json b/android/app/src/main/assets/map/style-dark.json new file mode 100644 index 0000000..1ff2718 --- /dev/null +++ b/android/app/src/main/assets/map/style-dark.json @@ -0,0 +1 @@ +{"version":8,"glyphs":"asset://map/fonts/{fontstack}/{range}.pbf","sprite":"asset://map/sprites/dark","sources":{"protomaps":{"type":"vector","url":"pmtiles://file://__MAP_FILE__","attribution":"© OpenStreetMap contributors"}},"layers":[{"id":"background","type":"background","paint":{"background-color":"#34373d"}},{"id":"earth","type":"fill","filter":["==","$type","Polygon"],"source":"protomaps","source-layer":"earth","paint":{"fill-color":"#1f1f1f"}},{"id":"landcover","type":"fill","source":"protomaps","source-layer":"landcover","paint":{"fill-color":["match",["get","kind"],"grassland","rgba(30, 41, 31, 1)","barren","rgba(38, 38, 36, 1)","urban_area","rgba(28, 28, 28, 1)","farmland","rgba(31, 36, 32, 1)","glacier","rgba(43, 43, 43, 1)","scrub","rgba(34, 36, 30, 1)","rgba(28, 41, 37, 1)"],"fill-opacity":["interpolate",["linear"],["zoom"],5,1,7,0]}},{"id":"landuse_park","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","national_park","park","cemetery","protected_area","nature_reserve","forest","golf_course","wood","scrub","grassland","grass","glacier","sand","military","naval_base","airfield"],"paint":{"fill-opacity":["interpolate",["linear"],["zoom"],6,0,11,1],"fill-color":["case",["in",["get","kind"],["literal",["national_park","park","cemetery","protected_area","nature_reserve","forest","golf_course"]]],"#192a24",["==",["get","kind"],"wood"],"#202121",["in",["get","kind"],["literal",["scrub","grassland","grass"]]],"#222323",["==",["get","kind"],"glacier"],"#1c1c1c",["==",["get","kind"],"sand"],"#212123",["in",["get","kind"],["literal",["military","naval_base","airfield"]]],"#222323","#1f1f1f"]}},{"id":"landuse_urban_green","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","allotments","village_green","playground"],"paint":{"fill-color":"#192a24","fill-opacity":0.7}},{"id":"landuse_hospital","type":"fill","source":"protomaps","source-layer":"landuse","filter":["==","kind","hospital"],"paint":{"fill-color":"#252424"}},{"id":"landuse_industrial","type":"fill","source":"protomaps","source-layer":"landuse","filter":["==","kind","industrial"],"paint":{"fill-color":"#222222"}},{"id":"landuse_school","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","school","university","college"],"paint":{"fill-color":"#262323"}},{"id":"landuse_beach","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","beach"],"paint":{"fill-color":"#28282a"}},{"id":"landuse_zoo","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","zoo"],"paint":{"fill-color":"#222323"}},{"id":"landuse_aerodrome","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","aerodrome"],"paint":{"fill-color":"#1e1e1e"}},{"id":"roads_runway","type":"line","source":"protomaps","source-layer":"roads","filter":["==","kind_detail","runway"],"paint":{"line-color":"#333333","line-width":["interpolate",["exponential",1.6],["zoom"],10,0,12,4,18,30]}},{"id":"roads_taxiway","type":"line","source":"protomaps","source-layer":"roads","minzoom":13,"filter":["==","kind_detail","taxiway"],"paint":{"line-color":"#333333","line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,15,6]}},{"id":"landuse_runway","type":"fill","source":"protomaps","source-layer":"landuse","filter":["any",["in","kind","runway","taxiway"]],"paint":{"fill-color":"#333333"}},{"id":"water","type":"fill","filter":["==","$type","Polygon"],"source":"protomaps","source-layer":"water","paint":{"fill-color":"#31353f"}},{"id":"water_stream","type":"line","source":"protomaps","source-layer":"water","minzoom":14,"filter":["in","kind","stream"],"paint":{"line-color":"#31353f","line-width":0.5}},{"id":"water_river","type":"line","source":"protomaps","source-layer":"water","minzoom":9,"filter":["in","kind","river"],"paint":{"line-color":"#31353f","line-width":["interpolate",["exponential",1.6],["zoom"],9,0,9.5,1,18,12]}},{"id":"landuse_pedestrian","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","pedestrian","dam"],"paint":{"fill-color":"#1e1e1e"}},{"id":"landuse_pier","type":"fill","source":"protomaps","source-layer":"landuse","filter":["==","kind","pier"],"paint":{"fill-color":"#333333"}},{"id":"roads_tunnels_other_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["in","kind","other","path"]],"paint":{"line-color":"#141414","line-gap-width":["interpolate",["exponential",1.6],["zoom"],14,0,20,7]}},{"id":"roads_tunnels_minor_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["==","kind","minor_road"]],"paint":{"line-color":"#141414","line-dasharray":[3,2],"line-gap-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],12,0,12.5,1]}},{"id":"roads_tunnels_link_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["has","is_link"]],"paint":{"line-color":"#141414","line-dasharray":[3,2],"line-gap-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],12,0,12.5,1]}},{"id":"roads_tunnels_major_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["!has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#141414","line-dasharray":[3,2],"line-gap-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,0.5,18,13],"line-width":["interpolate",["exponential",1.6],["zoom"],9,0,9.5,1]}},{"id":"roads_tunnels_highway_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["!has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#141414","line-dasharray":[6,0.5],"line-gap-width":["interpolate",["exponential",1.6],["zoom"],3,0,3.5,0.5,18,15],"line-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,1,20,15]}},{"id":"roads_tunnels_other","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["in","kind","other","path"]],"paint":{"line-color":"#292929","line-dasharray":[4.5,0.5],"line-width":["interpolate",["exponential",1.6],["zoom"],14,0,20,7]}},{"id":"roads_tunnels_minor","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["==","kind","minor_road"]],"paint":{"line-color":"#292929","line-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11]}},{"id":"roads_tunnels_link","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["has","is_link"]],"paint":{"line-color":"#292929","line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11]}},{"id":"roads_tunnels_major","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["==","kind","major_road"]],"paint":{"line-color":"#292929","line-width":["interpolate",["exponential",1.6],["zoom"],6,0,12,1.6,15,3,18,13]}},{"id":"roads_tunnels_highway","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["==",["get","kind"],"highway"],["!",["has","is_link"]]],"paint":{"line-color":"#292929","line-width":["interpolate",["exponential",1.6],["zoom"],3,0,6,1.1,12,1.6,15,5,18,15]}},{"id":"buildings","type":"fill","source":"protomaps","source-layer":"buildings","filter":["in","kind","building","building_part"],"paint":{"fill-color":"#111111","fill-opacity":0.5}},{"id":"roads_pier","type":"line","source":"protomaps","source-layer":"roads","filter":["==","kind_detail","pier"],"paint":{"line-color":"#333333","line-width":["interpolate",["exponential",1.6],["zoom"],12,0,12.5,0.5,20,16]}},{"id":"roads_minor_service_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":13,"filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","minor_road"],["==","kind_detail","service"]],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],13,0,18,8],"line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,0.8]}},{"id":"roads_minor_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","minor_road"],["!=","kind_detail","service"]],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],12,0,12.5,1]}},{"id":"roads_link_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":13,"filter":["has","is_link"],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1.5]}},{"id":"roads_major_casing_late","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],6,0,12,1.6,15,3,18,13],"line-width":["interpolate",["exponential",1.6],["zoom"],9,0,9.5,1]}},{"id":"roads_highway_casing_late","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],3,0,3.5,0.5,18,15],"line-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,1,20,15]}},{"id":"roads_other","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["in","kind","other","path"],["!=","kind_detail","pier"]],"paint":{"line-color":"#333333","line-width":["interpolate",["exponential",1.6],["zoom"],14,0.5,20,12]}},{"id":"roads_link","type":"line","source":"protomaps","source-layer":"roads","filter":["has","is_link"],"paint":{"line-color":"#3d3d3d","line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11]}},{"id":"roads_minor_service","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","minor_road"],["==","kind_detail","service"]],"paint":{"line-color":"#333333","line-width":["interpolate",["exponential",1.6],["zoom"],13,0,18,8]}},{"id":"roads_minor","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","minor_road"],["!=","kind_detail","service"]],"paint":{"line-color":["interpolate",["exponential",1.6],["zoom"],11,"#3d3d3d",16,"#333333"],"line-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11]}},{"id":"roads_major_casing_early","type":"line","source":"protomaps","source-layer":"roads","maxzoom":12,"filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,0.5,18,13],"line-width":["interpolate",["exponential",1.6],["zoom"],9,0,9.5,1]}},{"id":"roads_major","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#3d3d3d","line-width":["interpolate",["exponential",1.6],["zoom"],6,0,12,1.6,15,3,18,13]}},{"id":"roads_highway_casing_early","type":"line","source":"protomaps","source-layer":"roads","maxzoom":12,"filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],3,0,3.5,0.5,18,15],"line-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,1]}},{"id":"roads_highway","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#474747","line-width":["interpolate",["exponential",1.6],["zoom"],3,0,6,1.1,12,1.6,15,5,18,15]}},{"id":"roads_rail","type":"line","source":"protomaps","source-layer":"roads","filter":["==","kind","rail"],"paint":{"line-dasharray":[0.3,0.75],"line-opacity":0.5,"line-color":"#000000","line-width":["interpolate",["exponential",1.6],["zoom"],3,0,6,0.15,18,9]}},{"id":"boundaries_country","type":"line","source":"protomaps","source-layer":"boundaries","filter":["<=","kind_detail",2],"paint":{"line-color":"#5b6374","line-width":0.7,"line-dasharray":["step",["zoom"],["literal",[2,0]],4,["literal",[2,1]]]}},{"id":"boundaries","type":"line","source":"protomaps","source-layer":"boundaries","filter":[">","kind_detail",2],"paint":{"line-color":"#5b6374","line-width":0.4,"line-dasharray":["step",["zoom"],["literal",[2,0]],4,["literal",[2,1]]]}},{"id":"roads_bridges_other_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["in","kind","other","path"]],"paint":{"line-color":"#2b2b2b","line-gap-width":["interpolate",["exponential",1.6],["zoom"],14,0,20,7]}},{"id":"roads_bridges_link_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["has","is_link"]],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],12,0,12.5,1.5]}},{"id":"roads_bridges_minor_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["==","kind","minor_road"]],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,0.8]}},{"id":"roads_bridges_major_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,0.5,18,10],"line-width":["interpolate",["exponential",1.6],["zoom"],9,0,9.5,1.5]}},{"id":"roads_bridges_other","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["in","kind","other","path"]],"paint":{"line-color":"#333333","line-dasharray":[2,1],"line-width":["interpolate",["exponential",1.6],["zoom"],14,0,20,7]}},{"id":"roads_bridges_minor","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["==","kind","minor_road"]],"paint":{"line-color":"#333333","line-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11]}},{"id":"roads_bridges_link","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["has","is_link"]],"paint":{"line-color":"#333333","line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11]}},{"id":"roads_bridges_major","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#3d3d3d","line-width":["interpolate",["exponential",1.6],["zoom"],6,0,12,1.6,15,3,18,13]}},{"id":"roads_bridges_highway_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#1f1f1f","line-gap-width":["interpolate",["exponential",1.6],["zoom"],3,0,3.5,0.5,18,15],"line-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,1,20,15]}},{"id":"roads_bridges_highway","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#474747","line-width":["interpolate",["exponential",1.6],["zoom"],3,0,6,1.1,12,1.6,15,5,18,15]}},{"id":"address_label","type":"symbol","source":"protomaps","source-layer":"buildings","minzoom":18,"filter":["==","kind","address"],"layout":{"symbol-placement":"point","text-font":["Noto Sans Regular"],"text-field":["get","addr_housenumber"],"text-size":12},"paint":{"text-color":"#525252","text-halo-color":"#1f1f1f","text-halo-width":1}},{"id":"water_waterway_label","type":"symbol","source":"protomaps","source-layer":"water","minzoom":13,"filter":["in","kind","river","stream"],"layout":{"symbol-placement":"line","text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":12,"text-letter-spacing":0.2},"paint":{"text-color":"#717784","text-halo-color":"#31353f","text-halo-width":1}},{"id":"roads_oneway","type":"symbol","source":"protomaps","source-layer":"roads","minzoom":16,"filter":["==",["get","oneway"],"yes"],"layout":{"symbol-placement":"line","icon-image":"arrow","icon-rotate":90,"symbol-spacing":100}},{"id":"roads_labels_minor","type":"symbol","source":"protomaps","source-layer":"roads","minzoom":15,"filter":["in","kind","minor_road","other","path"],"layout":{"symbol-sort-key":["get","min_zoom"],"symbol-placement":"line","text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":12},"paint":{"text-color":"#525252","text-halo-color":"#1f1f1f","text-halo-width":1}},{"id":"water_label_ocean","type":"symbol","source":"protomaps","source-layer":"water","filter":["in","kind","sea","ocean","bay","strait","fjord"],"layout":{"text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":["interpolate",["linear"],["zoom"],3,10,10,12],"text-letter-spacing":0.1,"text-max-width":9,"text-transform":"uppercase"},"paint":{"text-color":"#717784","text-halo-width":1,"text-halo-color":"#31353f"}},{"id":"earth_label_islands","type":"symbol","source":"protomaps","source-layer":"earth","filter":["in","kind","island"],"layout":{"text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":10,"text-letter-spacing":0.1,"text-max-width":8},"paint":{"text-color":"#525252","text-halo-color":"#1f1f1f","text-halo-width":1}},{"id":"water_label_lakes","type":"symbol","source":"protomaps","source-layer":"water","filter":["in","kind","lake","water"],"layout":{"text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":["interpolate",["linear"],["zoom"],3,10,6,12,10,12],"text-letter-spacing":0.1,"text-max-width":9},"paint":{"text-color":"#717784","text-halo-color":"#31353f","text-halo-width":1}},{"id":"roads_shields","type":"symbol","source":"protomaps","source-layer":"roads","filter":["all",["in",["get","kind"],["literal",["highway","major_road"]]],["has","shield_text"],["<=",["length",["get","shield_text"]],5]],"layout":{"icon-image":["match",["get","network"],"US:I",["concat","US:I-",["length",["get","shield_text"]],"char"],"NL:S-road",["concat","NL:S-road-",["length",["get","shield_text"]],"char"],["concat","generic_shield-",["length",["get","shield_text"]],"char"]],"text-field":["get","shield_text"],"text-font":["Noto Sans Regular"],"text-size":8,"icon-size":0.8,"symbol-placement":"line","icon-rotation-alignment":"viewport","text-rotation-alignment":"viewport"},"paint":{"text-color":"#666666"}},{"id":"roads_labels_major","type":"symbol","source":"protomaps","source-layer":"roads","minzoom":11,"filter":["in","kind","highway","major_road"],"layout":{"symbol-sort-key":["get","min_zoom"],"symbol-placement":"line","text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":12},"paint":{"text-color":"#666666","text-halo-color":"#1f1f1f","text-halo-width":1}},{"id":"pois","type":"symbol","source":"protomaps","source-layer":"pois","filter":["all",["in",["get","kind"],["literal",["beach","forest","marina","park","peak","zoo","garden","bench","aerodrome","station","bus_stop","ferry_terminal","stadium","university","library","school","animal","toilets","drinking_water","post_office","building","townhall","restaurant","fast_food","cafe","bar","supermarket","convenience","books","beauty","electronics","clothes","attraction","museum","theatre","artwork"]]],[">=",["zoom"],["+",["get","min_zoom"],0]]],"layout":{"icon-image":["match",["get","kind"],"station","train_station",["get","kind"]],"text-font":["Noto Sans Regular"],"text-justify":"auto","text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":["interpolate",["linear"],["zoom"],17,10,19,16],"text-max-width":8,"text-offset":[1.1,0],"text-variable-anchor":["left","right"]},"paint":{"text-color":["case",["in",["get","kind"],["literal",["beach","forest","marina","park","peak","zoo","garden","bench"]]],"#30C573",["in",["get","kind"],["literal",["aerodrome","station","bus_stop","ferry_terminal"]]],"#2B5CEA",["in",["get","kind"],["literal",["stadium","university","library","school","animal","toilets","drinking_water","post_office","building","townhall"]]],"#93939F",["in",["get","kind"],["literal",["supermarket","convenience","books","beauty","electronics","clothes"]]],"#4299BB",["in",["get","kind"],["literal",["restaurant","fast_food","cafe","bar"]]],"#F19B6E",["in",["get","kind"],["literal",["attraction","museum","theatre","artwork"]]],"#EF56BA","#1f1f1f"],"text-halo-color":"#1f1f1f","text-halo-width":1}},{"id":"places_subplace","type":"symbol","source":"protomaps","source-layer":"places","filter":["in","kind","neighbourhood","macrohood"],"layout":{"symbol-sort-key":["case",["has","sort_key"],["get","sort_key"],["get","min_zoom"]],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":7,"text-letter-spacing":0.1,"text-padding":["interpolate",["linear"],["zoom"],5,2,8,4,12,18,15,20],"text-size":["interpolate",["exponential",1.2],["zoom"],11,8,14,14,18,24],"text-transform":"uppercase"},"paint":{"text-color":"#525252","text-halo-color":"#1f1f1f","text-halo-width":1}},{"id":"places_region","type":"symbol","source":"protomaps","source-layer":"places","filter":["==","kind","region"],"layout":{"symbol-sort-key":["get","sort_key"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-size":["interpolate",["linear"],["zoom"],3,11,7,16],"text-radial-offset":0.2,"text-anchor":"center","text-transform":"uppercase"},"paint":{"text-color":"#3d3d3d","text-halo-color":"#1f1f1f","text-halo-width":1}},{"id":"places_locality","type":"symbol","source":"protomaps","source-layer":"places","filter":["==","kind","locality"],"layout":{"icon-image":["step",["zoom"],["case",["==",["get","capital"],"yes"],"capital","townspot"],8,""],"icon-size":0.7,"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-font":["Noto Sans Regular"],"symbol-sort-key":["case",["has","sort_key"],["get","sort_key"],["get","min_zoom"]],"text-padding":["interpolate",["linear"],["zoom"],5,3,8,7,12,11],"text-size":["interpolate",["linear"],["zoom"],2,["case",["<",["get","population_rank"],13],8,[">=",["get","population_rank"],13],13,0],4,["case",["<",["get","population_rank"],13],10,[">=",["get","population_rank"],13],15,0],6,["case",["<",["get","population_rank"],12],11,[">=",["get","population_rank"],12],17,0],8,["case",["<",["get","population_rank"],11],11,[">=",["get","population_rank"],11],18,0],10,["case",["<",["get","population_rank"],9],12,[">=",["get","population_rank"],9],20,0],15,["case",["<",["get","population_rank"],8],12,[">=",["get","population_rank"],8],22,0]],"icon-padding":["interpolate",["linear"],["zoom"],0,0,8,4,10,8,12,6,22,2],"text-justify":"auto","text-variable-anchor":["step",["zoom"],["literal",["bottom","left","right","top"]],8,["literal",["center"]]],"text-radial-offset":0.3},"paint":{"text-color":"#7a7a7a","text-halo-color":"#212121","text-halo-width":1}},{"id":"places_country","type":"symbol","source":"protomaps","source-layer":"places","filter":["==","kind","country"],"layout":{"symbol-sort-key":["case",["has","sort_key"],["get","sort_key"],["get","min_zoom"]],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-size":["interpolate",["linear"],["zoom"],2,["case",["<",["get","population_rank"],10],8,[">=",["get","population_rank"],10],12,0],6,["case",["<",["get","population_rank"],8],10,[">=",["get","population_rank"],8],18,0],8,["case",["<",["get","population_rank"],7],11,[">=",["get","population_rank"],7],20,0]],"icon-padding":["interpolate",["linear"],["zoom"],0,2,14,2,16,20,17,2,22,2],"text-transform":"uppercase"},"paint":{"text-color":"#5c5c5c","text-halo-color":"#1f1f1f","text-halo-width":1}}]} diff --git a/android/app/src/main/assets/map/style-light.json b/android/app/src/main/assets/map/style-light.json new file mode 100644 index 0000000..3331107 --- /dev/null +++ b/android/app/src/main/assets/map/style-light.json @@ -0,0 +1 @@ +{"version":8,"glyphs":"asset://map/fonts/{fontstack}/{range}.pbf","sprite":"asset://map/sprites/light","sources":{"protomaps":{"type":"vector","url":"pmtiles://file://__MAP_FILE__","attribution":"© OpenStreetMap contributors"}},"layers":[{"id":"background","type":"background","paint":{"background-color":"#cccccc"}},{"id":"earth","type":"fill","filter":["==","$type","Polygon"],"source":"protomaps","source-layer":"earth","paint":{"fill-color":"#e2dfda"}},{"id":"landcover","type":"fill","source":"protomaps","source-layer":"landcover","paint":{"fill-color":["match",["get","kind"],"grassland","rgba(210, 239, 207, 1)","barren","rgba(255, 243, 215, 1)","urban_area","rgba(230, 230, 230, 1)","farmland","rgba(216, 239, 210, 1)","glacier","rgba(255, 255, 255, 1)","scrub","rgba(234, 239, 210, 1)","rgba(196, 231, 210, 1)"],"fill-opacity":["interpolate",["linear"],["zoom"],5,1,7,0]}},{"id":"landuse_park","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","national_park","park","cemetery","protected_area","nature_reserve","forest","golf_course","wood","scrub","grassland","grass","glacier","sand","military","naval_base","airfield"],"paint":{"fill-opacity":["interpolate",["linear"],["zoom"],6,0,11,1],"fill-color":["case",["in",["get","kind"],["literal",["national_park","park","cemetery","protected_area","nature_reserve","forest","golf_course"]]],"#9cd3b4",["==",["get","kind"],"wood"],"#a0d9a0",["in",["get","kind"],["literal",["scrub","grassland","grass"]]],"#99d2bb",["==",["get","kind"],"glacier"],"#e7e7e7",["==",["get","kind"],"sand"],"#e2e0d7",["in",["get","kind"],["literal",["military","naval_base","airfield"]]],"#c6dcdc","#e2dfda"]}},{"id":"landuse_urban_green","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","allotments","village_green","playground"],"paint":{"fill-color":"#9cd3b4","fill-opacity":0.7}},{"id":"landuse_hospital","type":"fill","source":"protomaps","source-layer":"landuse","filter":["==","kind","hospital"],"paint":{"fill-color":"#e4dad9"}},{"id":"landuse_industrial","type":"fill","source":"protomaps","source-layer":"landuse","filter":["==","kind","industrial"],"paint":{"fill-color":"#d1dde1"}},{"id":"landuse_school","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","school","university","college"],"paint":{"fill-color":"#e4ded7"}},{"id":"landuse_beach","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","beach"],"paint":{"fill-color":"#e8e4d0"}},{"id":"landuse_zoo","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","zoo"],"paint":{"fill-color":"#c6dcdc"}},{"id":"landuse_aerodrome","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","aerodrome"],"paint":{"fill-color":"#dadbdf"}},{"id":"roads_runway","type":"line","source":"protomaps","source-layer":"roads","filter":["==","kind_detail","runway"],"paint":{"line-color":"#e9e9ed","line-width":["interpolate",["exponential",1.6],["zoom"],10,0,12,4,18,30]}},{"id":"roads_taxiway","type":"line","source":"protomaps","source-layer":"roads","minzoom":13,"filter":["==","kind_detail","taxiway"],"paint":{"line-color":"#e9e9ed","line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,15,6]}},{"id":"landuse_runway","type":"fill","source":"protomaps","source-layer":"landuse","filter":["any",["in","kind","runway","taxiway"]],"paint":{"fill-color":"#e9e9ed"}},{"id":"water","type":"fill","filter":["==","$type","Polygon"],"source":"protomaps","source-layer":"water","paint":{"fill-color":"#80deea"}},{"id":"water_stream","type":"line","source":"protomaps","source-layer":"water","minzoom":14,"filter":["in","kind","stream"],"paint":{"line-color":"#80deea","line-width":0.5}},{"id":"water_river","type":"line","source":"protomaps","source-layer":"water","minzoom":9,"filter":["in","kind","river"],"paint":{"line-color":"#80deea","line-width":["interpolate",["exponential",1.6],["zoom"],9,0,9.5,1,18,12]}},{"id":"landuse_pedestrian","type":"fill","source":"protomaps","source-layer":"landuse","filter":["in","kind","pedestrian","dam"],"paint":{"fill-color":"#e3e0d4"}},{"id":"landuse_pier","type":"fill","source":"protomaps","source-layer":"landuse","filter":["==","kind","pier"],"paint":{"fill-color":"#e0e0e0"}},{"id":"roads_tunnels_other_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["in","kind","other","path"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],14,0,20,7]}},{"id":"roads_tunnels_minor_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["==","kind","minor_road"]],"paint":{"line-color":"#e0e0e0","line-dasharray":[3,2],"line-gap-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],12,0,12.5,1]}},{"id":"roads_tunnels_link_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["has","is_link"]],"paint":{"line-color":"#e0e0e0","line-dasharray":[3,2],"line-gap-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],12,0,12.5,1]}},{"id":"roads_tunnels_major_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["!has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#e0e0e0","line-dasharray":[3,2],"line-gap-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,0.5,18,13],"line-width":["interpolate",["exponential",1.6],["zoom"],9,0,9.5,1]}},{"id":"roads_tunnels_highway_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["!has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#e0e0e0","line-dasharray":[6,0.5],"line-gap-width":["interpolate",["exponential",1.6],["zoom"],3,0,3.5,0.5,18,15],"line-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,1,20,15]}},{"id":"roads_tunnels_other","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["in","kind","other","path"]],"paint":{"line-color":"#d5d5d5","line-dasharray":[4.5,0.5],"line-width":["interpolate",["exponential",1.6],["zoom"],14,0,20,7]}},{"id":"roads_tunnels_minor","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["==","kind","minor_road"]],"paint":{"line-color":"#d5d5d5","line-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11]}},{"id":"roads_tunnels_link","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["has","is_link"]],"paint":{"line-color":"#d5d5d5","line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11]}},{"id":"roads_tunnels_major","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["==","kind","major_road"]],"paint":{"line-color":"#d5d5d5","line-width":["interpolate",["exponential",1.6],["zoom"],6,0,12,1.6,15,3,18,13]}},{"id":"roads_tunnels_highway","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_tunnel"],["==",["get","kind"],"highway"],["!",["has","is_link"]]],"paint":{"line-color":"#d5d5d5","line-width":["interpolate",["exponential",1.6],["zoom"],3,0,6,1.1,12,1.6,15,5,18,15]}},{"id":"buildings","type":"fill","source":"protomaps","source-layer":"buildings","filter":["in","kind","building","building_part"],"paint":{"fill-color":"#cccccc","fill-opacity":0.5}},{"id":"roads_pier","type":"line","source":"protomaps","source-layer":"roads","filter":["==","kind_detail","pier"],"paint":{"line-color":"#e0e0e0","line-width":["interpolate",["exponential",1.6],["zoom"],12,0,12.5,0.5,20,16]}},{"id":"roads_minor_service_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":13,"filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","minor_road"],["==","kind_detail","service"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],13,0,18,8],"line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,0.8]}},{"id":"roads_minor_casing","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","minor_road"],["!=","kind_detail","service"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],12,0,12.5,1]}},{"id":"roads_link_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":13,"filter":["has","is_link"],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1.5]}},{"id":"roads_major_casing_late","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],6,0,12,1.6,15,3,18,13],"line-width":["interpolate",["exponential",1.6],["zoom"],9,0,9.5,1]}},{"id":"roads_highway_casing_late","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],3,0,3.5,0.5,18,15],"line-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,1,20,15]}},{"id":"roads_other","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["in","kind","other","path"],["!=","kind_detail","pier"]],"paint":{"line-color":"#ebebeb","line-width":["interpolate",["exponential",1.6],["zoom"],14,0.5,20,12]}},{"id":"roads_link","type":"line","source":"protomaps","source-layer":"roads","filter":["has","is_link"],"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11]}},{"id":"roads_minor_service","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","minor_road"],["==","kind_detail","service"]],"paint":{"line-color":"#ebebeb","line-width":["interpolate",["exponential",1.6],["zoom"],13,0,18,8]}},{"id":"roads_minor","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","minor_road"],["!=","kind_detail","service"]],"paint":{"line-color":["interpolate",["exponential",1.6],["zoom"],11,"#ebebeb",16,"#ffffff"],"line-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11]}},{"id":"roads_major_casing_early","type":"line","source":"protomaps","source-layer":"roads","maxzoom":12,"filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,0.5,18,13],"line-width":["interpolate",["exponential",1.6],["zoom"],9,0,9.5,1]}},{"id":"roads_major","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.6],["zoom"],6,0,12,1.6,15,3,18,13]}},{"id":"roads_highway_casing_early","type":"line","source":"protomaps","source-layer":"roads","maxzoom":12,"filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],3,0,3.5,0.5,18,15],"line-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,1]}},{"id":"roads_highway","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["!has","is_tunnel"],["!has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.6],["zoom"],3,0,6,1.1,12,1.6,15,5,18,15]}},{"id":"roads_rail","type":"line","source":"protomaps","source-layer":"roads","filter":["==","kind","rail"],"paint":{"line-dasharray":[0.3,0.75],"line-opacity":0.5,"line-color":"#a7b1b3","line-width":["interpolate",["exponential",1.6],["zoom"],3,0,6,0.15,18,9]}},{"id":"boundaries_country","type":"line","source":"protomaps","source-layer":"boundaries","filter":["<=","kind_detail",2],"paint":{"line-color":"#adadad","line-width":0.7,"line-dasharray":["step",["zoom"],["literal",[2,0]],4,["literal",[2,1]]]}},{"id":"boundaries","type":"line","source":"protomaps","source-layer":"boundaries","filter":[">","kind_detail",2],"paint":{"line-color":"#adadad","line-width":0.4,"line-dasharray":["step",["zoom"],["literal",[2,0]],4,["literal",[2,1]]]}},{"id":"roads_bridges_other_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["in","kind","other","path"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],14,0,20,7]}},{"id":"roads_bridges_link_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["has","is_link"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],12,0,12.5,1.5]}},{"id":"roads_bridges_minor_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["==","kind","minor_road"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11],"line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,0.8]}},{"id":"roads_bridges_major_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,0.5,18,10],"line-width":["interpolate",["exponential",1.6],["zoom"],9,0,9.5,1.5]}},{"id":"roads_bridges_other","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["in","kind","other","path"]],"paint":{"line-color":"#ebebeb","line-dasharray":[2,1],"line-width":["interpolate",["exponential",1.6],["zoom"],14,0,20,7]}},{"id":"roads_bridges_minor","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["==","kind","minor_road"]],"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.6],["zoom"],11,0,12.5,0.5,15,2,18,11]}},{"id":"roads_bridges_link","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["has","is_link"]],"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.6],["zoom"],13,0,13.5,1,18,11]}},{"id":"roads_bridges_major","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["==","kind","major_road"]],"paint":{"line-color":"#f5f5f5","line-width":["interpolate",["exponential",1.6],["zoom"],6,0,12,1.6,15,3,18,13]}},{"id":"roads_bridges_highway_casing","type":"line","source":"protomaps","source-layer":"roads","minzoom":12,"filter":["all",["has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#e0e0e0","line-gap-width":["interpolate",["exponential",1.6],["zoom"],3,0,3.5,0.5,18,15],"line-width":["interpolate",["exponential",1.6],["zoom"],7,0,7.5,1,20,15]}},{"id":"roads_bridges_highway","type":"line","source":"protomaps","source-layer":"roads","filter":["all",["has","is_bridge"],["==","kind","highway"],["!has","is_link"]],"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.6],["zoom"],3,0,6,1.1,12,1.6,15,5,18,15]}},{"id":"address_label","type":"symbol","source":"protomaps","source-layer":"buildings","minzoom":18,"filter":["==","kind","address"],"layout":{"symbol-placement":"point","text-font":["Noto Sans Regular"],"text-field":["get","addr_housenumber"],"text-size":12},"paint":{"text-color":"#91888b","text-halo-color":"#ffffff","text-halo-width":1}},{"id":"water_waterway_label","type":"symbol","source":"protomaps","source-layer":"water","minzoom":13,"filter":["in","kind","river","stream"],"layout":{"symbol-placement":"line","text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":12,"text-letter-spacing":0.2},"paint":{"text-color":"#728dd4","text-halo-color":"#80deea","text-halo-width":1}},{"id":"roads_oneway","type":"symbol","source":"protomaps","source-layer":"roads","minzoom":16,"filter":["==",["get","oneway"],"yes"],"layout":{"symbol-placement":"line","icon-image":"arrow","icon-rotate":90,"symbol-spacing":100}},{"id":"roads_labels_minor","type":"symbol","source":"protomaps","source-layer":"roads","minzoom":15,"filter":["in","kind","minor_road","other","path"],"layout":{"symbol-sort-key":["get","min_zoom"],"symbol-placement":"line","text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":12},"paint":{"text-color":"#91888b","text-halo-color":"#ffffff","text-halo-width":1}},{"id":"water_label_ocean","type":"symbol","source":"protomaps","source-layer":"water","filter":["in","kind","sea","ocean","bay","strait","fjord"],"layout":{"text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":["interpolate",["linear"],["zoom"],3,10,10,12],"text-letter-spacing":0.1,"text-max-width":9,"text-transform":"uppercase"},"paint":{"text-color":"#728dd4","text-halo-width":1,"text-halo-color":"#80deea"}},{"id":"earth_label_islands","type":"symbol","source":"protomaps","source-layer":"earth","filter":["in","kind","island"],"layout":{"text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":10,"text-letter-spacing":0.1,"text-max-width":8},"paint":{"text-color":"#8f8f8f","text-halo-color":"#e0e0e0","text-halo-width":1}},{"id":"water_label_lakes","type":"symbol","source":"protomaps","source-layer":"water","filter":["in","kind","lake","water"],"layout":{"text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":["interpolate",["linear"],["zoom"],3,10,6,12,10,12],"text-letter-spacing":0.1,"text-max-width":9},"paint":{"text-color":"#728dd4","text-halo-color":"#80deea","text-halo-width":1}},{"id":"roads_shields","type":"symbol","source":"protomaps","source-layer":"roads","filter":["all",["in",["get","kind"],["literal",["highway","major_road"]]],["has","shield_text"],["<=",["length",["get","shield_text"]],5]],"layout":{"icon-image":["match",["get","network"],"US:I",["concat","US:I-",["length",["get","shield_text"]],"char"],"NL:S-road",["concat","NL:S-road-",["length",["get","shield_text"]],"char"],["concat","generic_shield-",["length",["get","shield_text"]],"char"]],"text-field":["get","shield_text"],"text-font":["Noto Sans Regular"],"text-size":8,"icon-size":0.8,"symbol-placement":"line","icon-rotation-alignment":"viewport","text-rotation-alignment":"viewport"},"paint":{"text-color":"#938a8d"}},{"id":"roads_labels_major","type":"symbol","source":"protomaps","source-layer":"roads","minzoom":11,"filter":["in","kind","highway","major_road"],"layout":{"symbol-sort-key":["get","min_zoom"],"symbol-placement":"line","text-font":["Noto Sans Regular"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":12},"paint":{"text-color":"#938a8d","text-halo-color":"#ffffff","text-halo-width":1}},{"id":"pois","type":"symbol","source":"protomaps","source-layer":"pois","filter":["all",["in",["get","kind"],["literal",["beach","forest","marina","park","peak","zoo","garden","bench","aerodrome","station","bus_stop","ferry_terminal","stadium","university","library","school","animal","toilets","drinking_water","post_office","building","townhall","restaurant","fast_food","cafe","bar","supermarket","convenience","books","beauty","electronics","clothes","attraction","museum","theatre","artwork"]]],[">=",["zoom"],["+",["get","min_zoom"],0]]],"layout":{"icon-image":["match",["get","kind"],"station","train_station",["get","kind"]],"text-font":["Noto Sans Regular"],"text-justify":"auto","text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-size":["interpolate",["linear"],["zoom"],17,10,19,16],"text-max-width":8,"text-offset":[1.1,0],"text-variable-anchor":["left","right"]},"paint":{"text-color":["case",["in",["get","kind"],["literal",["beach","forest","marina","park","peak","zoo","garden","bench"]]],"#20834D",["in",["get","kind"],["literal",["aerodrome","station","bus_stop","ferry_terminal"]]],"#315BCF",["in",["get","kind"],["literal",["stadium","university","library","school","animal","toilets","drinking_water","post_office","building","townhall"]]],"#6A5B8F",["in",["get","kind"],["literal",["supermarket","convenience","books","beauty","electronics","clothes"]]],"#1A8CBD",["in",["get","kind"],["literal",["restaurant","fast_food","cafe","bar"]]],"#CB6704",["in",["get","kind"],["literal",["attraction","museum","theatre","artwork"]]],"#EF56BA","#e2dfda"],"text-halo-color":"#e2dfda","text-halo-width":1}},{"id":"places_subplace","type":"symbol","source":"protomaps","source-layer":"places","filter":["in","kind","neighbourhood","macrohood"],"layout":{"symbol-sort-key":["case",["has","sort_key"],["get","sort_key"],["get","min_zoom"]],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":7,"text-letter-spacing":0.1,"text-padding":["interpolate",["linear"],["zoom"],5,2,8,4,12,18,15,20],"text-size":["interpolate",["exponential",1.2],["zoom"],11,8,14,14,18,24],"text-transform":"uppercase"},"paint":{"text-color":"#8f8f8f","text-halo-color":"#e0e0e0","text-halo-width":1}},{"id":"places_region","type":"symbol","source":"protomaps","source-layer":"places","filter":["==","kind","region"],"layout":{"symbol-sort-key":["get","sort_key"],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-size":["interpolate",["linear"],["zoom"],3,11,7,16],"text-radial-offset":0.2,"text-anchor":"center","text-transform":"uppercase"},"paint":{"text-color":"#b3b3b3","text-halo-color":"#e0e0e0","text-halo-width":1}},{"id":"places_locality","type":"symbol","source":"protomaps","source-layer":"places","filter":["==","kind","locality"],"layout":{"icon-image":["step",["zoom"],["case",["==",["get","capital"],"yes"],"capital","townspot"],8,""],"icon-size":0.7,"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-font":["Noto Sans Regular"],"symbol-sort-key":["case",["has","sort_key"],["get","sort_key"],["get","min_zoom"]],"text-padding":["interpolate",["linear"],["zoom"],5,3,8,7,12,11],"text-size":["interpolate",["linear"],["zoom"],2,["case",["<",["get","population_rank"],13],8,[">=",["get","population_rank"],13],13,0],4,["case",["<",["get","population_rank"],13],10,[">=",["get","population_rank"],13],15,0],6,["case",["<",["get","population_rank"],12],11,[">=",["get","population_rank"],12],17,0],8,["case",["<",["get","population_rank"],11],11,[">=",["get","population_rank"],11],18,0],10,["case",["<",["get","population_rank"],9],12,[">=",["get","population_rank"],9],20,0],15,["case",["<",["get","population_rank"],8],12,[">=",["get","population_rank"],8],22,0]],"icon-padding":["interpolate",["linear"],["zoom"],0,0,8,4,10,8,12,6,22,2],"text-justify":"auto","text-variable-anchor":["step",["zoom"],["literal",["bottom","left","right","top"]],8,["literal",["center"]]],"text-radial-offset":0.3},"paint":{"text-color":"#5c5c5c","text-halo-color":"#e0e0e0","text-halo-width":1}},{"id":"places_country","type":"symbol","source":"protomaps","source-layer":"places","filter":["==","kind","country"],"layout":{"symbol-sort-key":["case",["has","sort_key"],["get","sort_key"],["get","min_zoom"]],"text-field":["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-size":["interpolate",["linear"],["zoom"],2,["case",["<",["get","population_rank"],10],8,[">=",["get","population_rank"],10],12,0],6,["case",["<",["get","population_rank"],8],10,[">=",["get","population_rank"],8],18,0],8,["case",["<",["get","population_rank"],7],11,[">=",["get","population_rank"],7],20,0]],"icon-padding":["interpolate",["linear"],["zoom"],0,2,14,2,16,20,17,2,22,2],"text-transform":"uppercase"},"paint":{"text-color":"#a3a3a3","text-halo-color":"#e2dfda","text-halo-width":1}}]} diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/MainActivity.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/MainActivity.kt index 6ef3c26..d091f9f 100644 --- a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/MainActivity.kt +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/MainActivity.kt @@ -13,7 +13,10 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import org.onebusaway.vehicletracker.data.SessionStore import org.onebusaway.vehicletracker.data.TrackingRepository +import org.onebusaway.vehicletracker.data.map.MapRepository +import org.onebusaway.vehicletracker.data.map.refreshWhenSignedIn import org.onebusaway.vehicletracker.service.ServiceController import org.onebusaway.vehicletracker.service.rearmWhenActive import org.onebusaway.vehicletracker.ui.AppNav @@ -25,6 +28,8 @@ class MainActivity : ComponentActivity() { @Inject lateinit var serviceController: ServiceController @Inject lateinit var trackingRepository: TrackingRepository + @Inject lateinit var sessionStore: SessionStore + @Inject lateinit var mapRepository: MapRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -46,7 +51,10 @@ class MainActivity : ComponentActivity() { // resume prompt asks the driver. lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { - rearmWhenActive(trackingRepository.state, serviceController::startTracking) + launch { rearmWhenActive(trackingRepository.state, serviceController::startTracking) } + // Fetches the agency's street map, or checks it is current, whenever the app comes + // to the front signed in, when the driver signs in, and each day it stays in front. + refreshWhenSignedIn(sessionStore.session, mapRepository::refresh) } } } diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/api/ApiModels.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/api/ApiModels.kt index 165af1a..014d8bd 100644 --- a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/api/ApiModels.kt +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/api/ApiModels.kt @@ -24,6 +24,9 @@ import kotlinx.serialization.Serializable @Serializable data class EndTripRequest(@SerialName("trip_id") val tripId: Long) +/** Where the agency's offline street map is; [pmtilesUrl] is null when the agency has none. */ +@Serializable data class MapConfigDto(@SerialName("pmtiles_url") val pmtilesUrl: String? = null) + @Serializable data class LocationReportDto( @SerialName("vehicle_id") val vehicleId: String, /** GTFS trip_id; null (omitted on the wire) when the driver only knows the route. */ diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/api/TrackerApi.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/api/TrackerApi.kt index c711d21..41547cc 100644 --- a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/api/TrackerApi.kt +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/api/TrackerApi.kt @@ -21,6 +21,8 @@ interface TrackerApi { // A GTFS trip_id is arbitrary text too, so it is Retrofit's to percent-encode as well. @GET("api/v1/gtfs/trips/{trip_id}") suspend fun trip(@Path("trip_id") tripId: String): TripGeometryDto + + @GET("api/v1/map") suspend fun mapConfig(): MapConfigDto } /** diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapFileDownloader.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapFileDownloader.kt new file mode 100644 index 0000000..b39975a --- /dev/null +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapFileDownloader.kt @@ -0,0 +1,271 @@ +package org.onebusaway.vehicletracker.data.map + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okio.BufferedSource +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** The headers that tell one version of the file on its host from another. */ +@Serializable +data class FileVersion(val etag: String? = null, val lastModified: String? = null) { + val isKnown: Boolean get() = etag != null || lastModified != null + + /** True only when both name the same version. Two versions with nothing to compare are not the same. */ + fun sameAs(other: FileVersion?): Boolean = when { + other == null -> false + etag != null && other.etag != null -> etag == other.etag + lastModified != null && other.lastModified != null -> lastModified == other.lastModified + else -> false + } +} + +data class FetchRequest( + val url: String, + /** Where the bytes go while they arrive. It may hold the start of the file from an earlier try. */ + val part: File, + /** Where the finished file is moved to. */ + val target: File, + /** The version the bytes already in [part] belong to, to carry on from; null starts over. */ + val partVersion: FileVersion?, + /** A version the phone already has, or turned down. The fetch ends [FetchResult.Unchanged] if the host still serves it. */ + val knownVersion: FileVersion?, +) + +sealed interface FetchResult { + /** The host still serves [FetchRequest.knownVersion]. Nothing was written. */ + data object Unchanged : FetchResult + + /** The whole file is at [FetchRequest.target], checked. */ + data class Complete(val version: FileVersion, val length: Long) : FetchResult + + /** [version] is what the host sent, when it sent anything. */ + data class Failed(val reason: MapFailure, val version: FileVersion? = null) : FetchResult +} + +/** Fetches the agency's map file. An interface so [MapRepository] can be tested without a network. */ +fun interface MapFileFetcher { + /** + * Downloads [request]'s file. [onVersion] runs once the host has said which version it is + * sending and before any of it is written, so the caller can record what the part file holds; + * [onProgress] runs as the bytes arrive, with a total of -1 when the host gives none. + */ + suspend fun fetch( + request: FetchRequest, + onVersion: suspend (FileVersion) -> Unit, + onProgress: (bytes: Long, total: Long) -> Unit, + ): FetchResult +} + +/** + * Downloads a PMTiles file over plain HTTP from wherever the agency hosts it. + * + * A broken download carries on from where it stopped: the bytes collect in a part file, and the + * next try asks for the rest with `Range`, guarded by `If-Range` so that a file replaced in + * between is fetched afresh rather than spliced onto the old one. Only a file that is complete + * and whose own header agrees with its length is moved into place, so a map never opens a + * truncated file. + */ +class MapFileDownloader( + private val client: OkHttpClient, + private val freeSpace: (File) -> Long = File::getUsableSpace, + private val io: CoroutineDispatcher = Dispatchers.IO, +) : MapFileFetcher { + + override suspend fun fetch( + request: FetchRequest, + onVersion: suspend (FileVersion) -> Unit, + onProgress: (bytes: Long, total: Long) -> Unit, + ): FetchResult = withContext(io) { + try { + val resume = request.partVersion?.takeIf { it.isKnown && request.part.length() >= HEADER_BYTES } + if (resume == null) request.part.delete() + when (val first = attempt(request, resume, onVersion, onProgress)) { + // The host would not serve the rest of the part: start over, once. + Attempt.RangeRefused -> { + request.part.delete() + (attempt(request, null, onVersion, onProgress) as? Attempt.Done)?.result + ?: FetchResult.Failed(MapFailure.NETWORK) + } + is Attempt.Done -> first.result + } + } catch (e: IOException) { + // The part keeps what arrived, for the next try to carry on from. + FetchResult.Failed(MapFailure.NETWORK) + } + } + + private sealed interface Attempt { + data object RangeRefused : Attempt + data class Done(val result: FetchResult) : Attempt + } + + private suspend fun attempt( + request: FetchRequest, + resume: FileVersion?, + onVersion: suspend (FileVersion) -> Unit, + onProgress: (Long, Long) -> Unit, + ): Attempt { + val offset = if (resume != null) request.part.length() else 0L + val http = Request.Builder() + .url(request.url) + // OkHttp otherwise asks for gzip and unzips it quietly, which hides the file's length + // and makes byte ranges meaningless. + .header("Accept-Encoding", "identity") + if (resume != null) { + http.header("Range", "bytes=$offset-") + http.header("If-Range", resume.etag ?: resume.lastModified!!) + } else { + request.knownVersion?.etag?.let { http.header("If-None-Match", it) } + ?: request.knownVersion?.lastModified?.let { http.header("If-Modified-Since", it) } + } + + client.newCall(http.build()).await().use { response -> + val version = FileVersion(response.header("ETag"), response.header("Last-Modified")) + val start: Long + val total: Long + when (response.code) { + HTTP_NOT_MODIFIED -> return Attempt.Done(FetchResult.Unchanged) + HTTP_RANGE_NOT_SATISFIABLE -> + return if (resume != null) Attempt.RangeRefused else Attempt.Done(FetchResult.Failed(MapFailure.NETWORK, version)) + HTTP_OK -> { + // A host that ignores If-None-Match still names the version it sends. + if (resume == null && version.sameAs(request.knownVersion)) return Attempt.Done(FetchResult.Unchanged) + start = 0 + total = response.body?.contentLength() ?: -1 + } + HTTP_PARTIAL_CONTENT -> { + val range = contentRange(response.header("Content-Range")) + if (resume == null || range == null || range.first != offset) { + request.part.delete() + return Attempt.Done(FetchResult.Failed(MapFailure.NETWORK, version)) + } + start = offset + total = range.second + } + else -> return Attempt.Done(FetchResult.Failed(MapFailure.NETWORK, version)) + } + val source = response.body?.source() ?: return Attempt.Done(FetchResult.Failed(MapFailure.NETWORK, version)) + + val directory = requireNotNull(request.part.absoluteFile.parentFile) + directory.mkdirs() + val needed = (if (total > 0) total - start else 0L) + SPACE_TO_SPARE + if (freeSpace(directory) < needed) return Attempt.Done(FetchResult.Failed(MapFailure.NO_SPACE, version)) + + // The first bytes say whether this is a map file at all, so a wrong URL costs a few + // bytes rather than the whole download. + if (start == 0L && !source.startsWith(MAGIC)) return Attempt.Done(FetchResult.Failed(MapFailure.INVALID_FILE, version)) + + onVersion(version) + var written = start + FileOutputStream(request.part, start > 0).use { out -> + val buffer = ByteArray(BUFFER_BYTES) + while (true) { + currentCoroutineContext().ensureActive() + val read = source.read(buffer) + if (read == -1) break + out.write(buffer, 0, read) + written += read + onProgress(written, total) + } + out.fd.sync() + } + + if (total > 0 && written != total) return Attempt.Done(FetchResult.Failed(MapFailure.NETWORK, version)) + if (!headerMatchesLength(request.part)) { + request.part.delete() + return Attempt.Done(FetchResult.Failed(MapFailure.INVALID_FILE, version)) + } + Files.move(request.part.toPath(), request.target.toPath(), StandardCopyOption.ATOMIC_MOVE) + return Attempt.Done(FetchResult.Complete(version, written)) + } + } + + companion object { + /** The client map files are fetched with. Not the API's: the driver's token must never reach the file's host. */ + fun client(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + } +} + +/** Room left free on top of the file, so the download never fills the phone. */ +internal const val SPACE_TO_SPARE = 50L * 1024 * 1024 + +private const val BUFFER_BYTES = 64 * 1024 +private const val HTTP_OK = 200 +private const val HTTP_PARTIAL_CONTENT = 206 +private const val HTTP_NOT_MODIFIED = 304 +private const val HTTP_RANGE_NOT_SATISFIABLE = 416 + +/** A PMTiles version 3 file starts with these 8 bytes. */ +private val MAGIC = "PMTiles".toByteArray(Charsets.US_ASCII) + 3.toByte() + +/** The PMTiles version 3 header's length. */ +internal const val HEADER_BYTES = 127 + +private const val TILE_TYPE_VECTOR = 1 + +/** `bytes first-last/total` as (first, total); null for anything else, including an unknown total. */ +private fun contentRange(header: String?): Pair? { + val match = header?.let { Regex("""bytes (\d+)-(\d+)/(\d+)""").matchEntire(it.trim()) } ?: return null + return match.groupValues[1].toLong() to match.groupValues[3].toLong() +} + +/** + * True when [file] is a PMTiles version 3 vector map whose header accounts for exactly its + * length: the root directory, metadata, leaf directories and tile data all lie inside it, and the + * last of them ends where the file does. A file cut short anywhere fails this. + */ +internal fun headerMatchesLength(file: File): Boolean { + val length = file.length() + if (length < HEADER_BYTES) return false + val header = ByteArray(HEADER_BYTES) + RandomAccessFile(file, "r").use { it.readFully(header) } + if (!header.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)) return false + val bytes = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN) + if (bytes.get(99).toInt() != TILE_TYPE_VECTOR) return false + // Root directory, metadata, leaf directories, tile data: each an offset and a length. + val sections = listOf(8, 24, 40, 56).map { at -> bytes.getLong(at) to bytes.getLong(at + 8) } + if (sections.any { (offset, size) -> offset < 0 || size < 0 }) return false + if (sections.first().first < HEADER_BYTES) return false + return sections.maxOf { (offset, size) -> offset + size } == length +} + +/** True when the next bytes are [prefix]. Reads ahead without consuming them. */ +private fun BufferedSource.startsWith(prefix: ByteArray): Boolean = + request(prefix.size.toLong()) && peek().readByteArray(prefix.size.toLong()).contentEquals(prefix) + +/** Runs the call without holding a thread, and cancels it if the coroutine is cancelled. */ +private suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) { _, value, _ -> value.close() } + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + }) +} diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapFileState.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapFileState.kt new file mode 100644 index 0000000..71d9ee7 --- /dev/null +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapFileState.kt @@ -0,0 +1,41 @@ +package org.onebusaway.vehicletracker.data.map + +import kotlinx.coroutines.flow.StateFlow +import java.io.File + +/** Where the agency's offline street map stands on this phone. */ +sealed interface MapFileState { + /** No street map: the agency has none, or the server has not been asked yet. */ + data object None : MapFileState + + /** The first copy is on its way. An update to a map already here downloads without showing this. */ + data class Downloading(val bytes: Long, val total: Long) : MapFileState + + data class Ready(val file: File) : MapFileState + + /** The first copy could not be fetched. The app tries again by itself. */ + data class Failed(val reason: MapFailure) : MapFileState +} + +enum class MapFailure { + /** The phone has too little free space for the file. */ + NO_SPACE, + + /** The server or the file's host could not be reached, or answered with an error. */ + NETWORK, + + /** What the host sent is not a complete vector map file. */ + INVALID_FILE, + + /** Anything else, such as `map.json` failing to save. It is logged, and the next refresh tries again. */ + UNEXPECTED, +} + +/** The street map's state, as the tracking screen reads it. */ +interface MapFileSource { + val state: StateFlow +} + +/** How far a download has got, in whole percent. It shows 100 only once the file is done. */ +fun downloadPercent(bytes: Long, total: Long): Int = + if (total <= 0) 0 else (bytes * 100 / total).toInt().coerceIn(0, 99) diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapFileStore.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapFileStore.kt new file mode 100644 index 0000000..e540e9b --- /dev/null +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapFileStore.kt @@ -0,0 +1,104 @@ +package org.onebusaway.vehicletracker.data.map + +import android.util.Log +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.UUID + +private const val TAG = "MapFileStore" +private const val METADATA = "map.json" +private const val PART_SUFFIX = ".part" + +/** What the phone holds of the agency's map, as recorded in `map.json`. */ +@Serializable +data class MapRecord( + /** The file maps open, and where it came from. */ + val current: StoredMap? = null, + /** A download under way, kept so it carries on after the app restarts. */ + val pending: PendingDownload? = null, + /** A version the host served that was not a usable map, so it is not fetched again. */ + val rejected: RejectedMap? = null, +) + +@Serializable +data class StoredMap(val url: String, val fileName: String, val version: FileVersion, val checkedAtEpochSec: Long) + +@Serializable +data class PendingDownload(val url: String, val fileName: String, val version: FileVersion? = null) + +@Serializable +data class RejectedMap(val url: String, val version: FileVersion, val atEpochSec: Long) + +/** + * The agency's map files and `map.json`, the record of which one is current and what is being + * downloaded, in one directory. The app puts that directory in `noBackupFilesDir`: the app is + * backed up to the cloud, and a map tens of megabytes long would push it past Android's 25 MB + * backup limit, so that nothing would be backed up at all. + * + * Takes a [File] rather than a `Context` so it can be tested on the JVM, as + * [org.onebusaway.vehicletracker.data.FileTripGeometryStore] does. + */ +class MapFileStore( + private val directory: File, + private val io: CoroutineDispatcher = Dispatchers.IO, +) { + private val json = Json { ignoreUnknownKeys = true } + private val metadata = File(directory, METADATA) + private val temp = File(directory, "$METADATA.tmp") + + /** What `map.json` says, or an empty record when there is none or it cannot be read. */ + suspend fun load(): MapRecord = withContext(io) { + if (!metadata.exists()) return@withContext MapRecord() + try { + json.decodeFromString(MapRecord.serializer(), metadata.readText()) + } catch (e: Exception) { + // Unreadable is as good as empty: the map is downloaded again, and the files the + // record named go at the next start. + Log.w(TAG, "Could not read the map record", e) + MapRecord() + } + } + + suspend fun save(record: MapRecord) = withContext(io) { + directory.mkdirs() + // Written in full beside the real file and then renamed over it, so a crash mid-write + // leaves the old record or the new one, never half of one. + FileOutputStream(temp).use { out -> + out.write(json.encodeToString(MapRecord.serializer(), record).toByteArray()) + out.fd.sync() + } + Files.move(temp.toPath(), metadata.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + Unit + } + + fun file(name: String): File = File(directory, name) + + fun partFile(name: String): File = File(directory, name + PART_SUFFIX) + + /** A name no file in the directory has had. */ + fun newFileName(): String = "map-${UUID.randomUUID()}.pmtiles" + + /** + * Deletes every file [record] does not name: maps replaced by a newer one or dropped by the + * agency, and downloads given up on. Only safe while no map has a file open, which is why the + * app does it at start, before any map opens, and never later. + */ + suspend fun deleteUnlisted(record: MapRecord) = withContext(io) { + val keep = setOfNotNull( + METADATA, + record.current?.fileName, + record.pending?.fileName?.let { it + PART_SUFFIX }, + ) + directory.listFiles()?.filter { it.name !in keep }?.forEach { stray -> + if (!stray.deleteRecursively()) Log.w(TAG, "Could not delete ${stray.name}") + } + Unit + } +} diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapRepository.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapRepository.kt new file mode 100644 index 0000000..02de340 --- /dev/null +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapRepository.kt @@ -0,0 +1,184 @@ +package org.onebusaway.vehicletracker.data.map + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.onebusaway.vehicletracker.di.EpochSecondsClock +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "MapRepository" + +/** How long a map stays current before its host is asked whether there is a newer one. */ +internal const val CHECK_EVERY_S = 24L * 60 * 60 + +internal const val FIRST_RETRY_MS = 5_000L +internal const val LAST_RETRY_MS = 5 * 60_000L + +/** + * Keeps the agency's offline street map on the phone: asks the server where the file is, + * downloads it, and once it is here asks the file's host once a day whether it has changed. + * + * [refresh] starts that and returns at once. The work runs in the app's scope, so it carries on + * across screens, and for a whole shift while the tracking service keeps the app alive. Until the + * first copy is here, a network failure is tried again after 5 s, then twice as long each time up + * to every 5 min. Anything else waits for the next [refresh]: a phone too full for the file rarely + * empties itself, and each try would cost the driver data. So does a failed check once there is a + * map. + * + * A file a map may have open is never changed or deleted (maplibre-native #3658: MapLibre crashes + * if it is). A newer version downloads under a new name and the state moves to it, so the next + * map to open reads it while an open one keeps the old. Files nothing points to any more are + * deleted when the app next starts, before any map opens. + */ +@Singleton +class MapRepository @Inject constructor( + private val server: MapServer, + private val store: MapFileStore, + private val fetcher: MapFileFetcher, + private val scope: CoroutineScope, + @param:EpochSecondsClock private val now: () -> Long, +) : MapFileSource { + private val _state = MutableStateFlow(MapFileState.None) + override val state: StateFlow = _state.asStateFlow() + + private val started: Job = scope.launch { orLogged("Reading the map record", otherwise = {}) { start() } } + private val lock = Any() + private var running: Job? = null + + /** Asks for the map, or checks the one here is current, unless a refresh is already under way. */ + fun refresh() { + synchronized(lock) { + if (running?.isActive == true) return + running = scope.launch { + started.join() + keepTrying() + } + } + } + + private suspend fun start() { + val record = store.load() + store.deleteUnlisted(record) + val current = record.current ?: return + val file = store.file(current.fileName) + if (file.exists()) { + _state.value = MapFileState.Ready(file) + } else { + store.save(record.copy(current = null)) + } + } + + private enum class Outcome { DONE, RETRY } + + private suspend fun keepTrying() { + var wait = FIRST_RETRY_MS + while (orLogged("The map check", otherwise = { failed(MapFailure.UNEXPECTED, retry = false) }) { check() } == Outcome.RETRY) { + delay(wait) + wait = (wait * 2).coerceAtMost(LAST_RETRY_MS) + } + } + + private suspend fun check(): Outcome { + val record = store.load() + val current = record.current + if (current != null && record.pending == null && isRecent(current.checkedAtEpochSec)) return Outcome.DONE + + val url = when (val answer = server.ask()) { + is MapServerAnswer.File -> answer.url + MapServerAnswer.NoMap -> { + // The files stay until the next start: a map on screen may be reading one. + if (record != MapRecord()) store.save(MapRecord()) + _state.value = MapFileState.None + return Outcome.DONE + } + MapServerAnswer.SignedOut -> return Outcome.DONE + MapServerAnswer.Unreachable -> return failed(MapFailure.NETWORK) + } + + val rejected = record.rejected?.takeIf { it.url == url } + if (rejected != null && isRecent(rejected.atEpochSec)) return failed(MapFailure.INVALID_FILE, retry = false) + + val pending = record.pending?.takeIf { it.url == url } ?: PendingDownload(url, store.newFileName()) + // A download from a URL the server no longer gives is not coming back. + record.pending?.takeIf { it.url != url }?.let { store.partFile(it.fileName).delete() } + val known = if (current?.url == url) current.version else rejected?.version + val firstCopy = current == null + var shownPercent = -1 + + val result = fetcher.fetch( + FetchRequest(url, store.partFile(pending.fileName), store.file(pending.fileName), pending.version, known), + onVersion = { version -> store.save(store.load().copy(pending = pending.copy(version = version))) }, + onProgress = { bytes, total -> + // An update downloads quietly: the map already here is still good. + val percent = downloadPercent(bytes, total) + if (firstCopy && percent != shownPercent) { + shownPercent = percent + _state.value = MapFileState.Downloading(bytes, total) + } + }, + ) + return when (result) { + FetchResult.Unchanged -> { + val latest = store.load().copy(pending = null) + if (current?.url == url) { + store.save(latest.copy(current = current.copy(checkedAtEpochSec = now()))) + Outcome.DONE + } else { + // The host still serves the file that was turned down. + store.save(latest.copy(rejected = rejected?.copy(atEpochSec = now()))) + failed(MapFailure.INVALID_FILE, retry = false) + } + } + is FetchResult.Complete -> { + store.save(MapRecord(current = StoredMap(url, pending.fileName, result.version, now()))) + _state.value = MapFileState.Ready(store.file(pending.fileName)) + Log.i(TAG, "Map file ready, ${result.length} bytes") + Outcome.DONE + } + is FetchResult.Failed -> { + Log.w(TAG, "Map file not downloaded: ${result.reason}") + when (result.reason) { + MapFailure.INVALID_FILE -> { + store.save(store.load().copy(pending = null, rejected = RejectedMap(url, result.version ?: FileVersion(), now()))) + failed(MapFailure.INVALID_FILE, retry = false) + } + MapFailure.NO_SPACE, MapFailure.UNEXPECTED -> failed(result.reason, retry = false) + MapFailure.NETWORK -> failed(MapFailure.NETWORK) + } + } + } + } + + /** + * Shows [reason] while the phone has no map. A phone that has one keeps showing it, and does + * not retry: the check is still due, so the next [refresh] tries again. + */ + private fun failed(reason: MapFailure, retry: Boolean = true): Outcome { + if (_state.value is MapFileState.Ready) return Outcome.DONE + _state.value = MapFileState.Failed(reason) + return if (retry) Outcome.RETRY else Outcome.DONE + } + + private fun isRecent(epochSec: Long): Boolean = now() - epochSec in 0 until CHECK_EVERY_S + + /** + * Runs [block], logging what it throws, such as an IOException from a save on a full phone, + * and answering [otherwise] instead. Nothing above this catches it, and an exception that + * leaves the app's scope crashes the app. Cancellation passes through. + */ + private inline fun orLogged(what: String, otherwise: () -> T, block: () -> T): T = try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "$what failed", e) + otherwise() + } +} diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapServer.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapServer.kt new file mode 100644 index 0000000..de5f528 --- /dev/null +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/MapServer.kt @@ -0,0 +1,43 @@ +package org.onebusaway.vehicletracker.data.map + +import kotlinx.coroutines.CancellationException +import org.onebusaway.vehicletracker.data.api.TrackerApiProvider +import retrofit2.HttpException +import javax.inject.Inject + +/** What the server says about the agency's map file. */ +sealed interface MapServerAnswer { + data class File(val url: String) : MapServerAnswer + + /** The agency has no map file, or the server is older than `GET /api/v1/map` and answers 404. */ + data object NoMap : MapServerAnswer + + /** The server turned the driver's token down; asking again before they sign in is pointless. */ + data object SignedOut : MapServerAnswer + + /** No answer: no network, a server error, or a reply that is not one. */ + data object Unreachable : MapServerAnswer +} + +/** Asks the server where the agency's map file is. An interface so [MapRepository] can be tested without one. */ +fun interface MapServer { + suspend fun ask(): MapServerAnswer +} + +class TrackerMapServer @Inject constructor(private val apiProvider: TrackerApiProvider) : MapServer { + override suspend fun ask(): MapServerAnswer = try { + apiProvider.get().mapConfig().pmtilesUrl?.takeIf { it.isNotBlank() } + ?.let { MapServerAnswer.File(it) } + ?: MapServerAnswer.NoMap + } catch (e: CancellationException) { + throw e + } catch (e: HttpException) { + when (e.code()) { + 404 -> MapServerAnswer.NoMap + 401 -> MapServerAnswer.SignedOut + else -> MapServerAnswer.Unreachable + } + } catch (e: Exception) { + MapServerAnswer.Unreachable + } +} diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/RefreshWhenSignedIn.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/RefreshWhenSignedIn.kt new file mode 100644 index 0000000..beb4da2 --- /dev/null +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/data/map/RefreshWhenSignedIn.kt @@ -0,0 +1,23 @@ +package org.onebusaway.vehicletracker.data.map + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.onebusaway.vehicletracker.data.Session + +/** + * Calls [refresh] when collection starts with the driver signed in, each time they sign in, and + * once a day after that for as long as collection lasts: a driver can keep the app in front for a + * whole day and more. Calling it costs nothing while the map is current, because + * [MapRepository.refresh] goes to the network only when a check is due. + */ +internal suspend fun refreshWhenSignedIn(session: Flow, refresh: () -> Unit) { + session.map { it.token != null }.distinctUntilChanged().collectLatest { signedIn -> + while (signedIn) { + refresh() + delay(CHECK_EVERY_S * 1000) + } + } +} diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/di/AppModule.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/di/AppModule.kt index 31a1e74..654c79d 100644 --- a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/di/AppModule.kt +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/di/AppModule.kt @@ -29,6 +29,13 @@ import org.onebusaway.vehicletracker.data.vehiclePrefsDataStore import org.onebusaway.vehicletracker.data.api.ApiFactory import org.onebusaway.vehicletracker.data.api.TrackerApi import org.onebusaway.vehicletracker.data.api.TrackerApiProvider +import org.onebusaway.vehicletracker.data.map.MapFileDownloader +import org.onebusaway.vehicletracker.data.map.MapFileFetcher +import org.onebusaway.vehicletracker.data.map.MapFileSource +import org.onebusaway.vehicletracker.data.map.MapFileStore +import org.onebusaway.vehicletracker.data.map.MapRepository +import org.onebusaway.vehicletracker.data.map.MapServer +import org.onebusaway.vehicletracker.data.map.TrackerMapServer import org.onebusaway.vehicletracker.service.ServiceController import org.onebusaway.vehicletracker.service.ServiceControllerImpl import java.io.File @@ -171,6 +178,22 @@ object AppModule { @Provides fun provideTrackerApiProvider(holder: ApiHolder): TrackerApiProvider = TrackerApiProvider(holder::api) + /** The agency's map files: in `noBackupFilesDir`, because tens of megabytes would push the app past its backup limit. */ + @Provides + @Singleton + fun provideMapFileStore(@ApplicationContext context: Context): MapFileStore = + MapFileStore(File(context.noBackupFilesDir, "map")) + + @Provides + @Singleton + fun provideMapFileFetcher(): MapFileFetcher = MapFileDownloader(MapFileDownloader.client()) + + @Provides + fun provideMapServer(apiProvider: TrackerApiProvider): MapServer = TrackerMapServer(apiProvider) + + @Provides + fun provideMapFileSource(repository: MapRepository): MapFileSource = repository + @Provides @EpochSecondsClock fun provideClock(): () -> Long = { System.currentTimeMillis() / 1000 } diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/map/MapStyle.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/map/MapStyle.kt new file mode 100644 index 0000000..32057ac --- /dev/null +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/map/MapStyle.kt @@ -0,0 +1,29 @@ +package org.onebusaway.vehicletracker.ui.map + +import android.content.Context +import kotlinx.serialization.json.JsonPrimitive +import java.io.File + +/** + * Where the street style names the map file. `android/map-style/generate.mjs` writes it into the + * styles under `assets/map/`, once each. + */ +const val MAP_FILE_PLACEHOLDER = "__MAP_FILE__" + +/** The street style for [dark] or light, from the app's assets, reading [file]. */ +fun streetStyle(context: Context, dark: Boolean, file: File): String { + val template = context.assets.open(if (dark) "map/style-dark.json" else "map/style-light.json") + .bufferedReader() + .use { it.readText() } + return streetStyleJson(template, file) +} + +/** + * [template] reading its vector tiles from [file]. The path lands inside a JSON string, so it is + * escaped as one. + */ +fun streetStyleJson(template: String, file: File): String { + require(template.contains(MAP_FILE_PLACEHOLDER)) { "the street style does not name a map file" } + val escaped = JsonPrimitive(file.absolutePath).toString().removeSurrounding("\"") + return template.replace(MAP_FILE_PLACEHOLDER, escaped) +} diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/map/RouteMap.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/map/RouteMap.kt index 973d752..6a0463e 100644 --- a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/map/RouteMap.kt +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/map/RouteMap.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -56,6 +57,7 @@ import org.maplibre.android.maps.MapView import org.maplibre.android.maps.Style import org.maplibre.android.style.expressions.Expression import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.Layer import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.layers.Property import org.maplibre.android.style.layers.PropertyFactory.circleColor @@ -77,9 +79,14 @@ import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.Point import org.onebusaway.vehicletracker.R +import org.onebusaway.vehicletracker.data.map.MapFailure +import org.onebusaway.vehicletracker.data.map.MapFileState +import org.onebusaway.vehicletracker.data.map.downloadPercent import org.onebusaway.vehicletracker.engine.Adherence +import org.onebusaway.vehicletracker.engine.GeoPoint import org.onebusaway.vehicletracker.engine.ShapeGeometry import org.onebusaway.vehicletracker.engine.TripGeometry +import java.io.File import kotlin.math.roundToInt // The sizes and the off-route look are iOS's, from RouteMapViewController and MapGlyphs @@ -124,10 +131,11 @@ fun Context.glEsVersion(): Int = getSystemService(ActivityManager::class.java).d * The trip on a map, as spec §6.3 has it: the shape in the route colour over a darker casing, * the stops with the next one enlarged and named, the vehicle pointed along its course, and the * matched position while it is off the route. The camera follows heading-up with the vehicle a - * third of the way up. There is no street map under it yet, only a plain background. + * third of the way up. Under it is the agency's street map once the file is on the phone, and a + * plain background until then, with a note while the first copy downloads. */ @Composable -fun RouteMap(geometry: TripGeometry, adherence: Adherence?, modifier: Modifier = Modifier) { +fun RouteMap(geometry: TripGeometry, adherence: Adherence?, mapFile: MapFileState, modifier: Modifier = Modifier) { val context = LocalContext.current val dark = isSystemInDarkTheme() val labelColor = MaterialTheme.colorScheme.onSurface.toArgb() @@ -143,6 +151,8 @@ fun RouteMap(geometry: TripGeometry, adherence: Adherence?, modifier: Modifier = onDispose { lifecycle.removeObserver(observer) } } LaunchedEffect(geometry, adherence) { controller.show(geometry, adherence) } + val streetFile = (mapFile as? MapFileState.Ready)?.file + LaunchedEffect(streetFile) { controller.useStreets(streetFile) } // Restarted by every drag, so the map goes back only once the driver has left it alone. LaunchedEffect(controller.following, controller.drags) { if (!controller.following) { @@ -157,6 +167,7 @@ fun RouteMap(geometry: TripGeometry, adherence: Adherence?, modifier: Modifier = modifier = Modifier.fillMaxSize(), onRelease = { controller.destroy() }, ) + MapFileNote(mapFile, Modifier.align(Alignment.TopStart).padding(8.dp)) if (!controller.following) { FilledTonalButton( onClick = controller::follow, @@ -168,6 +179,26 @@ fun RouteMap(geometry: TripGeometry, adherence: Adherence?, modifier: Modifier = } } +/** A line over the map while it has no streets to show yet. Nothing once it has, or when the agency has no map. */ +@Composable +private fun MapFileNote(state: MapFileState, modifier: Modifier = Modifier) { + val text = when (state) { + is MapFileState.Downloading -> stringResource(R.string.tracking_map_downloading, downloadPercent(state.bytes, state.total)) + is MapFileState.Failed -> stringResource( + if (state.reason == MapFailure.NO_SPACE) R.string.tracking_map_no_space else R.string.tracking_map_retrying, + ) + MapFileState.None, is MapFileState.Ready -> return + } + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + shadowElevation = 2.dp, + ) { + Text(text, Modifier.padding(horizontal = 10.dp, vertical = 6.dp), style = MaterialTheme.typography.labelMedium) + } +} + /** * Owns the [MapView] and pushes the trip and each fix into it: a port of iOS's * `RouteMapViewController`. It touches the map only for what changed since the fix before, which @@ -175,7 +206,7 @@ fun RouteMap(geometry: TripGeometry, adherence: Adherence?, modifier: Modifier = */ private class RouteMapController( private val context: Context, - dark: Boolean, + private val dark: Boolean, private val labelColor: Int, private val haloColor: Int, ) { @@ -195,6 +226,14 @@ private class RouteMapController( private var started = false private var resumed = false private var destroyed = false + private var vehicleActive = false + + /** + * The street map file the map reads, from the first one it is given. It never changes after + * that: MapLibre crashes if the file under an open map is replaced (maplibre-native #3658), so + * a newer file waits until the screen opens again. + */ + private var streetFile: File? = null private var latestGeometry: TripGeometry? = null private var latestAdherence: Adherence? = null @@ -231,21 +270,48 @@ private class RouteMapController( isRotateGesturesEnabled = false isTiltGesturesEnabled = false isCompassEnabled = false - // Nothing on the plain background needs crediting. isLogoEnabled = false - isAttributionEnabled = false } map.addOnCameraMoveStartedListener { reason -> if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) drags++ } - map.setStyle(Style.Builder().fromJson(plainStyle(dark))) { style -> - if (destroyed) return@setStyle - this.style = style - addLayers(style) - activateVehicle(map, style) - render() - } + loadStyle(map) + } + } + + /** Puts the streets in [file] under the trip, unless the map already has a file. */ + fun useStreets(file: File?) { + if (file == null || streetFile != null || destroyed) return + streetFile = file + map?.let(::loadStyle) + } + + private fun loadStyle(map: MapLibreMap) { + val file = streetFile + val json = if (file != null) streetStyle(context, dark, file) else plainStyle(dark) + // setStyle retires the current style at once, and MapLibre throws on any later use of it. + // Until the new one has loaded there is nothing to draw on: render() waits, and + // onStyleLoaded puts the trip back. + style = null + map.setStyle(Style.Builder().fromJson(json)) { style -> + // A plain style that finishes after the streets were asked for is already being replaced. + if (destroyed || (file == null) != (streetFile == null)) return@setStyle + onStyleLoaded(map, style, streets = file != null) + } + } + + private fun onStyleLoaded(map: MapLibreMap, style: Style, streets: Boolean) { + this.style = style + // Above every road but below the street names, so they stay readable over the route. + addLayers(style, below = if (streets) style.layers.firstOrNull { it is SymbolLayer }?.id else null) + // OpenStreetMap's licence asks for its credit wherever its data is on screen. + map.uiSettings.isAttributionEnabled = streets + if (!vehicleActive) { + activateVehicle(map, style) + vehicleActive = true } + repaint(style) + render() } fun show(geometry: TripGeometry, adherence: Adherence?) { @@ -283,10 +349,10 @@ private class RouteMapController( following = true } - private fun applyFollowPadding() { + private fun applyFollowPadding(animationMs: Long = FOLLOW_ANIMATION_MS) { val map = map ?: return if (destroyed) return - map.locationComponent.paddingWhileTracking(doubleArrayOf(0.0, mapView.height / 3.0, 0.0, 0.0), FOLLOW_ANIMATION_MS) + map.locationComponent.paddingWhileTracking(doubleArrayOf(0.0, mapView.height / 3.0, 0.0, 0.0), animationMs) } fun onLifecycleEvent(event: Lifecycle.Event) { @@ -327,21 +393,27 @@ private class RouteMapController( state = next } - private fun addLayers(style: Style) { + /** + * The trip's layers. The line, its casing and the stops go below the layer called [below] when + * there is one; the next stop's name and the matched position always go on top. + */ + private fun addLayers(style: Style, below: String?) { + fun addRouteLayer(layer: Layer) = if (below != null) style.addLayerBelow(layer, below) else style.addLayer(layer) + style.addSource(GeoJsonSource(ROUTE_SOURCE)) style.addSource(GeoJsonSource(STOPS_SOURCE)) style.addSource(GeoJsonSource(NEXT_STOP_SOURCE)) style.addSource(GeoJsonSource(SNAPPED_SOURCE)) // Casing first: layers draw in the order they are added, so the line sits centred on the // wider one underneath it. - style.addLayer( + addRouteLayer( LineLayer(CASING_LAYER, ROUTE_SOURCE).withProperties( lineWidth(CASING_WIDTH), lineCap(Property.LINE_CAP_ROUND), lineJoin(Property.LINE_JOIN_ROUND), ), ) - style.addLayer( + addRouteLayer( LineLayer(ROUTE_LAYER, ROUTE_SOURCE).withProperties( lineWidth(ROUTE_WIDTH), lineCap(Property.LINE_CAP_ROUND), @@ -349,7 +421,7 @@ private class RouteMapController( ), ) val isNext = Expression.eq(Expression.get(NEXT_STOP_PROPERTY), Expression.literal(true)) - style.addLayer( + addRouteLayer( CircleLayer(STOPS_LAYER, STOPS_SOURCE).withProperties( circleRadius(Expression.switchCase(isNext, Expression.literal(NEXT_STOP_RADIUS), Expression.literal(STOP_RADIUS))), circleStrokeColor(Color.WHITE), @@ -430,11 +502,7 @@ private class RouteMapController( heading = FollowHeading() shape = ShapeGeometry.of(geometry.shapePoints) - val routeColor = routeLineColor(geometry.routeColor) - style.getSourceAs(ROUTE_SOURCE)?.setGeoJson(routeLineString(geometry.shapePoints)) - style.getLayer(CASING_LAYER)?.setProperties(lineColor(casingColor(routeColor)), lineOpacity(1f)) - style.getLayer(ROUTE_LAYER)?.setProperties(lineColor(routeColor), lineOpacity(1f)) - style.getLayer(STOPS_LAYER)?.setProperties(circleColor(routeColor)) + paintRoute(style, geometry) drawStops(style, geometry, nextStopIndex = -1) setSnappedVisible(style, visible = false) @@ -465,16 +533,8 @@ private class RouteMapController( // The line is only ever drawn two ways, so it is restyled only when it crosses between // them, or on the first fix, which decides which. - if (previous?.isOnRoute != next.isOnRoute) { - val opacity = if (next.isOnRoute) 1f else OFF_ROUTE_OPACITY - style.getLayer(CASING_LAYER)?.setProperties(lineOpacity(opacity)) - style.getLayer(ROUTE_LAYER)?.setProperties(lineOpacity(opacity)) - component.applyStyle(vehicleOptions(next.isOnRoute)) - setSnappedVisible(style, visible = !next.isOnRoute) - } - next.snapped?.let { snapped -> - style.getSourceAs(SNAPPED_SOURCE)?.setGeoJson(Point.fromLngLat(snapped.lon, snapped.lat)) - } + if (previous?.isOnRoute != next.isOnRoute) paintOnRoute(style, next.isOnRoute) + next.snapped?.let { drawSnapped(style, it) } if (previous?.nextStopIndex != next.nextStopIndex) drawStops(style, geometry, next.nextStopIndex) if (previous == null) { @@ -484,6 +544,44 @@ private class RouteMapController( } } + /** + * Draws the trip on a style that has just replaced another, as it was on the old one: a new + * style starts with none of the trip's data. The camera stays where it is. + */ + private fun repaint(style: Style) { + val geometry = drawnGeometry ?: return + val current = state + paintRoute(style, geometry) + drawStops(style, geometry, current?.nextStopIndex ?: -1) + paintOnRoute(style, current?.isOnRoute ?: true) + current?.snapped?.let { drawSnapped(style, it) } + } + + /** The shape in the route colour over its darker casing, and the stops in the same colour. */ + private fun paintRoute(style: Style, geometry: TripGeometry) { + val routeColor = routeLineColor(geometry.routeColor) + style.getSourceAs(ROUTE_SOURCE)?.setGeoJson(routeLineString(geometry.shapePoints)) + style.getLayer(CASING_LAYER)?.setProperties(lineColor(casingColor(routeColor)), lineOpacity(1f)) + style.getLayer(ROUTE_LAYER)?.setProperties(lineColor(routeColor), lineOpacity(1f)) + style.getLayer(STOPS_LAYER)?.setProperties(circleColor(routeColor)) + } + + /** Off the route the line fades, the vehicle turns grey and the matched position shows, as on iOS. */ + private fun paintOnRoute(style: Style, onRoute: Boolean) { + val opacity = if (onRoute) 1f else OFF_ROUTE_OPACITY + style.getLayer(CASING_LAYER)?.setProperties(lineOpacity(opacity)) + style.getLayer(ROUTE_LAYER)?.setProperties(lineOpacity(opacity)) + map?.locationComponent?.applyStyle(vehicleOptions(onRoute)) + // applyStyle also sets the map's padding to the options' own, none, which would drop the + // vehicle to the middle. Put the follow padding straight back, in the same frame. + if (following && state != null) applyFollowPadding(animationMs = 0) + setSnappedVisible(style, visible = !onRoute) + } + + private fun drawSnapped(style: Style, snapped: GeoPoint) { + style.getSourceAs(SNAPPED_SOURCE)?.setGeoJson(Point.fromLngLat(snapped.lon, snapped.lat)) + } + private fun drawStops(style: Style, geometry: TripGeometry, nextStopIndex: Int) { style.getSourceAs(STOPS_SOURCE)?.setGeoJson(stopFeatures(geometry.stops, nextStopIndex)) val nextStop = geometry.stops.getOrNull(nextStopIndex) diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/tracking/TrackingScreen.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/tracking/TrackingScreen.kt index 7dc0957..cfe1cb7 100644 --- a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/tracking/TrackingScreen.kt +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/tracking/TrackingScreen.kt @@ -69,7 +69,7 @@ fun TrackingScreen( // With no geometry there is no route to draw, and the panel already says so. val geometry = state.tracking.geometry if (mapSupported && geometry != null) { - RouteMap(geometry, state.tracking.adherence, modifier) + RouteMap(geometry, state.tracking.adherence, state.mapFile, modifier) } else { Spacer(modifier) } diff --git a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/tracking/TrackingViewModel.kt b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/tracking/TrackingViewModel.kt index 0454a2e..34991fc 100644 --- a/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/tracking/TrackingViewModel.kt +++ b/android/app/src/main/kotlin/org/onebusaway/vehicletracker/ui/tracking/TrackingViewModel.kt @@ -14,6 +14,8 @@ import org.onebusaway.vehicletracker.data.ActiveTrip import org.onebusaway.vehicletracker.data.TrackingRepository import org.onebusaway.vehicletracker.data.TrackingState import org.onebusaway.vehicletracker.data.TripStateStore +import org.onebusaway.vehicletracker.data.map.MapFileSource +import org.onebusaway.vehicletracker.data.map.MapFileState import javax.inject.Inject data class TrackingUiState( @@ -21,6 +23,7 @@ data class TrackingUiState( val activeTrip: ActiveTrip? = null, val ending: Boolean = false, val endTripError: Boolean = false, + val mapFile: MapFileState = MapFileState.None, ) @HiltViewModel @@ -28,13 +31,14 @@ class TrackingViewModel @Inject constructor( private val trackingRepository: TrackingRepository, tripStateStore: TripStateStore, private val tripEnder: TripEnder, + mapFiles: MapFileSource, ) : ViewModel() { private val _uiState = MutableStateFlow(TrackingUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { - combine(trackingRepository.state, tripStateStore.activeTrip, tripEnder.state) { tracking, trip, end -> - TrackingUiState(tracking = tracking, activeTrip = trip, ending = end.ending, endTripError = end.failed) + combine(trackingRepository.state, tripStateStore.activeTrip, tripEnder.state, mapFiles.state) { tracking, trip, end, mapFile -> + TrackingUiState(tracking = tracking, activeTrip = trip, ending = end.ending, endTripError = end.failed, mapFile = mapFile) } .onEach { _uiState.value = it } .launchIn(viewModelScope) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f8896d3..6560cc8 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -99,6 +99,9 @@ Schedule unavailable Re-center + Downloading map, %1$d%% + Not enough space for the map + Map not downloaded yet, will retry Resume Shift? diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/Fakes.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/Fakes.kt index e59390d..7c70f68 100644 --- a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/Fakes.kt +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/Fakes.kt @@ -1,6 +1,8 @@ package org.onebusaway.vehicletracker.data import kotlinx.coroutines.flow.MutableStateFlow +import org.onebusaway.vehicletracker.data.map.MapFileSource +import org.onebusaway.vehicletracker.data.map.MapFileState import org.onebusaway.vehicletracker.engine.TripGeometry import org.onebusaway.vehicletracker.service.ServiceController import java.security.GeneralSecurityException @@ -41,6 +43,10 @@ class FakeCryptor : Cryptor { } } +class FakeMapFileSource : MapFileSource { + override val state = MutableStateFlow(MapFileState.None) +} + class FakeSessionStore : SessionStore { val state = MutableStateFlow(Session(null, null, null)) override val session = state diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapFileDownloaderTest.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapFileDownloaderTest.kt new file mode 100644 index 0000000..8ec8532 --- /dev/null +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapFileDownloaderTest.kt @@ -0,0 +1,354 @@ +package org.onebusaway.vehicletracker.data.map + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import okio.Buffer +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.concurrent.TimeUnit + +/** [MapFileDownloader] against a local HTTP server, writing to a real directory. */ +class MapFileDownloaderTest { + @get:Rule val tempFolder = TemporaryFolder() + + private lateinit var server: MockWebServer + private lateinit var dir: File + private val file = pmtiles(size = 200_000) + private val versions = mutableListOf() + private val progress = mutableListOf>() + + @Before fun setUp() { + server = MockWebServer().apply { start() } + dir = tempFolder.newFolder("map") + } + + @After fun tearDown() = server.shutdown() + + private val part get() = File(dir, "map-1.pmtiles.part") + private val target get() = File(dir, "map-1.pmtiles") + private val url get() = server.url("/maps/agency.pmtiles").toString() + + private fun downloader(freeSpace: Long = Long.MAX_VALUE) = MapFileDownloader(MapFileDownloader.client(), freeSpace = { freeSpace }) + + private suspend fun fetch( + partVersion: FileVersion? = null, + knownVersion: FileVersion? = null, + downloader: MapFileDownloader = downloader(), + ): FetchResult = downloader.fetch( + FetchRequest(url, part, target, partVersion, knownVersion), + onVersion = { versions += it }, + onProgress = { bytes, total -> progress += bytes to total }, + ) + + private fun body(bytes: ByteArray) = Buffer().write(bytes) + + private fun whole(etag: String = "\"v1\"") = MockResponse().setHeader("ETag", etag).setBody(body(file)) + + @Test fun `downloads the whole file and moves it into place`() = runTest { + server.enqueue(whole()) + + val result = fetch() + + assertEquals(FetchResult.Complete(FileVersion(etag = "\"v1\""), file.size.toLong()), result) + assertArrayEquals(file, target.readBytes()) + assertFalse("the part file is renamed, not left behind", part.exists()) + assertEquals(listOf(FileVersion(etag = "\"v1\"")), versions) + assertEquals(file.size.toLong() to file.size.toLong(), progress.last()) + } + + @Test fun `asks for the bytes as stored, never gzipped`() = runTest { + server.enqueue(whole()) + + fetch() + + assertEquals("identity", server.takeRequest().getHeader("Accept-Encoding")) + } + + // The client is the production one: the driver's bearer token belongs to the agency's own + // server, and the map file is on someone else's host. + @Test fun `never sends an Authorization header, even after a redirect`() = runTest { + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/cdn/agency.pmtiles"))) + server.enqueue(whole()) + + val result = fetch() + + assertTrue("expected Complete, was $result", result is FetchResult.Complete) + val first = server.takeRequest() + val second = server.takeRequest() + assertEquals("/cdn/agency.pmtiles", second.path) + assertNull(first.getHeader("Authorization")) + assertNull(second.getHeader("Authorization")) + } + + @Test fun `carries on from a part file with Range and If-Range`() = runTest { + part.writeBytes(file.copyOfRange(0, 50_000)) + server.enqueue( + MockResponse().setResponseCode(206) + .setHeader("ETag", "\"v1\"") + .setHeader("Content-Range", "bytes 50000-${file.size - 1}/${file.size}") + .setBody(body(file.copyOfRange(50_000, file.size))), + ) + + val result = fetch(partVersion = FileVersion(etag = "\"v1\"")) + + assertEquals(FetchResult.Complete(FileVersion(etag = "\"v1\""), file.size.toLong()), result) + assertArrayEquals(file, target.readBytes()) + val request = server.takeRequest() + assertEquals("bytes=50000-", request.getHeader("Range")) + assertEquals("\"v1\"", request.getHeader("If-Range")) + val (firstBytes, total) = progress.first() + assertTrue("progress counts the bytes already there, began at $firstBytes", firstBytes > 50_000) + assertEquals(file.size.toLong(), total) + } + + @Test fun `resumes on Last-Modified when the host sends no ETag`() = runTest { + part.writeBytes(file.copyOfRange(0, 50_000)) + val stamp = "Mon, 05 Oct 2026 10:00:00 GMT" + server.enqueue( + MockResponse().setResponseCode(206) + .setHeader("Last-Modified", stamp) + .setHeader("Content-Range", "bytes 50000-${file.size - 1}/${file.size}") + .setBody(body(file.copyOfRange(50_000, file.size))), + ) + + fetch(partVersion = FileVersion(lastModified = stamp)) + + assertEquals(stamp, server.takeRequest().getHeader("If-Range")) + assertArrayEquals(file, target.readBytes()) + } + + // What a host does when the file changed since the part began (If-Range no longer matches), + // or when it does not do ranges at all: it sends the whole file. Splicing that onto the old + // part would make a corrupt map. + @Test fun `starts over when the host sends the whole file instead of the rest`() = runTest { + part.writeBytes(ByteArray(50_000) { 7 }) + server.enqueue(whole(etag = "\"v2\"")) + + val result = fetch(partVersion = FileVersion(etag = "\"v1\"")) + + assertEquals(FetchResult.Complete(FileVersion(etag = "\"v2\""), file.size.toLong()), result) + assertArrayEquals(file, target.readBytes()) + } + + @Test fun `starts over once when the host refuses the range`() = runTest { + part.writeBytes(file.copyOfRange(0, 50_000)) + server.enqueue(MockResponse().setResponseCode(416)) + server.enqueue(whole()) + + val result = fetch(partVersion = FileVersion(etag = "\"v1\"")) + + assertTrue("expected Complete, was $result", result is FetchResult.Complete) + assertArrayEquals(file, target.readBytes()) + assertEquals("bytes=50000-", server.takeRequest().getHeader("Range")) + assertNull("the second request asks for the whole file", server.takeRequest().getHeader("Range")) + } + + @Test fun `a part from an unknown version is not resumed`() = runTest { + part.writeBytes(ByteArray(50_000) { 7 }) + server.enqueue(whole()) + + fetch(partVersion = FileVersion()) + + assertNull(server.takeRequest().getHeader("Range")) + assertArrayEquals(file, target.readBytes()) + } + + @Test fun `a cut connection keeps the part, and the next try carries on from it`() = runTest { + server.enqueue(whole().setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY)) + + val cut = fetch() + + assertEquals(FetchResult.Failed(MapFailure.NETWORK), cut) + assertFalse(target.exists()) + val kept = part.length() + assertTrue("expected part of the file, had $kept bytes", kept in (HEADER_BYTES.toLong() + 1) until file.size.toLong()) + + server.enqueue( + MockResponse().setResponseCode(206) + .setHeader("ETag", "\"v1\"") + .setHeader("Content-Range", "bytes $kept-${file.size - 1}/${file.size}") + .setBody(body(file.copyOfRange(kept.toInt(), file.size))), + ) + val resumed = fetch(partVersion = versions.single()) + + assertTrue("expected Complete, was $resumed", resumed is FetchResult.Complete) + assertArrayEquals(file, target.readBytes()) + } + + @Test fun `cancelling leaves a part to carry on from`() = runTest { + server.enqueue(whole().throttleBody(16_384, 50, TimeUnit.MILLISECONDS)) + val someArrived = CompletableDeferred() + val downloader = downloader() + + withContext(Dispatchers.Default) { + val job = launch { + downloader.fetch( + FetchRequest(url, part, target, null, null), + onVersion = {}, + onProgress = { bytes, _ -> if (bytes >= 32_768) someArrived.complete(Unit) }, + ) + } + withTimeout(10_000) { someArrived.await() } + job.cancel() + job.join() + } + + assertFalse(target.exists()) + assertTrue("expected the bytes so far, had ${part.length()}", part.length() >= 32_768) + } + + @Test fun `stops before writing anything when the phone has too little space`() = runTest { + server.enqueue(whole()) + + val result = fetch(downloader = downloader(freeSpace = file.size + SPACE_TO_SPARE - 1)) + + assertEquals(FetchResult.Failed(MapFailure.NO_SPACE, FileVersion(etag = "\"v1\"")), result) + assertFalse(part.exists()) + assertTrue(versions.isEmpty()) + } + + @Test fun `room for the rest of a resumed file is enough`() = runTest { + part.writeBytes(file.copyOfRange(0, 150_000)) + server.enqueue( + MockResponse().setResponseCode(206) + .setHeader("ETag", "\"v1\"") + .setHeader("Content-Range", "bytes 150000-${file.size - 1}/${file.size}") + .setBody(body(file.copyOfRange(150_000, file.size))), + ) + + val result = fetch(partVersion = FileVersion(etag = "\"v1\""), downloader = downloader(freeSpace = 50_000 + SPACE_TO_SPARE)) + + assertTrue("expected Complete, was $result", result is FetchResult.Complete) + } + + @Test fun `a response that is not a map file is refused after its first bytes`() = runTest { + server.enqueue(MockResponse().setHeader("ETag", "\"page\"").setBody("Not found")) + + val result = fetch() + + assertEquals(FetchResult.Failed(MapFailure.INVALID_FILE, FileVersion(etag = "\"page\"")), result) + assertFalse(part.exists()) + assertFalse(target.exists()) + assertTrue("nothing recorded for a file never written", versions.isEmpty()) + } + + @Test fun `a raster map file is refused`() = runTest { + server.enqueue(MockResponse().setBody(body(pmtiles(size = 4096, tileType = 2)))) + + val result = fetch() + + assertTrue("expected INVALID_FILE, was $result", result is FetchResult.Failed && result.reason == MapFailure.INVALID_FILE) + assertFalse(part.exists()) + assertFalse(target.exists()) + } + + // The host's length and the bytes agree, so only the file's own header can tell it was cut + // short when it was uploaded. + @Test fun `a file shorter than its own header says is refused`() = runTest { + server.enqueue(MockResponse().setBody(body(file.copyOfRange(0, 150_000)))) + + val result = fetch() + + assertTrue("expected INVALID_FILE, was $result", result is FetchResult.Failed && result.reason == MapFailure.INVALID_FILE) + assertFalse(part.exists()) + assertFalse(target.exists()) + } + + @Test fun `the host still has the version here, so nothing is downloaded`() = runTest { + server.enqueue(MockResponse().setResponseCode(304)) + + val result = fetch(knownVersion = FileVersion(etag = "\"v1\"")) + + assertEquals(FetchResult.Unchanged, result) + assertEquals("\"v1\"", server.takeRequest().getHeader("If-None-Match")) + assertFalse(target.exists()) + } + + @Test fun `asks If-Modified-Since when all it has is a date`() = runTest { + server.enqueue(MockResponse().setResponseCode(304)) + val stamp = "Mon, 05 Oct 2026 10:00:00 GMT" + + fetch(knownVersion = FileVersion(lastModified = stamp)) + + val request = server.takeRequest() + assertEquals(stamp, request.getHeader("If-Modified-Since")) + assertNull(request.getHeader("If-None-Match")) + } + + // Some hosts send 200 whatever the request says. Without this, every phone would download the + // whole map again at every daily check. + @Test fun `a host that ignores the condition is caught by its ETag`() = runTest { + server.enqueue(whole(etag = "\"v1\"")) + + val result = fetch(knownVersion = FileVersion(etag = "\"v1\"")) + + assertEquals(FetchResult.Unchanged, result) + assertFalse(part.exists()) + assertFalse(target.exists()) + } + + @Test fun `a newer version is downloaded`() = runTest { + server.enqueue(whole(etag = "\"v2\"")) + + val result = fetch(knownVersion = FileVersion(etag = "\"v1\"")) + + assertEquals(FetchResult.Complete(FileVersion(etag = "\"v2\""), file.size.toLong()), result) + } + + @Test fun `a server error is a network failure`() = runTest { + server.enqueue(MockResponse().setResponseCode(503)) + + assertEquals(FetchResult.Failed(MapFailure.NETWORK, FileVersion()), fetch()) + } + + @Test fun `no server is a network failure`() = runTest { + server.shutdown() + + assertEquals(FetchResult.Failed(MapFailure.NETWORK), fetch()) + } +} + +/** [headerMatchesLength], the check that keeps a truncated file from ever being opened. */ +class PmtilesHeaderTest { + @get:Rule val tempFolder = TemporaryFolder() + + private fun check(bytes: ByteArray) = headerMatchesLength(tempFolder.newFile().apply { writeBytes(bytes) }) + + @Test fun `a whole file passes`() = assertTrue(check(pmtiles())) + + @Test fun `a file cut short fails`() = assertFalse(check(pmtiles().copyOfRange(0, 4000))) + + @Test fun `bytes past the last section fail`() = assertFalse(check(pmtiles() + ByteArray(10))) + + @Test fun `a file shorter than the header fails`() = assertFalse(check(pmtiles().copyOfRange(0, HEADER_BYTES - 1))) + + @Test fun `another version fails`() = assertFalse(check(pmtiles().also { it[7] = 2 })) + + @Test fun `another format fails`() = assertFalse(check(pmtiles().also { it[0] = 'X'.code.toByte() })) + + @Test fun `raster tiles fail`() = assertFalse(check(pmtiles(tileType = 2))) + + @Test fun `a root directory inside the header fails`() = assertFalse( + check(pmtiles().also { java.nio.ByteBuffer.wrap(it).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(8, 100) }), + ) + + @Test fun `a negative length fails`() = assertFalse( + check(pmtiles().also { java.nio.ByteBuffer.wrap(it).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(48, -1) }), + ) +} diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapFileStateTest.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapFileStateTest.kt new file mode 100644 index 0000000..5e11bb6 --- /dev/null +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapFileStateTest.kt @@ -0,0 +1,16 @@ +package org.onebusaway.vehicletracker.data.map + +import org.junit.Assert.assertEquals +import org.junit.Test + +class MapFileStateTest { + @Test fun `a download's percent`() = assertEquals(40, downloadPercent(40, 100)) + + @Test fun `rounds down`() = assertEquals(33, downloadPercent(1, 3)) + + @Test fun `never says 100 before the file is in place`() = assertEquals(99, downloadPercent(100, 100)) + + @Test fun `an unknown total shows nothing done`() = assertEquals(0, downloadPercent(5_000, -1)) + + @Test fun `a metro-sized file`() = assertEquals(50, downloadPercent(46_500_000, 93_000_000)) +} diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapFileStoreTest.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapFileStoreTest.kt new file mode 100644 index 0000000..7da0962 --- /dev/null +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapFileStoreTest.kt @@ -0,0 +1,106 @@ +package org.onebusaway.vehicletracker.data.map + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException + +/** [MapFileStore] against a real directory. */ +class MapFileStoreTest { + @get:Rule val tempFolder = TemporaryFolder() + + private val record = MapRecord( + current = StoredMap("https://maps.example.org/a.pmtiles", "map-a.pmtiles", FileVersion(etag = "\"v1\""), 1_000), + pending = PendingDownload("https://maps.example.org/a.pmtiles", "map-b.pmtiles", FileVersion(etag = "\"v2\"")), + rejected = RejectedMap("https://maps.example.org/old.pmtiles", FileVersion(lastModified = "Mon, 05 Oct 2026 10:00:00 GMT"), 900), + ) + + @Test fun `a saved record reads back unchanged`() = runTest { + val store = MapFileStore(tempFolder.newFolder()) + + store.save(record) + + assertEquals(record, store.load()) + } + + @Test fun `saving creates the directory`() = runTest { + val store = MapFileStore(File(tempFolder.root, "not-yet/map")) + + store.save(record) + + assertEquals(record, store.load()) + } + + @Test fun `nothing saved reads back as an empty record`() = runTest { + assertEquals(MapRecord(), MapFileStore(tempFolder.newFolder()).load()) + } + + @Test fun `a record this build cannot read loads as empty, not a crash`() = runTest { + val dir = tempFolder.newFolder() + File(dir, "map.json").writeText("""{"current":{"url":""") + + assertEquals(MapRecord(), MapFileStore(dir).load()) + } + + @Test fun `a save that fails leaves the record before it intact`() = runTest { + val dir = tempFolder.newFolder() + val store = MapFileStore(dir) + store.save(record) + // A directory where the temporary file goes makes the write fail before the rename. + File(dir, "map.json.tmp").mkdir() + + val failure = runCatching { store.save(MapRecord()) }.exceptionOrNull() + + assertTrue("expected an IOException, was $failure", failure is IOException) + assertEquals(record, store.load()) + } + + @Test fun `start-up cleaning keeps the record, the current map and the download under way`() = runTest { + val dir = tempFolder.newFolder() + val store = MapFileStore(dir) + store.save(record) + listOf("map-a.pmtiles", "map-b.pmtiles.part", "map-old.pmtiles", "map-c.pmtiles.part", "map.json.tmp") + .forEach { File(dir, it).writeText("x") } + + store.deleteUnlisted(record) + + assertEquals(setOf("map.json", "map-a.pmtiles", "map-b.pmtiles.part"), dir.list()!!.toSet()) + } + + @Test fun `start-up cleaning with nothing recorded empties the directory but for the record`() = runTest { + val dir = tempFolder.newFolder() + val store = MapFileStore(dir) + store.save(MapRecord()) + File(dir, "map-a.pmtiles").writeText("x") + + store.deleteUnlisted(MapRecord()) + + assertEquals(setOf("map.json"), dir.list()!!.toSet()) + } + + @Test fun `cleaning a directory that does not exist yet does nothing`() = runTest { + MapFileStore(File(tempFolder.root, "missing")).deleteUnlisted(record) + } + + @Test fun `new file names never repeat`() { + val store = MapFileStore(tempFolder.newFolder()) + + assertNotEquals(store.newFileName(), store.newFileName()) + assertTrue(store.newFileName().endsWith(".pmtiles")) + } + + @Test fun `a part file sits beside the file it becomes`() { + val dir = tempFolder.newFolder() + val store = MapFileStore(dir) + + assertEquals(File(dir, "map-a.pmtiles.part"), store.partFile("map-a.pmtiles")) + assertEquals(File(dir, "map-a.pmtiles"), store.file("map-a.pmtiles")) + assertFalse(store.file("map-a.pmtiles").exists()) + } +} diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapRepositoryTest.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapRepositoryTest.kt new file mode 100644 index 0000000..70582a3 --- /dev/null +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/MapRepositoryTest.kt @@ -0,0 +1,474 @@ +package org.onebusaway.vehicletracker.data.map + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * [MapRepository]'s decisions, on virtual time, with the server and the file's host faked and the + * store writing to a real directory. + */ +class MapRepositoryTest { + @get:Rule val tempFolder = TemporaryFolder() + + private val url = "https://maps.example.org/agency.pmtiles" + private val v1 = FileVersion(etag = "\"v1\"") + private val v2 = FileVersion(etag = "\"v2\"") + private var clock = 1_790_000_000L + private val day = CHECK_EVERY_S + + private lateinit var dir: File + private lateinit var store: MapFileStore + private val server = FakeMapServer() + private val fetcher = FakeFetcher() + + private fun TestScope.setUp() { + dir = tempFolder.newFolder("map") + store = MapFileStore(dir, io = StandardTestDispatcher(testScheduler)) + server.now = { testScheduler.currentTime } + } + + /** + * Runs everything due in the next second. The repository works in [TestScope.backgroundScope], + * which advanceUntilIdle does not wait for; retries come later than this, so a test that + * wants one advances the clock itself. + */ + private fun TestScope.settle() { + advanceTimeBy(1_000) + runCurrent() + } + + /** A repository starting up, as at app start, with its first look at the directory done. */ + private fun TestScope.started(): MapRepository = + MapRepository(server, store, fetcher, backgroundScope, now = { clock }).also { settle() } + + private fun TestScope.statesOf(repository: MapRepository): List { + val states = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { repository.state.collect { states += it } } + return states + } + + /** A map that has been here since [checkedAt], its file on disk. */ + private suspend fun mapHere(name: String = "map-a.pmtiles", checkedAt: Long = clock, from: String = url): File { + store.save(MapRecord(current = StoredMap(from, name, v1, checkedAt))) + return File(dir, name).apply { writeText("map") } + } + + @Test fun `a map from before is shown at start, and files nothing names are deleted`() = runTest { + setUp() + val map = mapHere() + File(dir, "map-old.pmtiles").writeText("old") + File(dir, "map-x.pmtiles.part").writeText("abandoned") + + val repository = started() + + assertEquals(MapFileState.Ready(map), repository.state.value) + assertEquals(setOf("map.json", "map-a.pmtiles"), dir.list()!!.toSet()) + } + + @Test fun `a recorded map whose file is gone is forgotten`() = runTest { + setUp() + store.save(MapRecord(current = StoredMap(url, "map-a.pmtiles", v1, clock))) + + val repository = started() + + assertEquals(MapFileState.None, repository.state.value) + assertNull(store.load().current) + } + + @Test fun `the first download shows its progress, then the map`() = runTest { + setUp() + val repository = started() + val states = statesOf(repository) + fetcher.steps += fetcher.complete(v1, progress = listOf(25L to 100L, 26L to 100L, 60L to 100L)) + + repository.refresh() + settle() + + val target = fetcher.requests.single().target + assertEquals( + listOf( + MapFileState.None, + MapFileState.Downloading(25, 100), + MapFileState.Downloading(26, 100), + MapFileState.Downloading(60, 100), + MapFileState.Ready(target), + ), + states, + ) + assertEquals(MapRecord(current = StoredMap(url, target.name, v1, clock)), store.load()) + } + + @Test fun `progress is shown once per percent, not once per read`() = runTest { + setUp() + val repository = started() + val states = statesOf(repository) + fetcher.steps += fetcher.complete(v1, progress = listOf(1_000L to 1_000_000L, 2_000L to 1_000_000L, 10_000L to 1_000_000L)) + + repository.refresh() + settle() + + assertEquals(listOf(MapFileState.Downloading(1_000, 1_000_000), MapFileState.Downloading(10_000, 1_000_000)), states.filterIsInstance()) + } + + @Test fun `no map from the server shows the plain map and keeps the file until the next start`() = runTest { + setUp() + val map = mapHere(checkedAt = clock - day) + server.answers += MapServerAnswer.NoMap + val repository = started() + + repository.refresh() + settle() + + assertEquals(MapFileState.None, repository.state.value) + assertEquals(MapRecord(), store.load()) + assertTrue("a map on screen may still be reading it", map.exists()) + + started() + + assertFalse("gone at the next start", map.exists()) + } + + @Test fun `a check within a day asks nobody`() = runTest { + setUp() + mapHere(checkedAt = clock - day + 60) + val repository = started() + + repository.refresh() + settle() + + assertTrue(server.askedAt.isEmpty()) + assertTrue(fetcher.requests.isEmpty()) + } + + @Test fun `after a day the host is asked whether the map changed`() = runTest { + setUp() + val map = mapHere(checkedAt = clock - day) + fetcher.steps += fetcher.unchanged() + val repository = started() + + repository.refresh() + settle() + + val request = fetcher.requests.single() + assertEquals(v1, request.knownVersion) + assertNull(request.partVersion) + assertEquals(clock, store.load().current!!.checkedAtEpochSec) + assertEquals(MapFileState.Ready(map), repository.state.value) + } + + @Test fun `a clock set back makes the check due`() = runTest { + setUp() + mapHere(checkedAt = clock + 3_600) + fetcher.steps += fetcher.unchanged() + val repository = started() + + repository.refresh() + settle() + + assertEquals(1, fetcher.requests.size) + } + + @Test fun `a newer version downloads under a new name, and the old file waits for the next start`() = runTest { + setUp() + val old = mapHere(checkedAt = clock - day) + fetcher.steps += fetcher.complete(v2) + val repository = started() + + repository.refresh() + settle() + + val new = fetcher.requests.single().target + assertNotEquals(old, new) + assertEquals(MapFileState.Ready(new), repository.state.value) + assertEquals(StoredMap(url, new.name, v2, clock), store.load().current) + assertTrue("an open map may be reading it", old.exists()) + + started() + + assertFalse(old.exists()) + assertTrue(new.exists()) + } + + @Test fun `an update downloads without the progress note`() = runTest { + setUp() + mapHere(checkedAt = clock - day) + fetcher.steps += fetcher.complete(v2, progress = listOf(10L to 100L, 90L to 100L)) + val repository = started() + val states = statesOf(repository) + + repository.refresh() + settle() + + assertTrue("states were $states", states.all { it is MapFileState.Ready }) + } + + @Test fun `a new URL from the server downloads a new map`() = runTest { + setUp() + mapHere(checkedAt = clock - day, from = "https://old.example.org/a.pmtiles") + fetcher.steps += fetcher.complete(v2) + val repository = started() + + repository.refresh() + settle() + + val request = fetcher.requests.single() + assertEquals(url, request.url) + assertNull("the old URL's version says nothing about the new one", request.knownVersion) + assertEquals(url, store.load().current!!.url) + } + + @Test fun `one refresh at a time`() = runTest { + setUp() + val repository = started() + val gate = CompletableDeferred() + server.gate = gate + server.answers += MapServerAnswer.NoMap + + repository.refresh() + runCurrent() + repository.refresh() + repository.refresh() + gate.complete(Unit) + settle() + + assertEquals(1, server.askedAt.size) + } + + @Test fun `a refresh after the last one finished runs again`() = runTest { + setUp() + val repository = started() + server.answers += MapServerAnswer.NoMap + server.answers += MapServerAnswer.NoMap + + repository.refresh() + settle() + repository.refresh() + settle() + + assertEquals(2, server.askedAt.size) + } + + @Test fun `failures before the first copy are retried after 5 s, doubling up to 5 min`() = runTest { + setUp() + server.default = MapServerAnswer.Unreachable + val repository = started() + val start = testScheduler.currentTime + + repository.refresh() + advanceTimeBy(25 * 60_000L) + + val gaps = server.askedAt.map { it - start }.zipWithNext { a, b -> b - a }.take(8) + assertEquals(listOf(5_000L, 10_000L, 20_000L, 40_000L, 80_000L, 160_000L, 300_000L, 300_000L), gaps) + assertEquals(MapFileState.Failed(MapFailure.NETWORK), repository.state.value) + } + + @Test fun `with a map here, a failed check waits for the next refresh`() = runTest { + setUp() + val map = mapHere(checkedAt = clock - day) + server.default = MapServerAnswer.Unreachable + val repository = started() + + repository.refresh() + advanceTimeBy(60 * 60_000L) + + assertEquals(1, server.askedAt.size) + assertEquals(MapFileState.Ready(map), repository.state.value) + assertEquals("a network failure never forgets the map", map.name, store.load().current?.fileName) + } + + @Test fun `a refused token stops until the next refresh`() = runTest { + setUp() + server.default = MapServerAnswer.SignedOut + val repository = started() + + repository.refresh() + advanceTimeBy(60 * 60_000L) + + assertEquals(1, server.askedAt.size) + assertEquals(MapFileState.None, repository.state.value) + } + + @Test fun `a broken file is not fetched again within a day, then only if it changed`() = runTest { + setUp() + fetcher.steps += fetcher.fail(MapFailure.INVALID_FILE, version = v1) + val repository = started() + + repository.refresh() + settle() + + assertEquals(MapFileState.Failed(MapFailure.INVALID_FILE), repository.state.value) + assertEquals(RejectedMap(url, v1, clock), store.load().rejected) + + repository.refresh() + settle() + + assertEquals("the same day, the host is not asked again", 1, fetcher.requests.size) + + clock += day + fetcher.steps += fetcher.unchanged() + repository.refresh() + settle() + + assertEquals(2, fetcher.requests.size) + assertEquals(v1, fetcher.requests.last().knownVersion) + assertEquals(clock, store.load().rejected!!.atEpochSec) + assertEquals(MapFileState.Failed(MapFailure.INVALID_FILE), repository.state.value) + } + + @Test fun `a cut-off download carries on from its part file`() = runTest { + setUp() + fetcher.steps += fetcher.fail(MapFailure.NETWORK, afterStarting = v1) + fetcher.steps += fetcher.complete(v1) + val repository = started() + + repository.refresh() + advanceTimeBy(FIRST_RETRY_MS + 1) + + val (first, second) = fetcher.requests + assertNull(first.partVersion) + assertEquals(v1, second.partVersion) + assertEquals(first.part, second.part) + assertEquals(MapFileState.Ready(second.target), repository.state.value) + } + + @Test fun `a download from a URL the server no longer gives is dropped`() = runTest { + setUp() + val oldPart = File(dir, "map-p.pmtiles.part").apply { writeText("partial") } + store.save(MapRecord(pending = PendingDownload("https://old.example.org/a.pmtiles", "map-p.pmtiles", v1))) + fetcher.steps += fetcher.complete(v2) + val repository = started() + + repository.refresh() + settle() + + val request = fetcher.requests.single() + assertNull(request.partVersion) + assertNotEquals(oldPart, request.part) + assertFalse(oldPart.exists()) + } + + // Each try costs the driver whatever the host sends before the app hangs up, and a full phone + // rarely empties itself, so it waits for the driver to come back to the app. + @Test fun `too little space is said so, and tried at the next refresh, not on a timer`() = runTest { + setUp() + fetcher.steps += fetcher.fail(MapFailure.NO_SPACE) + fetcher.steps += fetcher.complete(v1) + val repository = started() + + repository.refresh() + advanceTimeBy(60 * 60_000L) + + assertEquals(MapFileState.Failed(MapFailure.NO_SPACE), repository.state.value) + assertEquals(1, fetcher.requests.size) + + repository.refresh() + settle() + + assertTrue(repository.state.value is MapFileState.Ready) + } + + // A directory where the record's temporary file goes makes every save fail, as a full phone + // would. The failure must end the check, not escape the app's scope and crash the app: runTest + // fails a test whose background coroutine throws. + @Test fun `a record that cannot be saved fails the check instead of crashing the app`() = runTest { + setUp() + val repository = started() + val blocker = File(dir, "map.json.tmp").apply { mkdir() } + fetcher.steps += fetcher.complete(v1) + + repository.refresh() + advanceTimeBy(60 * 60_000L) + + assertEquals(MapFileState.Failed(MapFailure.UNEXPECTED), repository.state.value) + assertEquals("not retried on a timer", 1, fetcher.requests.size) + + blocker.delete() + fetcher.steps += fetcher.complete(v1) + repository.refresh() + settle() + + assertTrue(repository.state.value is MapFileState.Ready) + } + + // A read-only directory, because start-up cleaning would remove a blocking file before the save. + @Test fun `a record that cannot be rewritten at start does not crash the app`() = runTest { + setUp() + store.save(MapRecord(current = StoredMap(url, "map-gone.pmtiles", v1, clock))) + dir.setWritable(false) + try { + val repository = started() + + assertEquals(MapFileState.None, repository.state.value) + } finally { + dir.setWritable(true) + } + } + + private inner class FakeMapServer : MapServer { + val answers = ArrayDeque() + var default: MapServerAnswer = MapServerAnswer.File(url) + val askedAt = mutableListOf() + var now: () -> Long = { 0 } + var gate: CompletableDeferred? = null + + override suspend fun ask(): MapServerAnswer { + askedAt += now() + gate?.await() + return answers.removeFirstOrNull() ?: default + } + } + + private class FakeFetcher : MapFileFetcher { + fun interface Step { + suspend fun run(request: FetchRequest, onVersion: suspend (FileVersion) -> Unit, onProgress: (Long, Long) -> Unit): FetchResult + } + + val steps = ArrayDeque() + val requests = mutableListOf() + + override suspend fun fetch( + request: FetchRequest, + onVersion: suspend (FileVersion) -> Unit, + onProgress: (bytes: Long, total: Long) -> Unit, + ): FetchResult { + requests += request + return checkNotNull(steps.removeFirstOrNull()) { "no fetch expected: $request" }.run(request, onVersion, onProgress) + } + + fun complete(version: FileVersion, progress: List> = emptyList()) = Step { request, onVersion, onProgress -> + onVersion(version) + for ((bytes, total) in progress) { + onProgress(bytes, total) + delay(1) + } + request.target.writeText("map") + FetchResult.Complete(version, 3) + } + + fun unchanged() = Step { _, _, _ -> FetchResult.Unchanged } + + fun fail(reason: MapFailure, version: FileVersion? = null, afterStarting: FileVersion? = null) = Step { request, onVersion, _ -> + if (afterStarting != null) { + onVersion(afterStarting) + request.part.writeText("partial") + } + FetchResult.Failed(reason, version ?: afterStarting) + } + } +} diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/PmtilesFixtures.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/PmtilesFixtures.kt new file mode 100644 index 0000000..89f6911 --- /dev/null +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/PmtilesFixtures.kt @@ -0,0 +1,25 @@ +package org.onebusaway.vehicletracker.data.map + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * A PMTiles version 3 file [size] bytes long whose header accounts for every byte: a 10-byte root + * directory, 10 bytes of metadata, no leaf directories and tile data to the end. The tile bytes + * are a counting pattern, so a test can tell a spliced file from a whole one. Not a map MapLibre + * could draw, but everything [headerMatchesLength] checks. + */ +fun pmtiles(size: Int = 4096, tileType: Int = 1): ByteArray { + require(size > HEADER_BYTES + 20) + val bytes = ByteArray(size) { (it % 251).toByte() } + val header = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + "PMTiles".toByteArray(Charsets.US_ASCII).forEachIndexed { i, b -> header.put(i, b) } + header.put(7, 3) + val root = HEADER_BYTES.toLong() + header.putLong(8, root).putLong(16, 10) + header.putLong(24, root + 10).putLong(32, 10) + header.putLong(40, root + 20).putLong(48, 0) + header.putLong(56, root + 20).putLong(64, size - root - 20) + header.put(99, tileType.toByte()) + return bytes +} diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/RefreshWhenSignedInTest.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/RefreshWhenSignedInTest.kt new file mode 100644 index 0000000..c5165f6 --- /dev/null +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/RefreshWhenSignedInTest.kt @@ -0,0 +1,96 @@ +package org.onebusaway.vehicletracker.data.map + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.onebusaway.vehicletracker.data.Session + +class RefreshWhenSignedInTest { + private val signedOut = Session(serverUrl = "https://tracker.example.org", token = null, issuedAtEpochSec = null) + private val signedIn = Session(serverUrl = "https://tracker.example.org", token = "jwt", issuedAtEpochSec = 100L) + private val session = MutableStateFlow(signedOut) + private var refreshes = 0 + + private fun TestScope.startCollecting() { + backgroundScope.launch { refreshWhenSignedIn(session) { refreshes++ } } + runCurrent() + } + + private fun TestScope.emit(next: Session) { + session.value = next + runCurrent() + } + + // How each return to the app refreshes: MainActivity collects anew every time it resumes. + @Test fun `signed in when collection starts refreshes once`() = runTest { + session.value = signedIn + + startCollecting() + + assertEquals(1, refreshes) + } + + @Test fun `signing in refreshes`() = runTest { + startCollecting() + assertEquals(0, refreshes) + + emit(signedIn) + + assertEquals(1, refreshes) + } + + @Test fun `signed out throughout never refreshes`() = runTest { + startCollecting() + + assertEquals(0, refreshes) + } + + @Test fun `a new token while signed in does not refresh again`() = runTest { + session.value = signedIn + startCollecting() + + emit(signedIn.copy(token = "jwt-2", issuedAtEpochSec = 200L)) + + assertEquals(1, refreshes) + } + + // Without this, a driver who keeps the app in front would never get the daily check. + @Test fun `staying signed in with the app in front refreshes again each day`() = runTest { + session.value = signedIn + startCollecting() + + advanceTimeBy(CHECK_EVERY_S * 1000 - 1) + assertEquals(1, refreshes) + + advanceTimeBy(2) + assertEquals(2, refreshes) + + advanceTimeBy(CHECK_EVERY_S * 1000) + assertEquals(3, refreshes) + } + + @Test fun `signing out stops the daily refresh`() = runTest { + session.value = signedIn + startCollecting() + + emit(signedOut) + advanceTimeBy(3 * CHECK_EVERY_S * 1000) + + assertEquals(1, refreshes) + } + + @Test fun `signing out and in again refreshes again`() = runTest { + session.value = signedIn + startCollecting() + + emit(signedOut) + emit(signedIn) + + assertEquals(2, refreshes) + } +} diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/TrackerMapServerTest.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/TrackerMapServerTest.kt new file mode 100644 index 0000000..89e4b68 --- /dev/null +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/data/map/TrackerMapServerTest.kt @@ -0,0 +1,84 @@ +package org.onebusaway.vehicletracker.data.map + +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.onebusaway.vehicletracker.data.api.ApiFactory +import org.onebusaway.vehicletracker.data.api.TrackerApiProvider + +/** [TrackerMapServer] reading `GET /api/v1/map` through the app's real API client. */ +class TrackerMapServerTest { + private lateinit var server: MockWebServer + private lateinit var mapServer: TrackerMapServer + + @Before fun setUp() { + server = MockWebServer().apply { start() } + mapServer = TrackerMapServer(TrackerApiProvider { ApiFactory { "jwt" }.create(server.url("/").toString()) }) + } + + @After fun tearDown() = server.shutdown() + + @Test fun `a URL is the agency's map file`() = runTest { + server.enqueue(MockResponse().setBody("""{"pmtiles_url":"https://maps.example.org/agency.pmtiles"}""")) + + assertEquals(MapServerAnswer.File("https://maps.example.org/agency.pmtiles"), mapServer.ask()) + + val request = server.takeRequest() + assertEquals("GET", request.method) + assertEquals("/api/v1/map", request.path) + assertEquals("Bearer jwt", request.getHeader("Authorization")) + } + + @Test fun `null means the agency has no map`() = runTest { + server.enqueue(MockResponse().setBody("""{"pmtiles_url":null}""")) + + assertEquals(MapServerAnswer.NoMap, mapServer.ask()) + } + + @Test fun `a blank URL means no map too`() = runTest { + server.enqueue(MockResponse().setBody("""{"pmtiles_url":" "}""")) + + assertEquals(MapServerAnswer.NoMap, mapServer.ask()) + } + + // A server from before GET /api/v1/map existed. + @Test fun `404 means no map`() = runTest { + server.enqueue(MockResponse().setResponseCode(404).setBody("404 page not found")) + + assertEquals(MapServerAnswer.NoMap, mapServer.ask()) + } + + @Test fun `401 means the driver must sign in again`() = runTest { + server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"invalid token"}""")) + + assertEquals(MapServerAnswer.SignedOut, mapServer.ask()) + } + + @Test fun `a server error is no answer`() = runTest { + server.enqueue(MockResponse().setResponseCode(500).setBody("""{"error":"internal server error"}""")) + + assertEquals(MapServerAnswer.Unreachable, mapServer.ask()) + } + + @Test fun `a reply that is not the expected JSON is no answer`() = runTest { + server.enqueue(MockResponse().setBody("captive portal")) + + assertEquals(MapServerAnswer.Unreachable, mapServer.ask()) + } + + @Test fun `no network is no answer`() = runTest { + server.shutdown() + + assertEquals(MapServerAnswer.Unreachable, mapServer.ask()) + } + + @Test fun `no server URL yet is no answer, not a crash`() = runTest { + val unconfigured = TrackerMapServer { error("not signed in") } + + assertEquals(MapServerAnswer.Unreachable, unconfigured.ask()) + } +} diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/ui/MapAssetsTest.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/ui/MapAssetsTest.kt new file mode 100644 index 0000000..dec4cd4 --- /dev/null +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/ui/MapAssetsTest.kt @@ -0,0 +1,103 @@ +package org.onebusaway.vehicletracker.ui + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.onebusaway.vehicletracker.ui.map.MAP_FILE_PLACEHOLDER +import java.io.File + +/** + * The street map assets as committed, read from `src/main/assets/map`: a style that names a font, + * a glyph range or a sprite the app does not ship draws a blank map or blank labels, and only the + * emulator would show it. + */ +class MapAssetsTest { + private val assets = File("src/main/assets/map") + private val font = "Noto Sans Regular" + + /** Basic Latin to letterlike symbols: every range a label in Latin letters can need. */ + private val latinRanges = listOf(0, 256, 512, 768, 7680, 8192, 8448) + + private val latinLabel = Json.parseToJsonElement( + """["case",["has","script"],["get","name:en"],["coalesce",["get","name:en"],["get","name"]]]""", + ) + + private fun style(flavor: String): Pair { + val text = File(assets, "style-$flavor.json").readText() + return text to Json.parseToJsonElement(text).jsonObject + } + + private fun JsonObject.layers() = getValue("layers").jsonArray.map { it.jsonObject } + + private fun JsonObject.labelLayers() = layers().mapNotNull { it["layout"]?.jsonObject?.takeIf { layout -> "text-field" in layout } } + + @Test fun `both styles read the map file through the placeholder, once`() { + for (flavor in listOf("light", "dark")) { + val (text, style) = style(flavor) + val source = style.getValue("sources").jsonObject.getValue("protomaps").jsonObject + assertEquals(flavor, "vector", source.getValue("type").jsonPrimitive.content) + assertEquals(flavor, "pmtiles://file://$MAP_FILE_PLACEHOLDER", source.getValue("url").jsonPrimitive.content) + assertEquals(flavor, 1, text.split(MAP_FILE_PLACEHOLDER).size - 1) + } + } + + @Test fun `both styles credit OpenStreetMap`() { + for (flavor in listOf("light", "dark")) { + val source = style(flavor).second.getValue("sources").jsonObject.getValue("protomaps").jsonObject + assertTrue(flavor, source.getValue("attribution").jsonPrimitive.content.contains("OpenStreetMap contributors")) + } + } + + @Test fun `every label is drawn in the one font the app ships`() { + for (flavor in listOf("light", "dark")) { + val labels = style(flavor).second.labelLayers() + assertTrue(flavor, labels.isNotEmpty()) + for (layout in labels) assertEquals(flavor, JsonArray(listOf(Json.parseToJsonElement("\"$font\""))), layout.getValue("text-font")) + } + } + + @Test fun `names are drawn in Latin letters only`() { + for (flavor in listOf("light", "dark")) { + val (text, style) = style(flavor) + assertFalse(flavor, text.contains("pgf:")) + val nameLabels = style.labelLayers().map { it.getValue("text-field") }.filter { it.toString().contains("\"name") } + assertTrue(flavor, nameLabels.isNotEmpty()) + for (field in nameLabels) assertEquals(flavor, latinLabel, field) + } + } + + @Test fun `glyphs and sprites are read from the app's own assets`() { + for (flavor in listOf("light", "dark")) { + val style = style(flavor).second + assertEquals("asset://map/fonts/{fontstack}/{range}.pbf", style.getValue("glyphs").jsonPrimitive.content) + assertEquals("asset://map/sprites/$flavor", style.getValue("sprite").jsonPrimitive.content) + } + } + + @Test fun `every glyph range a Latin label needs is there`() { + for (start in latinRanges) { + val range = File(assets, "fonts/$font/$start-${start + 255}.pbf") + assertTrue("missing $range", range.length() > 0) + } + } + + @Test fun `both sprite sheets are there, at both densities`() { + for (sheet in listOf("light", "light@2x", "dark", "dark@2x")) { + for (extension in listOf("json", "png")) { + assertTrue("missing $sheet.$extension", File(assets, "sprites/$sheet.$extension").length() > 0) + } + } + } + + @Test fun `the licences ship beside what they cover`() { + assertTrue(File(assets, "LICENSE.md").readText().contains("BSD 3-Clause")) + assertTrue(File(assets, "fonts/OFL.txt").readText().contains("SIL OPEN FONT LICENSE")) + } +} diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/ui/MapStyleTest.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/ui/MapStyleTest.kt new file mode 100644 index 0000000..0e8f01a --- /dev/null +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/ui/MapStyleTest.kt @@ -0,0 +1,34 @@ +package org.onebusaway.vehicletracker.ui + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import org.onebusaway.vehicletracker.ui.map.MAP_FILE_PLACEHOLDER +import org.onebusaway.vehicletracker.ui.map.streetStyleJson +import java.io.File + +class MapStyleTest { + private val template = """{"version":8,"sources":{"protomaps":{"type":"vector","url":"pmtiles://file://$MAP_FILE_PLACEHOLDER"}},"layers":[]}""" + + private fun sourceUrl(style: String): String = + Json.parseToJsonElement(style).jsonObject["sources"]!!.jsonObject["protomaps"]!!.jsonObject["url"]!!.jsonPrimitive.content + + @Test fun `the file's path goes into the source`() { + val file = File("/data/user/0/org.onebusaway.vehicletracker/no_backup/map/map-1.pmtiles") + + assertEquals("pmtiles://file://${file.absolutePath}", sourceUrl(streetStyleJson(template, file))) + } + + @Test fun `a path JSON has to escape still makes valid JSON with the path intact`() { + val file = File("/tmp/a \"quoted\" \\ name/map.pmtiles") + + assertEquals("pmtiles://file://${file.absolutePath}", sourceUrl(streetStyleJson(template, file))) + } + + @Test fun `a style that names no file is refused`() { + assertThrows(IllegalArgumentException::class.java) { streetStyleJson("""{"version":8}""", File("/map.pmtiles")) } + } +} diff --git a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/ui/ViewModelsTest.kt b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/ui/ViewModelsTest.kt index afb5fba..bdb8e71 100644 --- a/android/app/src/test/kotlin/org/onebusaway/vehicletracker/ui/ViewModelsTest.kt +++ b/android/app/src/test/kotlin/org/onebusaway/vehicletracker/ui/ViewModelsTest.kt @@ -25,6 +25,7 @@ import org.junit.Test import org.onebusaway.vehicletracker.data.* import org.onebusaway.vehicletracker.data.api.ApiFactory import org.onebusaway.vehicletracker.data.api.TrackerApiProvider +import org.onebusaway.vehicletracker.data.map.MapFileState import org.onebusaway.vehicletracker.engine.AdherenceEvaluator import org.onebusaway.vehicletracker.engine.TripFixtures import org.onebusaway.vehicletracker.engine.TripGeometry @@ -41,6 +42,7 @@ import org.onebusaway.vehicletracker.ui.tracking.TrackingViewModel import org.onebusaway.vehicletracker.ui.tracking.TripEnder import org.onebusaway.vehicletracker.ui.vehicles.VehicleViewModel import org.onebusaway.vehicletracker.ui.vehicles.VehiclesUiState +import java.io.File import java.io.IOException import java.time.Duration import java.time.OffsetDateTime @@ -755,6 +757,7 @@ class ViewModelsTest { /** Builds a [TrackingViewModel] on an active T1 whose server answers each end with [endResponse]. */ private fun withTrackingViewModel( endResponse: MockResponse = MockResponse().setBody("""{"status":"trip ended"}"""), + mapFiles: FakeMapFileSource = FakeMapFileSource(), block: (TrackingViewModel, TrackingRepository, FakeTripGeometryStore) -> Unit, ) { val server = MockWebServer().apply { start() } @@ -766,7 +769,7 @@ class ViewModelsTest { val geometryStore = FakeTripGeometryStore().apply { stored = TripFixtures.t1 } val tripRepository = TripRepository(provider, tripState, FakeVehiclePrefsStore(), clock = { 0L }) val tripEnder = TripEnder(tripRepository, tripState, geometryStore, FakeServiceController()) - val vm = TrackingViewModel(tracking, tripState, tripEnder) + val vm = TrackingViewModel(tracking, tripState, tripEnder, mapFiles) awaitCondition(description = "active trip loaded") { vm.uiState.value.activeTrip != null } block(vm, tracking, geometryStore) } finally { @@ -787,6 +790,22 @@ class ViewModelsTest { } } + @Test fun `the map file's state reaches the tracking screen`() = runTest(dispatcher) { + val mapFiles = FakeMapFileSource() + withTrackingViewModel(mapFiles = mapFiles) { vm, _, _ -> + assertEquals(MapFileState.None, vm.uiState.value.mapFile) + + mapFiles.state.value = MapFileState.Downloading(40, 100) + dispatcher.scheduler.advanceUntilIdle() + assertEquals(MapFileState.Downloading(40, 100), vm.uiState.value.mapFile) + + val file = File("/data/map/map-1.pmtiles") + mapFiles.state.value = MapFileState.Ready(file) + dispatcher.scheduler.advanceUntilIdle() + assertEquals(MapFileState.Ready(file), vm.uiState.value.mapFile) + } + } + @Test fun `the tracking service is told while the screen is visible`() = runTest(dispatcher) { withTrackingViewModel { vm, tracking, _ -> assertEquals(false, tracking.liveView.value) diff --git a/android/map-style/README.md b/android/map-style/README.md new file mode 100644 index 0000000..a76377f --- /dev/null +++ b/android/map-style/README.md @@ -0,0 +1,39 @@ +# Street map style + +`generate.mjs` builds the street map the Android app draws under the route, and +writes it to `android/app/src/main/assets/map/` with the fonts and icons it +reads. Those files are committed, so building the app needs neither Node nor +the network. Run this only to change them, with Node 18 or newer: + +```bash +npm ci +npm run generate +``` + +What it writes: + +- `style-light.json` and `style-dark.json`, from + [`@protomaps/basemaps`](https://github.com/protomaps/basemaps) 5.7.2 with + `lang: 'en'`, changed in two ways: every label is drawn in one font, Noto + Sans Regular, and names in Latin letters only (below). The tile source reads + `pmtiles://file://__MAP_FILE__`; the app puts the downloaded file's path + there (`MapStyle.kt`). +- `fonts/Noto Sans Regular/`: the 7 glyph ranges a label in Latin letters can + need, 0.66 MB. +- `sprites/`: the light and dark icon sheets, at 1x and 2x. +- `LICENSE.md` (the style is BSD-3-Clause, its design CC0, its icons MIT) and + `fonts/OFL.txt` (the SIL Open Font License), which have to ship with them. + +Everything is fetched from fixed commits of +[`protomaps/basemaps-assets`](https://github.com/protomaps/basemaps-assets) and +`protomaps/basemaps`, so a second run writes the same bytes. + +**Why Latin letters only.** The style Protomaps generates for English falls +back to a place's local name, in its own script, when it has no English name. +Each script needs its own font: Devanagari alone is 6.6 MB. Rather than ship +every one, or draw labels with their letters missing, the app draws the English +name, or the local name when it is written in Latin letters, and leaves other +places unlabelled. Labels in local scripts are a later step. + +The map files themselves are each agency's own: see section 12.6 of +`docs/deployment.md`. diff --git a/android/map-style/generate.mjs b/android/map-style/generate.mjs new file mode 100644 index 0000000..eea86dc --- /dev/null +++ b/android/map-style/generate.mjs @@ -0,0 +1,100 @@ +// Builds the street map style the driver app draws under the route, and copies the fonts and +// icons it reads, into ../app/src/main/assets/map/. Those files are committed, so building the +// app needs neither Node nor the network. Run this only to change them: +// +// npm ci +// npm run generate +// +// Everything is fetched from fixed commits, so running it again writes the same bytes. + +import { layers, namedFlavor } from '@protomaps/basemaps'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; + +const OUT = new URL('../app/src/main/assets/map/', import.meta.url); + +const BASEMAPS_ASSETS = 'https://raw.githubusercontent.com/protomaps/basemaps-assets/028c18f713baecad011301ff7a69acc39bcc2ae7'; +const BASEMAPS_LICENSE = 'https://raw.githubusercontent.com/protomaps/basemaps/f7bcb4869434030ba23271f43e9dbcf6bfc5d63f/LICENSE.md'; + +const FONT = 'Noto Sans Regular'; + +// The glyph ranges a label in Latin letters can need: Basic Latin and Latin-1, Latin Extended-A +// and -B, spacing modifiers, combining marks (and Greek), Latin Extended Additional (Vietnamese), +// punctuation and currency, and letterlike symbols and arrows. +const GLYPH_RANGES = [0, 256, 512, 768, 7680, 8192, 8448]; + +// MapStyle.kt replaces this with the downloaded file's path. +const MAP_FILE = '__MAP_FILE__'; + +const ATTRIBUTION = '© OpenStreetMap contributors'; + +// The English name, or the name itself when Protomaps has not marked it as written in another +// script; otherwise no label. A non-Latin script needs a font of its own, several megabytes +// each, so until there is a way to ship one per agency such places go unlabelled rather than +// being drawn with their letters missing. +const LATIN_LABEL = ['case', ['has', 'script'], ['get', 'name:en'], ['coalesce', ['get', 'name:en'], ['get', 'name']]]; + +const NAME_FIELDS = ['name', 'name:en', 'pgf:name', 'name2', 'pgf:name2', 'name3', 'pgf:name3', 'script']; + +function readsNames(expression) { + const json = JSON.stringify(expression); + return NAME_FIELDS.some((field) => json.includes(`"${field}"`)); +} + +function streetStyle(flavor) { + const style = { + version: 8, + glyphs: 'asset://map/fonts/{fontstack}/{range}.pbf', + sprite: `asset://map/sprites/${flavor}`, + sources: { + protomaps: { type: 'vector', url: `pmtiles://file://${MAP_FILE}`, attribution: ATTRIBUTION }, + }, + layers: layers('protomaps', namedFlavor(flavor), { lang: 'en' }), + }; + for (const layer of style.layers) { + const layout = layer.layout; + if (!layout || !('text-field' in layout)) continue; + if (readsNames(layout['text-field'])) layout['text-field'] = LATIN_LABEL; + layout['text-font'] = [FONT]; + } + const json = JSON.stringify(style); + // Anything left over here is a label the app has no font for. + for (const leftover of ['Noto Sans Medium', 'Noto Sans Italic', 'Devanagari', 'pgf:']) { + if (json.includes(leftover)) throw new Error(`style-${flavor}.json still mentions ${leftover}`); + } + return json; +} + +async function fetchBytes(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); +} + +async function save(path, bytes) { + const file = new URL(path, OUT); + await mkdir(new URL('.', file), { recursive: true }); + // Text files end with a newline, as the rest of the repository's do. + if (/\.(json|md|txt)$/.test(path) && bytes.at(-1) !== 0x0a) bytes = Buffer.concat([bytes, Buffer.from('\n')]); + await writeFile(file, bytes); + console.log(`${path} (${bytes.length} bytes)`); +} + +// Everything is built and fetched first, so a failed download leaves the committed assets as they were. +const files = []; +for (const flavor of ['light', 'dark']) { + files.push([`style-${flavor}.json`, Buffer.from(streetStyle(flavor) + '\n')]); + for (const sheet of [flavor, `${flavor}@2x`]) { + for (const extension of ['json', 'png']) { + files.push([`sprites/${sheet}.${extension}`, await fetchBytes(`${BASEMAPS_ASSETS}/sprites/v4/${sheet}.${extension}`)]); + } + } +} +for (const start of GLYPH_RANGES) { + const range = `${start}-${start + 255}.pbf`; + files.push([`fonts/${FONT}/${range}`, await fetchBytes(`${BASEMAPS_ASSETS}/fonts/${encodeURIComponent(FONT)}/${range}`)]); +} +files.push(['fonts/OFL.txt', await fetchBytes(`${BASEMAPS_ASSETS}/fonts/OFL.txt`)]); +files.push(['LICENSE.md', await fetchBytes(BASEMAPS_LICENSE)]); + +await rm(OUT, { recursive: true, force: true }); +for (const [path, bytes] of files) await save(path, bytes); diff --git a/android/map-style/package-lock.json b/android/map-style/package-lock.json new file mode 100644 index 0000000..848f0b1 --- /dev/null +++ b/android/map-style/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "map-style", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "map-style", + "dependencies": { + "@protomaps/basemaps": "5.7.2" + } + }, + "node_modules/@protomaps/basemaps": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.2.tgz", + "integrity": "sha512-K1Yk6bWdULulYg+R2QRVXx4NzJZan5YQhpejEG0c1/sXruJrfPIPZuakpf3jwAgVmjIRVQwAv+yRafDeN0aaUQ==", + "license": "BSD-3-Clause", + "bin": { + "generate_style": "src/cli.ts" + } + } + } +} diff --git a/android/map-style/package.json b/android/map-style/package.json new file mode 100644 index 0000000..26a1a04 --- /dev/null +++ b/android/map-style/package.json @@ -0,0 +1,11 @@ +{ + "name": "map-style", + "private": true, + "type": "module", + "scripts": { + "generate": "node generate.mjs" + }, + "dependencies": { + "@protomaps/basemaps": "5.7.2" + } +} diff --git a/docs/android-smoke-test.md b/docs/android-smoke-test.md index e0f3215..7dbfeb7 100644 --- a/docs/android-smoke-test.md +++ b/docs/android-smoke-test.md @@ -376,9 +376,10 @@ then start `T1` as in Check 1 (it is badged **Next** until `$ST1`). ### Check 2c: The map draws the trip and follows the vehicle The Tracking screen draws the run on a map between the adherence panel and the -trip counters: spec §6.3 of the iOS design, with the iOS map's sizes. There is -no street map under it yet, only a plain background. Run this with `T1` started -and the drive loop from Check 2b going. +trip counters: spec §6.3 of the iOS design, with the iOS map's sizes. With +`MAP_PMTILES_URL` unset, as in step 1, it draws on a plain background; Check 2d +adds the street map. Run this with `T1` started and the drive loop from Check 2b +going. 1. Watch the map from the moment the trip starts, through the detour, to ST3. 2. Partway along, drag the map sideways with one finger, then leave it alone. @@ -420,6 +421,39 @@ On the emulator the map stays north-up: `adb emu geo fix` sends a position with no course. On a phone it turns to keep the direction of travel pointing up, and holds the last direction while the vehicle stands still. +### Check 2d: The street map downloads, and works offline + +This needs the [`pmtiles`](https://github.com/protomaps/go-pmtiles) tool. Cut a +small map around the fixture's route from a Protomaps build (pick a date listed +at [maps.protomaps.com/builds](https://maps.protomaps.com/builds)), serve it from +your machine, and restart the server from step 1 pointing at it. The debug build +may fetch it over plain `http` from `10.0.2.2`; a release build needs `https`. + +```bash +pmtiles extract https://build.protomaps.com/.pmtiles /tmp/smoke.pmtiles \ + --bbox=-122.36,47.59,-122.30,47.62 --maxzoom=15 +(cd /tmp && python3 -m http.server 8092 &) +export MAP_PMTILES_URL=http://10.0.2.2:8092/smoke.pmtiles +# stop the server from step 1, then start it again the same way +``` + +With `T1` started and the drive loop from Check 2b going, switch to another app +and back, so the app asks the server for the map. Then turn on airplane mode, +force-stop the app, open it again and resume the shift. + +**Expected outcome:** + +- **While it downloads:** "Downloading map" and a percentage at the top left of + the map, over the plain background. +- **Once it is done:** streets, road names and places appear under the route + without the map jumping, and the vehicle stays a third of the way up. Street + names are drawn over the route line; the stops, the next stop's name and the + vehicle are drawn over the street names. An ⓘ at the bottom left opens + "© OpenStreetMap contributors". +- **Offline:** after the restart in airplane mode the banner says "No + connection", and the streets are all still there. +- **Dark theme** (Check 2c, step 4): the streets turn dark too. + ### Check 3 — Network loss flips the status red, recovery flips it back green 1. Disable networking on the device/emulator (physical airplane mode, or on diff --git a/docs/configuration.md b/docs/configuration.md index 54761ed..9ec7698 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -81,10 +81,10 @@ Rider mode, when on, verifies riders against the same schedule. ## Driver app map -Optional, and not used yet: the Android app does not download the map file, so -for now this only sets what `GET /api/v1/map` returns. The file is a PMTiles -map of the agency's service area, hosted wherever the agency likes; the server -never downloads it. +Optional. The PMTiles file the driver app downloads for its offline street +map, hosted wherever the agency likes; the server never downloads it. +[Deployment §12.6](deployment.md#126-offline-street-map) covers making and +hosting one. Unset, the app draws the route on a plain background. | Variable | Default | Status | Purpose | |---|---|---|---| diff --git a/docs/deployment.md b/docs/deployment.md index 56124a1..ee148bc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -84,7 +84,7 @@ Rules that apply to the whole table: | Variable | Default | Purpose | |---|---|---| -| `MAP_PMTILES_URL` | unset | Not used yet: the Android app does not download the file, so for now this only sets what `GET /api/v1/map` returns. Set it to an `https` URL of a PMTiles map file covering your service area, hosted wherever you like; the server never fetches the file. A value that is not an `http(s)` URL with a host, or that contains a username or password, stops the server at startup. Plain `http` is accepted with a warning, but Android release builds refuse to download over it. | +| `MAP_PMTILES_URL` | unset | The street map the driver app shows under the route: an `https` URL of a PMTiles file covering your service area, hosted wherever you like. Section [12.6](#126-offline-street-map) says how to make and host one. The server never fetches the file; it hands the URL to drivers at `GET /api/v1/map`. Unset, the app draws the route on a plain background. A value that is not an `http(s)` URL with a host, or that contains a username or password, stops the server at startup. Plain `http` is accepted with a warning, but Android release builds refuse to download over it. | ### Admin UI and proxying @@ -819,7 +819,8 @@ targets Java 17, so build with a JDK 17 toolchain. The output is but defines no `signingConfig`, so signing is the agency's job. Android will not install an unsigned APK. -The APK is about 20 MB, nearly all of it the map library. The release build +The APK is about 21 MB, nearly all of it the map library. The street map is not +in it: each phone downloads your map file (section 12.6). The release build carries that library for ARM phones only (`arm64-v8a` and `armeabi-v7a`), which halves the download; it will not install on an x86 emulator, where the debug build is the one to use. The map needs OpenGL ES 3.0. On a phone without it the @@ -929,3 +930,47 @@ which build a driver is running when you are debugging a report from the field. Then tell drivers three things: the server URL, their email, and their password. The app's [smoke-test walkthrough](android-smoke-test.md) is a useful script for verifying a new build end to end before you distribute it. + +### 12.6 Offline street map + +The Tracking screen can draw the trip over a street map that works with no +signal: the app downloads one map file of your service area and reads it from +the phone. Without one it draws the route on a plain background. You host the +file; the server only tells the app where it is (`MAP_PMTILES_URL`, section 3). + +**Make the file.** Cut your area out of a [Protomaps](https://protomaps.com) +daily build with the [`pmtiles`](https://github.com/protomaps/go-pmtiles) tool: + +```bash +pmtiles extract https://build.protomaps.com/.pmtiles agency.pmtiles \ + --bbox=,,, --maxzoom=15 +``` + +Builds are listed at [maps.protomaps.com/builds](https://maps.protomaps.com/builds) +and each is kept for about a week. It has to be a Protomaps build: the app's +style is made for its layers, and any other file draws a blank map. In October +2026 a city like Seattle came to about 22 MB and all of King County Metro's +service area to 93 MB. `--maxzoom=14` makes the file about 60% smaller, with a +little less detail when fully zoomed in: worth it when drivers use mobile data. + +**Host it.** Any HTTPS host that serves a static file works: your own web +server, object storage, a release asset. It has to send an `ETag` or +`Last-Modified` header and answer byte-range requests, as common hosts do; then +a download cut off part-way carries on where it stopped. Set `MAP_PMTILES_URL` +to the file's URL and restart the server. + +**What phones do.** Once the driver has signed in, the app downloads the file +in the background, on any connection, and shows "Downloading map" on the +Tracking screen until it is done. It needs the file's size plus 50 MB free, and +says so when the phone is too full. Once a day it asks your host whether the +file has changed. To update the map, replace the file at the same URL: within a +day phones fetch the new version quietly and show it the next time the Tracking +screen opens. To drop the map, unset `MAP_PMTILES_URL`: within a day phones +learn it is gone, the Tracking screen goes back to the plain background the next +time it opens, and the file is deleted the next time the app starts. A Tracking +screen that is open at the time keeps what it shows until then. + +Street names are drawn in Latin letters only: the English name where +OpenStreetMap has one, or else the local name when it is written in Latin +letters. A place named only in another script, such as Devanagari, has no label +for now.