commit f1563c9890d31b66909fb250a50afff063ef0290 Author: divkit-github <111018801+divkit-github@users.noreply.github.com> Date: Wed Aug 10 23:43:52 2022 +0300 chore(html-reporter): up version Initial commit Add notification for divkit Allow overwrite for migration [migration] browser/bitbucket/personal/booster/divkit Note: mandatory check (NEED_CHECK) was skipped DivKit files moved dIVKIT-0: Fix ts builder build update div-gallery visibility screenshots update div-gallery visibility screens a.yaml cleanup updated codegen scripts change gallery alignment change internal types in expressions removed copyrights remove permissions TemplatesSupportTests refactoring Ничего не поменялось. Отрефакторил и почистил тесты. Split package.json send all public removed yandex-team links URL added type as ArgumentType added camera permission description safe area insets for errors count view `EdgeInsets` for debug view with errors count. Needed for fullscreen divs. Naming corresponds to mapping `EdgeInsets` to div-variables. Will be used here: https://nda.ya.ru/t/Lhr5Lxgb5KN6nX fixed compiler warnings fix DivKitComponents variables setup, add update func fix DivKitComponents variables setup, add update func authors clean up Удалил коллег из инфраструктуры, тестирования и мессенджера. Половину людей я вообще тут не знаю. Список какой-то косячный, в нём, например, @booster и @askvortsov нет. Предлагаю заново вручную составить и записать туда тех, кто точно дивкитом занимался, а не просто мимо проходил. Fix DivKit pods compilability Fix DivKit pods compilability use only english in demo app translate divKit demo app russian text Scroll when keyboard opened Scroll when keyboard opened Fix gallery scroll in a11y Это копия пр-а, который я делал в репо алискита: https://bitbucket.browser.yandex-team.ru/projects/ML/repos/mobile-alice-library-android/pull-requests/18808/overview authors clean up2 SwiftGenerator flake8 errors fix & ya.make edit swift generation tests swift references swift generator implementation init swift generator code refactoring 1) rename to modeling 2) rename kind to PropertyType 3) delete apply_pluralization_rules 4) description default value is empty string 5) remove entity_type_field_alias, typename_alias, client_optional, profileEntities, ignore_properties, generate_plain_constructor 6) remove client prefix Add XcodeBuild in ignore Add XcodeBuild in ignore introduce new global variable api introduce new global variable api Revert commit bdcbc0081395ebe44ed7fe24bf5f4b25569c6df0 LayoutKitSnapshotTests Fix sandboxRelease dependencies. support zero value for width and height remove unused css variables "CONTRIBUTING.md" README draft updated gitignore/arcignore introduce color type in expressions introduce color type in expressions added xcconfig files fixed swift formatting fail tests if updating references Чтобы уж точно не забыть вернуть False. Layout changes Fix expression parsing don't change clickable state in a11y don't change clickable state in a11y Update items checks Parse description references to translations add licence file link fixed expression properties generation add ability to track requests to global variables Добавляем в GlobalVariableController возможность подписаться на запросы к глобальным переменным. Нужно дляОткрыт Накопительная палитра , а так же в целом полезная функциональность, через которую можно делать ленивый прогрев глобальных переменных Changelog Minor fixes bump divkit version up to 3.0.0 fixed snapshot tests depdendencies Иногда не запускалась сборка SnapshotTestsHostApp перед стартом тестов. used new api-generator deserialization refactoring removed gitignore samples text fixes fixed samples text remove theme button remove theme button demo activity redesign demo activity redesign habr sample app change deleted images in screenshot tests change deleted images in screenshot tests new ic_launcher new ic_launcher more readme Binding variable for div input Biding variable for div input Implement documentation generator Rename max_lines to max_visible_lines Rename max_lines to max_visible_lines add habr and medium fix div-functions doc Fix link in description Text selecting for demo app enabled Text selecting for demo app enabled KotlinGenerator kotlin code generation kotlin generation tests kotlin generation references add gradle wrapper to kotlin json builder add gradle wrapper to kotlin json builder DemoApp redesign fixed codegen scripts debugView safeAreaInsets DebugView safe area insets TypeScript Generator ya.make changes TypeScript generator TypeScript tests TypeScript test references fix enumeration aliases debugInfoParams fix compile Fix black box flicker in video-custom Fixed stub image showing by moving it away from PlayerView. Increased stub loading speed by adding VideoViewModel warmup. Fixed redundant redraws by combining stub visibility and bitmap data. PlayerView background was set to transparent. DivExpression fix DivExpression fix Implement translations Save & share bind globalVariableController to divContext bind variableController to divContext used new TypeScript generator Подключил генератор на питоне. Изменения в сгенерированных файлах связаны в основном с переводом описаний на английский язык. moved LivePreview Перенёс LivePreview из отдельного модуля в DivKitDemoApp. Добавил сканер кодов в UrlInputView. Удалил часть дублирующегося кода. remove sms and phone_state permission fix links interception fix links interception Fix link in documentation remove site from "config.stable.json" Support highlight_color and keyboardType in div input Support highlight_color in div input Action animation fix for iOS diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..a40967c1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +.gradle +.idea +.swiftpm +.vscode +build/ +XcodeBuild/ +xcuserdata/ + +local.properties diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 000000000..148f632c5 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,49 @@ +The following authors have created the source code of "DivKit" published and distributed by YANDEX LLC as the owner: +4eb0da +akhmetsafin +bgubanov +chris-benua +edubinskaya +geaden +ggys +grechka62 +gulevsky +i-ts +ivanovsky-v +morevsavva +nikita-kladov +nkhodyunya +pkurchatov +rnkorneeva +tayrinn +timatifey +ttok +valeriahil +ventywing +yagamayunov +yboyarinov + +The following authors have licensed their contributions to YANDEX LLC and everyone who uses "DivKit" under the licensing terms detailed in LICENSE available at https://github.com/divkit/divkit/blob/main/LICENSE. +4eb0da <4eb0da@yandex-team.ru> +akhmetsafin +bgubanov +chris-benua +edubinskaya +geaden +ggys +grechka62 +gulevsky +i-ts +ivanovsky-v +morevsavva +nikita-kladov +nkhodyunya +pkurchatov +rnkorneeva +tayrinn +timatifey +ttok +valeriahil +ventywing +yagamayunov +yboyarinov diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..267004efc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Notice to external contributors + + +## General info + +Hello! In order for us (YANDEX LLC) to accept patches and other contributions from you, you will have to adopt our Yandex Contributor License Agreement (the **CLA**). The current version of the CLA can be found here: +1) https://yandex.ru/legal/cla/?lang=en (in English) and +2) https://yandex.ru/legal/cla/?lang=ru (in Russian). + +By adopting the CLA, you state the following: + +* You obviously wish and are willingly licensing your contributions to us for our open source projects under the terms of the CLA, +* You have read the terms and conditions of the CLA and agree with them in full, +* You are legally able to provide and license your contributions as stated, +* We may use your contributions for our open source projects and for any other our project too, +* We rely on your assurances concerning the rights of third parties in relation to your contributions. + +If you agree with these principles, please read and adopt our CLA. By providing us your contributions, you hereby declare that you have already read and adopt our CLA, and we may freely merge your contributions with our corresponding open source project and use it in further in accordance with terms and conditions of the CLA. + +## Provide contributions + +If you have already adopted terms and conditions of the CLA, you are able to provide your contributions. When you submit your pull request, please add the following information into it: + +``` +I hereby agree to the terms of the CLA available at: [link]. +``` + +Replace the bracketed text as follows: +* [link] is the link to the current version of the CLA: https://yandex.ru/legal/cla/, https://yandex.ru/legal/cla/?lang=en (in English) or https://yandex.ru/legal/cla/?lang=ru (in Russian). + +It is enough to provide us such notification once. + +## Other questions + +If you have any questions, please mail us at opensource@yandex-team.ru. diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..3a5274b25 --- /dev/null +++ b/LICENSE @@ -0,0 +1,400 @@ +Copyright 2022 YANDEX LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +This software uses the following dependencies. Their respective licensing terms and copyright notices are given below. Some of the dependencies belong to the same rightsholders and are licensed under the same terms, thus, for convenience, such dependencies are grouped and have only one copyright notice and one license notice applicable to them all. + +androidx.appcompat:appcompat +androidx.core:core +androidx.core:core-ktx +androidx.recyclerview:recyclerview +androidx.test:core +androidx.test:core-ktx +androidx.collection:collection +androidx.vectordrawable:vectordrawable +androidx.exifinterface:exifinterface +androidx.annotation:annotation +androidx.transition:transition +androidx.viewpager2:viewpager2 +androidx.lifecycle:lifecycle-runtime-ktx +androidx.constraintlayout:constraintlayout +androidx.emoji:emoji + +Copyright (c) 2022 Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +com.google.dagger:dagger +com.google.dagger:dagger-compiler + +Copyright (c) 2016 Google Inc. +Copyright (c) 2016 Square Inc. + +Licensed under the Apache License, Version 2.0 (the "License") +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +javax.inject:javax.inject + +Copyright (c) 2009 The JSR-330 Expert Group + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +org.jetbrains.kotlin:kotlin-reflect + +Copyright (c)2010-2017 JetBrains s.r.o. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +org.json:json + +JSON License + +Copyright (c) 2002 JSON.org + +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 shall be used for Good, not Evil. + +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. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +com.squareup.okhttp3:okhttp + +Copyright (c) 2012 Square, Inc. +Copyright (c) 2012 The Android Open Source Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +com.google.android.material:material + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +com.google.code.findbugs:jsr305 + +Copyright (с) 2006 FindBugs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +com.google.auto.service:auto-service + +Copyright (c) 2008 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +com.google.auto.service:auto-service-annotations + +Copyright (c) 2013 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +org.hamcrest:hamcrest-junit + +Copyright (c) 2014 Nat Pryce +Copyright (c) 2015 Joseph Walton +Copyright (c) 2015 Lars Vogel + +License: Eclipse Public License - v 1.0 + +The source code is available at: https://github.com/hamcrest/hamcrest-junit + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +junit:junit + +Copyright (c) 2002-2021 JUnit + +License: Eclipse Public License - v 1.0 + +The source code is available at: https://github.com/junit-team/junit5 + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +org.robolectric:robolectric + +MIT License + +Copyright (c) 2010 Xtreme Labs, Pivotal Labs and Google Inc. + +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. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +org.robolectric:shadows-supportv4 + +MIT License + +Copyright (c) 2018 Nimble + +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. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +org.easytesting:fest-reflect + +Copyright (c) 2009 Alex Ruiz + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +com.squareup.moshi:moshi + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +com.squareup:fest-android + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +org.mockito:mockito-core + +MIT License + +Copyright (c) 2007 Mockito contributors + +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. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +org.mockito.kotlin:mockito-kotlin + +MIT License + +Copyright (c) 2016 Niek Haarman +Copyright (c) 2007 Mockito contributors + +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. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +io.strikt:strikt-core + +Copyright (c) 2014 Netflix + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +org.jetbrains.kotlinx:kotlinx-coroutines-core +org.jetbrains.kotlinx:kotlinx-coroutines-test +org.jetbrains.kotlinx:kotlinx-coroutines-android + +Copyright (c)2000-2020 JetBrains s.r.o. and Kotlin Programming Language contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 000000000..3070a6ddc --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# DIVKIT + +**DivKit** 🐋 is an open source Server-Driven UI (SDUI) framework. +SDUI is a an emerging technique that leverage the server to build the user interfaces of their mobile apps. + +The convenience of DivKit as a server-driven UI technology is that it doesn’t require much at the start. You can transfer a view or even a particular element (such as a card or button) to Divs. At the very beginning, even a server isn’t needed: DivKit is easy to use as a framework that facilitates development. You can include all data on the client-side, and when the server response mechanism becomes available, the app can start receiving updates right away. + +We’ve made a sandbox for you to experiment with. The DivKit web engine is connected to it, but you can also download iOS/Android app demos from stores and link them to the sandbox. The data will be updated live: the sandbox connects to the playground app via web sockets. + +[Documentation](https://divkit.tech/doc). [Medium](https://medium.com/p/cad519252f0f). [Habr](https://habr.com/ru/company/yandex/blog/683886/). + +Telegram: [News](https://t.me/divkit_news) | [English-speaking chat](https://t.me/divkit_community_en) | [Чат на русском](https://t.me/divkit_community_ru). + +[Twitter](https://twitter.com/divkit) + +Project structure: + +## Api Generator +Used for generating general Api for all platforns from Schema. + +## Client +Several libraries for rendering UIs on Android, iOS and Web. + +## Json-builder +Libraries that build JSON in DivKit format. + +## Schema +JSON schema that describes DivKit data format. + +## Test data +Samples and test data. diff --git a/api_generator/.arcignore b/api_generator/.arcignore new file mode 100644 index 000000000..256041143 --- /dev/null +++ b/api_generator/.arcignore @@ -0,0 +1 @@ +/tests/output/ diff --git a/api_generator/.gitignore b/api_generator/.gitignore new file mode 100644 index 000000000..256041143 --- /dev/null +++ b/api_generator/.gitignore @@ -0,0 +1 @@ +/tests/output/ diff --git a/api_generator/README.md b/api_generator/README.md new file mode 100644 index 000000000..7d4d9e744 --- /dev/null +++ b/api_generator/README.md @@ -0,0 +1,49 @@ +# DivKit API Generator + +The script takes a JSON schema at the input and generates the code of objects described in the schema based on it and the config. + +## Config format: + +```json +{ + "lang": "[kotlin|kotlinDsl|swift|typescript|python|documentation]", + "header": "// Header of every generated file. Generated code. Do not modify.\nimport some.lib\nimport other.lib\n\n" +} +``` + +## Commands + +### With ya make + +Build: +```shell +ya make +``` + +Run generator: +```shell +./api_generator -c /config/file/name.json -s /path/to/schema -o /output/path +``` + +Run tests: +```shell +ya make -t +``` + +### Without ya make + +Setup environment: +```shell +pip install -r requirements.txt +pip install -r dev_requirements.txt +``` + +Run generator: +```shell +python -m api_generator -c /config/file/name.json -s /path/to/schema -o /output/path +``` + +Run tests: +```shell +python -m pytest +``` diff --git a/api_generator/api_generator/__init__.py b/api_generator/api_generator/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api_generator/api_generator/__main__.py b/api_generator/api_generator/__main__.py new file mode 100644 index 000000000..5d17caf0c --- /dev/null +++ b/api_generator/api_generator/__main__.py @@ -0,0 +1,51 @@ +from typing import Tuple +from argparse import ArgumentParser +import os + +from .generator import generate_api +from .config import Config + + +def __file_path(path) -> str: + if os.path.isfile(path): + return path + else: + raise FileNotFoundError(path) + + +def __dir_path(path: str) -> str: + if os.path.isdir(path): + return path + else: + raise NotADirectoryError(path) + + +def __dir_path_with_makedir(path: str) -> str: + try: + return __dir_path(path) + except NotADirectoryError: + os.makedirs(path) + return __dir_path(path) + + +def __parse_arguments() -> Tuple[str, str, str]: + parser = ArgumentParser(description='The script takes a JSON schema at the input and generates the code of ' + 'objects described in the schema based on it and the config.') + parser.add_argument('-c', '--config', type=__file_path, help='Path to config file', required=True) + parser.add_argument('-s', '--schema', type=__dir_path, help='Path to schema directory', required=True) + parser.add_argument('-o', '--output', + type=__dir_path_with_makedir, + help='Path to generator output directory', + required=True) + parsed_args = parser.parse_args() + return parsed_args.config, parsed_args.schema, parsed_args.output + + +def main(): + config_path, schema_path, output_path = __parse_arguments() + config = Config(config_path, schema_path, output_path) + generate_api(config) + + +if __name__ == '__main__': + main() diff --git a/api_generator/api_generator/config.py b/api_generator/api_generator/config.py new file mode 100644 index 000000000..20080b56b --- /dev/null +++ b/api_generator/api_generator/config.py @@ -0,0 +1,66 @@ +from __future__ import annotations +from typing import Text, List, Optional +from pydantic import BaseModel, Field +from enum import Enum, auto + + +class Platform(str, Enum): + ANDROID = 'android' + IOS = 'ios' + WEB = 'web' + + +class DescriptionLanguage(str, Enum): + EN = 'en' + RU = 'ru' + + +class GeneratedLanguage(str, Enum): + SWIFT = 'swift' + KOTLIN = 'kotlin' + KOTLIN_DSL = 'kotlinDsl' + TYPE_SCRIPT = 'typescript' + PYTHON = 'python' + DOCUMENTATION = 'documentation' + + +TEMPLATE_SUFFIX = '_template' + + +class GenerationMode(Enum): + NORMAL_WITH_TEMPLATES = auto() + NORMAL_WITHOUT_TEMPLATES = auto() + TEMPLATE = auto() + + @property + def is_template(self) -> bool: + if self in [GenerationMode.NORMAL_WITH_TEMPLATES, GenerationMode.NORMAL_WITHOUT_TEMPLATES]: + return False + elif self is GenerationMode.TEMPLATE: + return True + + @property + def name_suffix(self) -> str: + if self in [GenerationMode.NORMAL_WITH_TEMPLATES, GenerationMode.NORMAL_WITHOUT_TEMPLATES]: + return '' + elif self is GenerationMode.TEMPLATE: + return TEMPLATE_SUFFIX + + def protocol_name(self, lang: GeneratedLanguage, name: str) -> Optional[str]: + if self.is_template: + return 'TemplateValue' if lang is GeneratedLanguage.SWIFT else f'JsonTemplate<{name}>' + return None + + +class Config: + class GenerationConfig(BaseModel): + lang: GeneratedLanguage + header: Text = '' + errors_collectors: List[str] = Field([], alias='errorsCollectors') + kotlin_annotations: List[str] = Field([], alias='kotlinAnnotations') + generate_equality: bool = Field(False, alias='generateEquality') + + def __init__(self, config_path: str, schema_path: str, output_path: str): + self.schema_path: str = schema_path + self.output_path: str = output_path + self.generation: Config.GenerationConfig = Config.GenerationConfig.parse_file(path=config_path) diff --git a/api_generator/api_generator/generator.py b/api_generator/api_generator/generator.py new file mode 100644 index 000000000..830d71ff9 --- /dev/null +++ b/api_generator/api_generator/generator.py @@ -0,0 +1,26 @@ +from .config import Config, GeneratedLanguage +from .schema.preprocessing import schema_preprocessing +from .schema.modeling import build_objects +from .generators import Generator, SwiftGenerator, KotlinGenerator, DocumentationGenerator, TypeScriptGenerator + + +def __build_generator(config: Config) -> Generator: + lang = config.generation.lang + generator_dict = { + GeneratedLanguage.SWIFT: SwiftGenerator, + GeneratedLanguage.KOTLIN: KotlinGenerator, + GeneratedLanguage.DOCUMENTATION: DocumentationGenerator, + GeneratedLanguage.TYPE_SCRIPT: TypeScriptGenerator, + } + generator = generator_dict.get(lang, None) + if generator is None: + raise NotImplementedError + return generator(config) + + +def generate_api(config: Config): + root_directory = schema_preprocessing(config) + objects = build_objects(root_directory, config.generation) + + generator = __build_generator(config) + generator.generate(objects) diff --git a/api_generator/api_generator/generators/__init__.py b/api_generator/api_generator/generators/__init__.py new file mode 100644 index 000000000..091921fc4 --- /dev/null +++ b/api_generator/api_generator/generators/__init__.py @@ -0,0 +1,7 @@ +from .base import Generator +from .swift import SwiftGenerator +from .kotlin import KotlinGenerator +from .documentation import DocumentationGenerator +from .type_script import TypeScriptGenerator + +__all__ = [Generator, SwiftGenerator, KotlinGenerator, DocumentationGenerator, TypeScriptGenerator] diff --git a/api_generator/api_generator/generators/base.py b/api_generator/api_generator/generators/base.py new file mode 100644 index 000000000..efc1e799a --- /dev/null +++ b/api_generator/api_generator/generators/base.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import os.path +from abc import ABC, abstractmethod +from typing import List + +from ..config import Config +from ..schema.modeling.entities import ( + Declarable, + Entity, + EntityEnumeration, + StringEnumeration, + Property, + String, + Array, Url, Int, Double, +) +from ..schema.modeling.text import Text +from .. import utils + + +def declaration_comment(p: Property, default_value_comment_fun) -> str: + comments = [] + if isinstance(p.property_type, String): + string: String = p.property_type + if string.min_length > 0: + end = 's' if string.min_length > 1 else '' + comments.append(f'at least {string.min_length} char{end}') + if string.regex is not None: + comments.append(f'regex: {string.regex.pattern}') + elif isinstance(p.property_type, Array): + array: Array = p.property_type + if array.min_items > 0: + comments.append(f'at least {array.min_items} elements') + if array.strict_parsing: + comments.append('all received elements must be valid') + elif isinstance(p.property_type, Url) and p.property_type.schemes: + joined_schemes = ', '.join(p.property_type.schemes) + comments.append(f'valid schemes: [{joined_schemes}]') + elif isinstance(p.property_type, (Int, Double)): + constraint = p.property_type.constraint + if constraint is not None: + comments.append(f'constraint: {constraint}') + + if p.default_value is not None: + comments.append(f'default value: {default_value_comment_fun(p)}') + + if not comments: + return '' + joined_comments = '; '.join(comments) + return f' // {joined_comments}' + + +class Generator(ABC): + def __init__(self, config: Config) -> None: + self._config = config + self._output_path = config.output_path + + def generate(self, objects: List[Declarable]): + self._clear_output_directory() + self._generate_files(objects) + + def _clear_output_directory(self): + utils.clear_content_of_directory(self._output_path) + + def _generate_files(self, objects: List[Declarable]): + for obj in objects: + declaration = [] + for line in str(self._main_declaration(obj)).strip().split('\n'): + if line.isspace(): + declaration.append('') + else: + declaration.append(line) + declaration = '\n'.join(declaration).strip() + if not declaration: + continue + head_for_file = self._head_for_file + '\n' if self._head_for_file.strip() else '' + file_content = f'{head_for_file}{declaration}\n' + filename = os.path.join(self._output_path, self._filename(obj.name)) + with open(filename, 'w') as file: + file.write(file_content) + + @property + def _head_for_file(self) -> str: + return self._config.generation.header + + def _main_declaration(self, obj: Declarable) -> Text: + if isinstance(obj, Entity): + return self._entity_declaration(obj) + elif isinstance(obj, EntityEnumeration): + return self._entity_enumeration_declaration(obj) + elif isinstance(obj, StringEnumeration): + return self._string_enumeration_declaration(obj) + else: + raise NotImplementedError + + @abstractmethod + def _filename(self, name: str) -> str: + pass + + @abstractmethod + def _entity_declaration(self, entity: Entity) -> Text: + pass + + @abstractmethod + def _entity_enumeration_declaration(self, entity_enumeration: EntityEnumeration) -> Text: + pass + + @abstractmethod + def _string_enumeration_declaration(self, string_enumeration: StringEnumeration) -> Text: + pass diff --git a/api_generator/api_generator/generators/documentation/__init__.py b/api_generator/api_generator/generators/documentation/__init__.py new file mode 100644 index 000000000..47bcbf5cd --- /dev/null +++ b/api_generator/api_generator/generators/documentation/__init__.py @@ -0,0 +1,3 @@ +from .generator import DocumentationGenerator + +__all__ = [DocumentationGenerator] diff --git a/api_generator/api_generator/generators/documentation/documentation_entities.py b/api_generator/api_generator/generators/documentation/documentation_entities.py new file mode 100644 index 000000000..214a251a2 --- /dev/null +++ b/api_generator/api_generator/generators/documentation/documentation_entities.py @@ -0,0 +1,376 @@ +from abc import abstractmethod +from typing import cast, Dict, List, Union + +from .utils import code, content_in_quotes +from ...schema.modeling.entities import ( + PropertyType, + Property, + Declarable, + Entity, + EntityEnumeration, + StringEnumeration, + Array, + Bool, + BoolInt, + Color, + Dictionary, + Double, + Int, + Object, + ObjectFormat, + StaticString, + String, + Url +) +from ...schema.modeling.text import Text + + +class DocumentationPropertyType(PropertyType): + + @property + @abstractmethod + def description(self) -> str: + pass + + def json_description(self, obj_stack: List[Declarable], prefix: str, suffix: str) -> Text: + return content_in_quotes(prefix, self.description, suffix) + + @abstractmethod + def constraints(self, dictionary: Dict[str, str]) -> str: + pass + + def details(self, dictionary: Dict[str, str]) -> str: + return '' + + +class DocumentationProperty(Property): + + def update_base(self): + update_property_type_base(self.property_type) + + @property + def property_type_doc(self) -> DocumentationPropertyType: + return cast(DocumentationPropertyType, self.property_type) + + def json_description_prefix(self, parent: Entity, property_type: PropertyType) -> str: + if isinstance(property_type, Array): + return self.json_description_prefix(parent, property_type.property_type) + return f'{self.dict_field}{"" if self.optional else "*"}: ' + + +class DocumentationDeclarable(Declarable): + @abstractmethod + def json_description(self, obj_stack: List[Declarable], prefix: str = '', suffix: str = '') -> Text: + pass + + @property + @abstractmethod + def include_in_menu_file(self) -> bool: + pass + + +def update_declarable_base(obj: Declarable) -> DocumentationDeclarable: + if isinstance(obj, Entity): + obj.__class__ = DocumentationEntity + obj = cast(DocumentationEntity, obj) + obj.update_base() + return obj + elif isinstance(obj, EntityEnumeration): + obj.__class__ = DocumentationEntityEnumeration + return cast(DocumentationEntityEnumeration, obj) + elif isinstance(obj, StringEnumeration): + obj.__class__ = DocumentationStringEnumeration + return cast(DocumentationStringEnumeration, obj) + else: + raise NotImplementedError + + +def update_property_type_base(property_type: PropertyType): + if isinstance(property_type, Array): + property_type.__class__ = DocumentationArray + property_type = cast(DocumentationArray, property_type) + property_type.update_base() + elif isinstance(property_type, Bool): + property_type.__class__ = DocumentationBool + elif isinstance(property_type, BoolInt): + property_type.__class__ = DocumentationBoolInt + elif isinstance(property_type, Color): + property_type.__class__ = DocumentationColor + elif isinstance(property_type, Dictionary): + property_type.__class__ = DocumentationDictionary + elif isinstance(property_type, Double): + property_type.__class__ = DocumentationDouble + elif isinstance(property_type, Int): + property_type.__class__ = DocumentationInt + elif isinstance(property_type, Object): + property_type.__class__ = DocumentationObject + property_type = cast(DocumentationObject, property_type) + property_type.update_base() + elif isinstance(property_type, StaticString): + property_type.__class__ = DocumentationStaticString + elif isinstance(property_type, String): + property_type.__class__ = DocumentationString + elif isinstance(property_type, Url): + property_type.__class__ = DocumentationUrl + else: + raise NotImplementedError + + +class DocumentationEntity(Entity, DocumentationDeclarable): + + def update_base(self): + for prop in self.properties: + prop.__class__ = DocumentationProperty + prop = cast(DocumentationProperty, prop) + prop.update_base() + + @property + def display_name(self) -> str: + return self._display_name + + @property + def is_deprecated(self) -> bool: + return self._is_deprecated + + @property + def properties_doc(self) -> List[DocumentationProperty]: + return cast(List[DocumentationProperty], self.properties) + + def json_description(self, obj_stack: List[Declarable], prefix: str = '', suffix: str = '') -> Text: + if self in obj_stack: + return Text(prefix + self.display_name + suffix) + + result = Text(prefix + '{') + last_index = len(self.properties_doc) - 1 + for index, prop in enumerate(sorted( + self.properties_doc, + key=lambda p: not isinstance(p.property_type, StaticString) + )): + result += prop.property_type_doc.json_description( + obj_stack=obj_stack + [self], + prefix=prop.json_description_prefix(parent=self, property_type=prop.property_type), + suffix='' if index == last_index else ',' + ).indented() + result += '}' + suffix + return result + + def include_in_menu_file(self): + return not self.generate_as_protocol + + +class DocumentationEntityEnumeration(EntityEnumeration, DocumentationDeclarable): + + def json_description(self, obj_stack: List[Declarable], prefix: str = '', suffix: str = '') -> Text: + return Text(prefix + self.name + suffix) + + def include_in_menu_file(self) -> bool: + return True + + +class DocumentationStringEnumeration(StringEnumeration, DocumentationDeclarable): + + def json_description(self, obj_stack: List[Declarable], prefix: str = '', suffix: str = '') -> Text: + return content_in_quotes(prefix, 'string', suffix) + + def include_in_menu_file(self) -> bool: + return False + + +class DocumentationArray(Array, DocumentationPropertyType): + + def update_base(self): + if not isinstance(self.property_type, DocumentationPropertyType): + update_property_type_base(self.property_type) + + @property + def description(self) -> str: + return 'array' + + @property + def __property_type_doc(self) -> DocumentationPropertyType: + return cast(DocumentationPropertyType, self.property_type) + + def json_description(self, + obj_stack: List[Declarable], + prefix: str, + suffix: str) -> Text: + array_type_description = self.__property_type_doc.json_description(obj_stack=obj_stack, prefix='', suffix=',') + if len(array_type_description.lines) > 1: + result = Text(prefix + '[') + result += array_type_description.indented() + result += ' ...' + result += ']' + suffix + return result + return self.__property_type_doc.json_description( + obj_stack=obj_stack, + prefix=prefix + '[ ', + suffix=', ... ]' + suffix + ) + + def constraints(self, dictionary: Dict[str, str]) -> str: + result = Text() + min_items = self.min_items + if min_items == 1: + result += dictionary['div_generator_non_empty_array'] + elif min_items > 1: + result += dictionary['div_generator_elements_min_number'].format(min_items) + if self.strict_parsing: + result += dictionary['div_generator_partial_parsing'] + return str(result) + + def details(self, dictionary: Dict[str, str]) -> str: + return self.__property_type_doc.details(dictionary) + + +class DocumentationBool(Bool, DocumentationPropertyType): + + @property + def description(self) -> str: + return 'bool' + + def constraints(self, dictionary: Dict[str, str]) -> str: + return '' + + +class DocumentationBoolInt(BoolInt, DocumentationPropertyType): + + @property + def description(self) -> str: + return 'bool_int' + + def constraints(self, dictionary: Dict[str, str]) -> str: + return '' + + +class DocumentationColor(Color, DocumentationPropertyType): + + @property + def description(self) -> str: + return 'string' + + def constraints(self, dictionary: Dict[str, str]) -> str: + return dictionary['div_generator_valid_formats_color'] + + +class DocumentationDictionary(Dictionary, DocumentationPropertyType): + + @property + def description(self) -> str: + return 'object' + + def constraints(self, dictionary: Dict[str, str]) -> str: + return '' + + +class DocumentationDouble(Double, DocumentationPropertyType): + + @property + def description(self) -> str: + return 'number' + + def constraints(self, dictionary: Dict[str, str]) -> str: + return constraints_for_numbers(self, dictionary) + + +class DocumentationInt(Int, DocumentationPropertyType): + + @property + def description(self) -> str: + return 'int' + + def constraints(self, dictionary: Dict[str, str]) -> str: + return constraints_for_numbers(self, dictionary) + + +def constraints_for_numbers(number: Union[Double, Int], dictionary: Dict[str, str]) -> str: + if number.constraint is None: + return '' + constraint_str = number.constraint.replace('number', 'x') + return dictionary['div_generator_value_restriction'].format(constraint_str) + + +class DocumentationObject(Object, DocumentationPropertyType): + + def update_base(self): + if not isinstance(self.object, DocumentationDeclarable): + update_declarable_base(self.object) + + @property + def description(self) -> str: + if isinstance(self.object, StringEnumeration): + return 'string' + return 'string' if self.format == ObjectFormat.JSON_STRING else 'object' + + @property + def __object_doc(self) -> DocumentationDeclarable: + return cast(DocumentationDeclarable, self.object) + + def json_description(self, obj_stack: List[DocumentationDeclarable], prefix: str, suffix: str) -> Text: + return self.__object_doc.json_description(obj_stack, prefix, suffix) + + def constraints(self, dictionary: Dict[str, str]) -> str: + obj = self.object + if isinstance(obj, StringEnumeration): + values = ', '.join(map(lambda case: code(case[0]), obj.cases)) + return dictionary['div_generator_possible_values'].format(values) + result = Text(dictionary['div_generator_value_type'].format(obj.name)) + if self.format == ObjectFormat.JSON_STRING: + result += dictionary['div_generator_value_json'] + return str(result) + + def details(self, dictionary: Dict[str, str]) -> str: + if not isinstance(self.object, EntityEnumeration): + return '' + + list_header = dictionary['div_generator_values_list'] + details = list(map(lambda obj: f'
  • [{obj[0]}]({obj[0]}.md#{obj[0]})
  • ', self.object.entities)) + details.insert(0, list_header) + return ''.join(details) + + +class DocumentationStaticString(StaticString, DocumentationPropertyType): + + @property + def description(self) -> str: + return 'string' + + def json_description(self, obj_stack: List[Declarable], prefix: str, suffix: str) -> Text: + return content_in_quotes(prefix, self.value, suffix) + + def constraints(self, dictionary: Dict[str, str]) -> str: + return dictionary['div_generator_value_must_be'].format(self.value) + + +class DocumentationString(String, DocumentationPropertyType): + + @property + def description(self) -> str: + return 'string' + + def constraints(self, dictionary: Dict[str, str]) -> str: + result = Text() + min_length = self.min_length + if min_length == 1: + result += dictionary['div_generator_non_empty_string'] + elif min_length > 1: + result += dictionary['div_generator_min_length'].format(min_length) + if self.formatted: + result += dictionary['div_generator_html_formatting'] + if self.regex is not None: + result += dictionary['div_generator_value_regex'].format(self.regex.pattern) + return str(result) + + +class DocumentationUrl(Url, DocumentationPropertyType): + + @property + def description(self) -> str: + return 'string' + + def constraints(self, dictionary: Dict[str, str]) -> str: + result = Text(dictionary['div_generator_value_url']) + schemes = self.schemes + if schemes is not None and len(schemes) > 0: + schemes_str = ', '.join(map(lambda s: code(s), schemes)) + result += dictionary['div_generator_schemes'].format(schemes_str) + return str(result) diff --git a/api_generator/api_generator/generators/documentation/generator.py b/api_generator/api_generator/generators/documentation/generator.py new file mode 100644 index 000000000..1241358b9 --- /dev/null +++ b/api_generator/api_generator/generators/documentation/generator.py @@ -0,0 +1,174 @@ +import os +from pathlib import Path +from typing import List, Optional + +from .documentation_entities import ( + update_declarable_base, + DocumentationDeclarable, + DocumentationEntity, + DocumentationProperty +) +from .translations import translations +from .utils import bold, code, paragraph +from ..base import Generator +from ...config import Config, Platform, DescriptionLanguage +from ...schema.modeling.entities import ( + Declarable, + Entity, + EntityEnumeration, + StringEnumeration, + Property +) +from ...schema.modeling.text import Text + + +class DocumentationGenerator(Generator): + + def __init__(self, config: Config) -> None: + super().__init__(config) + self.__lang = DescriptionLanguage.EN + self.__translations = {} + + def generate(self, objects: List[Declarable]): + self._clear_output_directory() + objects_doc: List[DocumentationDeclarable] = list(map(lambda obj: update_declarable_base(obj), objects)) + for lang in DescriptionLanguage: + self.__lang = lang + self._output_path = self._config.output_path + '/' + lang + self.__translations = translations(lang) + Path(self._output_path).mkdir(parents=True, exist_ok=True) + self._generate_files(objects_doc) + self.__make_menu_file(objects_doc) + + def __make_menu_file(self, objects: List[DocumentationDeclarable]): + filename = os.path.join(self._config.output_path, 'toc.yaml') + with open(filename, 'w') as file: + file.write(self.__menu_content(objects)) + + def __menu_content(self, objects: List[DocumentationDeclarable]) -> str: + items = '\n'.join(map( + lambda obj: self.__menu_item(obj), + sorted( + filter(lambda obj: obj.include_in_menu_file, objects), + key=lambda obj: obj.name < obj.name + ) + )) + return '\n'.join([ + 'title:', + 'href:', + 'items:', + items + ]) + + @staticmethod + def __menu_item(obj: Declarable) -> str: + toc = '' if obj.include_in_documentation_toc else '\n hidden: true' + return '\n'.join([ + f' -name: {obj.name}', + f' href: concepts/divs/{obj.name}.md{toc}' + ]) + + def _filename(self, name: str) -> str: + return name + '.md' + + def _entity_declaration(self, entity: DocumentationEntity) -> Text: + deprecation = self.__translations['div_generator_type_deprecated'] if entity.is_deprecated else '' + header = '\n'.join(filter( + lambda text: text.strip(), + [entity.display_name, self.__description(entity), deprecation] + )) + params_header = self.__translations['div_generator_parameters'] + return Text([ + f'# {header}', + '', + '## JSON', + '```json', + str(entity.json_description(obj_stack=[])), + '```', + '', + f'## {params_header}', + self.__fields_table(entity) + ]) + + def __description(self, entity: DocumentationEntity) -> str: + description = entity.description_doc(self.__lang) + return description if description else self.__translations['div_generator_no_description'] + + def __fields_table(self, entity: DocumentationEntity, table_name: Optional[str] = None) -> str: + header = ('### ' + table_name + '\n') if table_name is not None else '' + params_header = self.__translations['div_generator_parameters'] + description_header = self.__translations['div_generator_description'] + + properties_table_content = Text(list(map( + lambda p: f'| {code(p.dict_field)} | {self.__table_description(p)} |', + entity.properties_doc + ))) + + inner_types_tables = '\n\n'.join(map( + lambda inner_entity: self.__fields_table(entity=inner_entity, table_name=inner_entity.name), + filter(lambda inner_type: isinstance(inner_type, Entity), entity.inner_types) + )) + inner_types_tables = ('\n\n' + inner_types_tables) if inner_types_tables.strip() else '' + + if len(properties_table_content.lines) == 0: + return inner_types_tables + + return '\n'.join([ + f'{header}| {params_header} | {description_header} |', + '| --- | --- |', + f'{properties_table_content}{inner_types_tables}' + ]) + + def __table_description(self, prop: DocumentationProperty) -> str: + property_type = prop.property_type_doc + optionality = '' if prop.optional else self.__translations['div_generator_required_parameter'] + deprecation = self.__translations['div_generator_parameter_deprecated'] if prop.is_deprecated else '' + default_value = '' if prop.default_value is None else \ + self.__translations['div_generator_default_value'].format(prop.default_value) + + return ''.join(map( + lambda s: paragraph(s), + map( + lambda t: t.replace('\n', '

    '), + filter(None, [ + bold(property_type.description), + optionality, + deprecation, + prop.description_doc(self.__lang), + property_type.constraints(self.__translations), + default_value, + self.__platforms(prop), + property_type.details(self.__translations) + ]) + ) + )) + + def __platforms(self, prop: Property) -> str: + if prop.platforms is None: + return '' + if len(prop.platforms) == 0: + return self.__translations['div_generator_in_progress'] + platform_names = ', '.join(map(lambda p: self.__platform_name(p), prop.platforms)) + return self.__translations['div_generator_platforms'].format(platform_names) + + def __platform_name(self, platform: Platform) -> str: + if platform == Platform.ANDROID: + return self.__translations['div_generator_android'] + if platform == Platform.IOS: + return self.__translations['div_generator_ios'] + if platform == Platform.WEB: + return self.__translations['div_generator_web'] + raise NotImplementedError + + def _entity_enumeration_declaration(self, entity_enumeration: EntityEnumeration) -> Text: + header = self.__translations['div_generator_entity_enumeration'] + enumeration = '\n'.join(map(lambda entity: f'* [{entity[0]}]({entity[0]}.md#{entity[0]})', + entity_enumeration.entities)) + return Text([ + f'# {entity_enumeration.name}', + header, + enumeration + ]) + + def _string_enumeration_declaration(self, string_enumeration: StringEnumeration) -> Text: + return Text() diff --git a/api_generator/api_generator/generators/documentation/translations.py b/api_generator/api_generator/generators/documentation/translations.py new file mode 100644 index 000000000..233181dd4 --- /dev/null +++ b/api_generator/api_generator/generators/documentation/translations.py @@ -0,0 +1,129 @@ +from typing import Dict +from ...config import DescriptionLanguage + +__full_translations: Dict[str, Dict[str, str]] = { + "div_generator_entity_enumeration": { + "en": "It can be one of the following types:", + "ru": "Может быть одним из следующих типов:" + }, + "div_generator_values_list": { + "en": "List of possible values:", + "ru": "Список возможных значений:" + }, + "div_generator_non_empty_array": { + "en": "An array must not be empty.", + "ru": "Массив должен быть непустым." + }, + "div_generator_elements_min_number": { + "en": "The minimum number of elements is {}.", + "ru": "Минимальное количество элементов — {}." + }, + "div_generator_partial_parsing": { + "en": "Partial parsing is not allowed.", + "ru": "Не допускается частичный парсинг." + }, + "div_generator_valid_formats_color": { + "en": "Valid formats: `#RGB`, `#ARGB`, `#RRGGBB`, `#AARRGGBB`.", + "ru": "Допустимые форматы: `#RGB`, `#ARGB`, `#RRGGBB`, `#AARRGGBB`." + }, + "div_generator_value_restriction": { + "en": "Restriction for the value `x`: `{}`.", + "ru": "Ограничение для значения `x`: `{}`." + }, + "div_generator_possible_values": { + "en": "Possible values: {}.", + "ru": "Возможные значения: {}." + }, + "div_generator_value_type": { + "en": "The value has the type `{}`.", + "ru": "Значение имеет тип `{}`." + }, + "div_generator_value_json": { + "en": "**Attention:** the value is represented as a JSON string, not an object.", + "ru": "**Внимание:** значение представлено в виде JSON-строки, а не объекта." + }, + "div_generator_value_must_be": { + "en": "The value must always be `{}`.", + "ru": "Значение всегда должно равняться `{}`." + }, + "div_generator_value_url": { + "en": "The value must be a valid URL.", + "ru": "Значение должно быть валидным URL." + }, + "div_generator_schemes": { + "en": "Allowed schemes: {}.", + "ru": "Разрешенные схемы: {}." + }, + "div_generator_non_empty_string": { + "en": "A string must not be empty.", + "ru": "Строка должна быть непустой." + }, + "div_generator_min_length": { + "en": "Minimum length {}.", + "ru": "Минимальная длина {}." + }, + "div_generator_html_formatting": { + "en": "Limited HTML formatting is allowed.", + "ru": "Допускается ограниченное HTML-форматирование." + }, + "div_generator_value_regex": { + "en": "The value must match the regular expression `{}`.", + "ru": "Значение должно удовлетворять регулярному выражению `{}`." + }, + "div_generator_default_value": { + "en": "Default value: `{}`.", + "ru": "Значение по умолчанию: `{}`." + }, + "div_generator_required_parameter": { + "en": "Required parameter.", + "ru": "Обязательный параметр." + }, + "div_generator_parameter_deprecated": { + "en": "Parameter is deprecated", + "ru": "Параметр устарел." + }, + "div_generator_in_progress": { + "en": "The functionality is under development.", + "ru": "Функциональность находится в разработке." + }, + "div_generator_platforms": { + "en": "Available platforms: {}.", + "ru": "Доступные платформы: {}." + }, + "div_generator_android": { + "en": "Android", + "ru": "Android" + }, + "div_generator_ios": { + "en": "iOS", + "ru": "iOS" + }, + "div_generator_web": { + "en": "browser", + "ru": "браузер" + }, + "div_generator_parameters": { + "en": "Parameters", + "ru": "Параметры" + }, + "div_generator_type_deprecated": { + "en": "Type is deprecated.", + "ru": "Тип устарел." + }, + "div_generator_description": { + "en": "Description", + "ru": "Описание" + }, + "div_generator_no_description": { + "en": "There is no description yet.", + "ru": "Описания пока нет" + } +} + + +def translations(lang: DescriptionLanguage) -> Dict[str, str]: + result = dict() + lang_value = lang.value + for key, values in __full_translations.items(): + result[key] = values[lang_value] + return result diff --git a/api_generator/api_generator/generators/documentation/utils.py b/api_generator/api_generator/generators/documentation/utils.py new file mode 100644 index 000000000..a68872380 --- /dev/null +++ b/api_generator/api_generator/generators/documentation/utils.py @@ -0,0 +1,17 @@ +from ...schema.modeling.text import Text + + +def bold(content: str) -> str: + return f'**{content}**' + + +def code(content: str) -> str: + return f'`{content}`' + + +def content_in_quotes(prefix: str, content: str, suffix: str) -> Text: + return Text(f'{prefix}"{content}"{suffix}') + + +def paragraph(content: str) -> str: + return f'

    {content}

    ' diff --git a/api_generator/api_generator/generators/kotlin/__init__.py b/api_generator/api_generator/generators/kotlin/__init__.py new file mode 100644 index 000000000..0af3b8d21 --- /dev/null +++ b/api_generator/api_generator/generators/kotlin/__init__.py @@ -0,0 +1,3 @@ +from .generator import KotlinGenerator + +__all__ = [KotlinGenerator] diff --git a/api_generator/api_generator/generators/kotlin/generator.py b/api_generator/api_generator/generators/kotlin/generator.py new file mode 100644 index 000000000..e1ff35bc0 --- /dev/null +++ b/api_generator/api_generator/generators/kotlin/generator.py @@ -0,0 +1,322 @@ +from typing import cast + +from .kotlin_entities import ( + KotlinEntity, + KotlinProperty, + KotlinEntityEnumeration, + PARSING_ERRORS_PROP_NAME, + ENTITY_STATIC_CREATOR +) +from ..base import Generator +from ... import utils +from ...config import GenerationMode, GeneratedLanguage, TEMPLATE_SUFFIX +from ...schema.modeling.entities import ( + StringEnumeration, + EntityEnumeration, + Entity, + Object, + ObjectFormat, +) +from ...schema.modeling.text import Text, EMPTY + + +class KotlinGenerator(Generator): + def __init__(self, config): + super(KotlinGenerator, self).__init__(config) + self.kotlin_annotations = config.generation.kotlin_annotations + self._error_collectors = config.generation.errors_collectors + self._generate_equality = config.generation.generate_equality + + def _filename(self, name: str) -> str: + return f'{utils.capitalize_camel_case(name)}.kt' + + def _entity_declaration(self, entity: Entity) -> Text: + entity: KotlinEntity = cast(KotlinEntity, entity) + entity.__class__ = KotlinEntity + entity.eval_errors_collector_enabled(self._error_collectors) + entity.update_bases() + + if entity.generate_as_protocol: + return self.__declaration_as_interface(entity) + result: Text = self.__main_declaration_header(entity) + + is_template = entity.generation_mode.is_template + + if is_template: + result += EMPTY + result += ' constructor (' + result += ' env: ParsingEnvironment,' + result += f' parent: {utils.capitalize_camel_case(entity.name)}? = null,' + result += ' topLevel: Boolean = false,' + result += ' json: JSONObject' + result += ' ) {' + result += ' val logger = env.logger' + constructor = entity.constructor_body(with_commas=False).indented(indent_width=8) + if constructor.lines: + result += constructor + result += ' }' + result += EMPTY + result += entity.value_resolving_declaration.indented(indent_width=4) + + result += EMPTY + result += entity.serialization_declaration.indented(indent_width=4) + + if not is_template and self._generate_equality and not entity.instance_properties: + result += EMPTY + result += self.__manual_equals_hash_code_declaration.indented(indent_width=4) + + static_declarations = entity.static_declarations + if static_declarations.lines: + result += EMPTY + result += ' companion object {' + result += static_declarations.indented(indent_width=8) + result += ' }' + result += EMPTY + + if entity.inner_types: + for inner_type in filter(lambda t: not isinstance(t, StringEnumeration) or not is_template, + entity.inner_types): + result += EMPTY + result += self._main_declaration(inner_type).indented(indent_width=4) + + result += '}' + + return result + + @staticmethod + def __declaration_as_interface(entity: KotlinEntity) -> Text: + result = Text(f'interface {utils.capitalize_camel_case(entity.name)} {{') + for prop in entity.instance_properties_kotlin: + result += prop.declaration(overridden=False, + in_interface=True, + with_comma=False, + with_default=False).indented(indent_width=4) + result += '}' + return result + + def __main_declaration_header(self, entity: KotlinEntity) -> Text: + result = Text() + for annotation in self.kotlin_annotations: + result += annotation + data_prefix = 'data ' + if entity.generation_mode.is_template or not self._generate_equality or not entity.instance_properties: + data_prefix = '' + prefix = f'{data_prefix}class {utils.capitalize_camel_case(entity.name)}' + + interfaces = 'JSONSerializable' + protocol_plus_super_entities = entity.protocol_plus_super_entities + if protocol_plus_super_entities is not None: + interfaces += f', {protocol_plus_super_entities}' + suffix = f' : {interfaces} {{' + + def add_instance_properties(text: Text, is_template: bool) -> Text: + mixed_properties = entity.instance_properties_kotlin + if entity.errors_collector_enabled: + mixed_properties.append(KotlinProperty( + name=PARSING_ERRORS_PROP_NAME, + description='', + description_translations={}, + dict_field='', + property_type=Object(name='List', object=None, format=ObjectFormat.DEFAULT), + optional=True, + is_deprecated=False, + mode=GenerationMode.NORMAL_WITHOUT_TEMPLATES, + supports_expressions_flag=False, + default_value=None, + platforms=None + )) + for prop in mixed_properties: + overridden = False + if entity.implemented_protocol is not None: + overridden = any(p.name == prop.name for p in entity.implemented_protocol.properties) + text += prop.declaration( + overridden=overridden, + in_interface=False, + with_comma=not is_template, + with_default=not is_template + ).indented(indent_width=4) + return text + + if entity.generation_mode.is_template: + result += prefix + suffix + if entity.instance_properties: + result = add_instance_properties(text=result, is_template=True) + else: + if not entity.instance_properties: + result += f'{prefix}(){suffix}' + else: + result += f'{prefix}(' + result = add_instance_properties(text=result, is_template=False) + result += f'){suffix}' + + return result + + @property + def __manual_equals_hash_code_declaration(self) -> Text: + result = Text('override fun equals(other: Any?) = javaClass == other?.javaClass') + result += EMPTY + result += 'override fun hashCode() = javaClass.hashCode()' + return result + + def _entity_enumeration_declaration(self, entity_enumeration: EntityEnumeration) -> Text: + entity_enumeration: KotlinEntityEnumeration = cast(KotlinEntityEnumeration, entity_enumeration) + entity_enumeration.__class__ = KotlinEntityEnumeration + declaration_name = utils.capitalize_camel_case(entity_enumeration.name) + entity_declarations = list(map(utils.capitalize_camel_case, entity_enumeration.entity_names)) + naming = entity_enumeration.case_naming + result = Text() + for annotation in self.kotlin_annotations: + result += annotation + interfaces = ['JSONSerializable', entity_enumeration.mode.protocol_name( + lang=GeneratedLanguage.KOTLIN, + name=entity_enumeration.resolved_prefixed_declaration)] + interfaces = ', '.join(filter(None, interfaces)) + + result += f'sealed class {declaration_name} : {interfaces} {{' + for decl in entity_declarations: + decl = f'class {naming.format_case_name(decl)}(val value: {decl}) : {declaration_name}()' + result += Text(indent_width=4, init_lines=decl) + result += EMPTY + + result += f' fun value(): {entity_enumeration.common_interface(GeneratedLanguage.KOTLIN) or "Any"} {{' + result += ' return when (this) {' + for decl in entity_declarations: + decl = f'is {naming.format_case_name(decl)} -> value' + result += Text(indent_width=12, init_lines=decl) + result += ' }' + result += ' }' + result += EMPTY + + result += ' override fun writeToJSON(): JSONObject {' + result += ' return when (this) {' + for decl in entity_declarations: + decl = f'is {naming.format_case_name(decl)} -> value.writeToJSON()' + result += Text(indent_width=12, init_lines=decl) + result += ' }' + result += ' }' + result += EMPTY + + if entity_enumeration.mode.is_template: + self_name = entity_enumeration.resolved_prefixed_declaration + + result += f' override fun resolve(env: ParsingEnvironment, data: JSONObject): {self_name} {{' + result += ' return when (this) {' + for decl in entity_declarations: + case_name = naming.format_case_name(decl) + line = f'is {case_name} -> {self_name}.{case_name}(value.resolve(env, data))' + result += Text(indent_width=12, init_lines=line) + result += ' }' + result += ' }' + result += EMPTY + + result += ' val type: String' + result += ' get() {' + result += ' return when (this) {' + for decl in entity_declarations: + line = f'is {naming.format_case_name(decl)} -> {decl}.TYPE' + result += Text(indent_width=16, init_lines=line) + result += ' }' + result += ' }' + result += EMPTY + elif self._generate_equality: + result += ' override fun equals(other: Any?): Boolean {' + result += ' if (this === other) { return true }' + result += f' if (other is {declaration_name}) {{' + result += ' return value().equals(other.value())' + result += ' }' + result += ' return false' + result += ' }' + result += EMPTY + + result += ' companion object {' + result += ' @Throws(ParsingException::class)' + + source_name = 'json' + source_type = 'JSONObject' + read_type_expr = 'json.read("type", logger = logger, env = env)' + throwing_expr = 'throw typeMismatch(json = json, key = "type", value = type)' + + if entity_enumeration.mode.is_template: + def deserialization_args(s): + return f'env, parent?.value() as {s}?, topLevel, {source_name}' + result += ' operator fun invoke(' + result += ' env: ParsingEnvironment,' + result += ' topLevel: Boolean = false,' + result += f' {source_name}: {source_type}' + result += f' ): {declaration_name} {{' + result += ' val logger = env.logger' + result += f' val receivedType: String = {read_type_expr}' + result += f' val parent = env.templates[receivedType] as? {declaration_name}' + result += ' val type = parent?.type ?: receivedType' + else: + def deserialization_args(s): + return f'env, {source_name}' + result += ' @JvmStatic' + result += ' @JvmName("fromJson")' + args = f'env: ParsingEnvironment, {source_name}: {source_type}' + result += f' operator fun invoke({args}): {declaration_name} {{' + result += ' val logger = env.logger' + result += f' val type: String = {read_type_expr}' + result += ' when (type) {' + for decl in entity_declarations: + line = f'{decl}.TYPE -> return {naming.format_case_name(decl)}({decl}({deserialization_args(decl)}))' + result += Text(indent_width=16, init_lines=line) + + if entity_enumeration.mode is GenerationMode.NORMAL_WITH_TEMPLATES: + result += ' }' + name = utils.capitalize_camel_case(entity_enumeration.name + TEMPLATE_SUFFIX) + template_type = entity_enumeration.template_declaration_prefix + name + result += f' val template = env.templates.getOrThrow(type, json) as? {template_type}' + result += ' if (template != null) {' + result += f' return template.resolve(env, {source_name})' + result += ' } else {' + result += f' {throwing_expr}' + result += ' }' + else: + result += f' else -> {throwing_expr}' + result += ' }' + result += ' }' + + static_creator_lambda = f'env: ParsingEnvironment, it: JSONObject -> {declaration_name}(env, json = it)' + result += f' val {ENTITY_STATIC_CREATOR} = {{ {static_creator_lambda} }}' + result += ' }' + result += '}' + return result + + def _string_enumeration_declaration(self, string_enumeration: StringEnumeration) -> Text: + declaration_name = utils.capitalize_camel_case(string_enumeration.name) + cases_declarations = list(map(lambda s: Text(indent_width=16, init_lines=f'{s}.value -> {s}'), + map(lambda s: utils.fixing_first_digit(utils.constant_upper_case(s[0])), + string_enumeration.cases))) + result = Text(f'enum class {declaration_name}(private val value: String) {{') + for ind, case in enumerate(string_enumeration.cases): + terminal = ',' if ind != (len(cases_declarations) - 1) else ';' + name = utils.fixing_first_digit(utils.constant_upper_case(case[0])) + value = case[1] + result += Text(indent_width=4, init_lines=f'{name}("{value}"){terminal}') + result += EMPTY + + result += ' companion object Converter {' + result += f' fun toString(obj: {declaration_name}): String {{' + result += ' return obj.value' + result += ' }' + result += EMPTY + + result += f' fun fromString(string: String): {declaration_name}? {{' + result += ' return when (string) {' + result += cases_declarations + result += ' else -> null' + result += ' }' + result += ' }' + result += EMPTY + + result += ' val FROM_STRING = { string: String ->' + result += ' when (string) {' + result += cases_declarations + result += ' else -> null' + result += ' }' + result += ' }' + result += ' }' + result += '}' + + return result diff --git a/api_generator/api_generator/generators/kotlin/kotlin_entities.py b/api_generator/api_generator/generators/kotlin/kotlin_entities.py new file mode 100644 index 000000000..c515e4323 --- /dev/null +++ b/api_generator/api_generator/generators/kotlin/kotlin_entities.py @@ -0,0 +1,784 @@ +from __future__ import annotations +from functools import reduce +import dataclasses +from typing import List, Optional, cast + +from ..base import declaration_comment +from ...schema.modeling.entities import ( + Entity, + EntityEnumeration, + StringEnumeration, + Property, + PropertyType, + Int, + Bool, + BoolInt, + Double, + StaticString, + Object, + Array, + Url, + Color, + String, + Dictionary, + ObjectFormat +) +from ...config import GenerationMode +from ... import utils +from ...schema.modeling.text import Text, EMPTY + +EXPRESSION_TYPE_NAME = 'Expression' +EXPRESSION_LIST_TYPE_NAME = 'ExpressionsList' +PARSING_ERRORS_PROP_NAME = "parsingErrors" +ENTITY_STATIC_CREATOR = 'CREATOR' + + +def _number_validator_decl(type: str, constraint: Optional[str]) -> Optional[str]: + if constraint is None: + return None + return f'{{ it: {type} -> {constraint.replace("number", "it")} }}' + + +def _kotlin_default_value_declaration_comment(p: Property) -> str: + if isinstance(p.property_type, Object) and not isinstance(p.property_type.object, StringEnumeration): + property_type = cast(KotlinPropertyType, Object(name='', + object=p.property_type.object, + format=ObjectFormat.DEFAULT)) + comment_value = property_type.declaration_by_default_value(p.default_value, True) + else: + comment_value = p.default_value + return comment_value + + +class KotlinEntity(Entity): + errors_collector_enabled: bool + + def update_bases(self): + Int.__bases__ = (KotlinPropertyType, PropertyType,) + Bool.__bases__ = (KotlinPropertyType, PropertyType,) + BoolInt.__bases__ = (KotlinPropertyType, PropertyType,) + Double.__bases__ = (KotlinPropertyType, PropertyType,) + StaticString.__bases__ = (KotlinPropertyType, PropertyType,) + Object.__bases__ = (KotlinPropertyType, PropertyType,) + Array.__bases__ = (KotlinPropertyType, PropertyType,) + Url.__bases__ = (KotlinPropertyType, PropertyType,) + Color.__bases__ = (KotlinPropertyType, PropertyType,) + String.__bases__ = (KotlinPropertyType, PropertyType,) + Dictionary.__bases__ = (KotlinPropertyType, PropertyType,) + for prop in self.properties: + prop.__class__ = KotlinProperty + + @property + def properties_kotlin(self) -> List[KotlinProperty]: + result = [] + for prop in self.properties: + prop.__class__ = KotlinProperty + result.append(cast(KotlinProperty, prop)) + return result + + @property + def instance_properties_kotlin(self) -> List[KotlinProperty]: + result = [] + for prop in self.instance_properties: + prop.__class__ = KotlinProperty + result.append(cast(KotlinProperty, prop)) + return result + + def eval_errors_collector_enabled(self, errors_collectors: List[str]): + self.errors_collector_enabled = not self.generation_mode.is_template and self.original_name in errors_collectors + + def constructor_body(self, with_commas: bool, extra_properties: List[str] = None) -> Text: + if extra_properties is None: + extra_properties = [] + if not self.instance_properties and not extra_properties: + return Text() + expressions = [] + for prop in self.instance_properties_kotlin: + expressions.append(prop.deserialization_declaration(mode=self.generation_mode)) + expressions.extend(extra_properties) + result = Text() + for ind, expr in enumerate(expressions): + comma = '' + if with_commas and ind != (len(expressions) - 1): + comma = ',' + result += f'{expr}{comma}' + return result + + @property + def value_resolving_declaration(self) -> Text: + args = 'env: ParsingEnvironment, data: JSONObject' + result = Text(f'override fun resolve({args}): {self.resolved_prefixed_declaration} {{') + if not self.instance_properties: + result += f' return {self.resolved_prefixed_declaration}()' + else: + result += f' return {self.resolved_prefixed_declaration}(' + prop_decl = Text() + props = self.instance_properties_kotlin + for ind, p in enumerate(props): + ending = ',' if ind != (len(props) - 1) else '' + template_deserialization = p.make_template_deserialization(dict_field=p.dict_field, + value_override=None) + prop_decl += f'{template_deserialization}{ending}' + result += prop_decl.indented(indent_width=8) + result += ' )' + result += '}' + return result + + @property + def serialization_declaration(self) -> Text: + result = Text('override fun writeToJSON(): JSONObject {') + result += ' val json = JSONObject()' + for prop in self.properties_kotlin: + result += prop.serialization_declaration.indented(indent_width=4) + result += ' return json' + result += '}' + return result + + @property + def static_declarations(self) -> Text: + properties_kotlin = self.properties_kotlin + is_template = self.generation_mode.is_template + name = utils.capitalize_camel_case(self.name) + static_properties = list(filter(lambda p: isinstance(p.property_type, StaticString), properties_kotlin)) + default_values = [] + instance_properties_kotlin = self.instance_properties_kotlin + for p in instance_properties_kotlin: + decl = p.default_value_declaration + if decl is not None: + default_values.append(decl) + type_helpers = [] + for p in filter(lambda p: cast(KotlinPropertyType, p.property_type).is_enum_of_expressions, + instance_properties_kotlin): + type_helpers.append(p.type_helper_declaration) + groups = [] + if static_properties: + static_decl = Text() + for static in static_properties: + static_decl += static.declaration(overridden=False, + in_interface=False, + with_comma=False, + with_default=False) + groups.append(static_decl) + if default_values: + groups.append(Text(default_values)) + + if type_helpers: + groups.append(Text(type_helpers)) + + if not is_template: + constructor = Text() + constructor += '@JvmStatic' + constructor += '@JvmName("fromJson")' + constructor += f'operator fun invoke(env: ParsingEnvironment, json: JSONObject): {name} {{' + + if self.errors_collector_enabled: + constructor += ' val env = env.withErrorsCollector()' + + constructor += ' val logger = env.logger' + constructor += f' return {name}(' + extra_properties = [] + if self.errors_collector_enabled: + extra_properties.append(f'{PARSING_ERRORS_PROP_NAME} = env.collectErrors()') + + constructor_body = self.constructor_body(with_commas=True, + extra_properties=extra_properties).indented(indent_width=8) + if constructor_body.lines: + constructor += constructor_body + constructor += utils.indented(')', indent_width=4) + constructor += '}' + groups.append(constructor) + + validators = Text() + for p in properties_kotlin: + validator_or_empty = p.static_validator_expression(with_template_validators=is_template) + if not str(validator_or_empty): + continue + validators += validator_or_empty + if validators.lines: + groups.append(validators) + + if is_template: + readers = Text() + for p in properties_kotlin: + readers += p.static_reader_deserialization_expression + groups.append(readers) + static_creator_lambda = f'env: ParsingEnvironment, it: JSONObject -> {name}(env, json = it)' + groups.append(Text(f'val {ENTITY_STATIC_CREATOR} = {{ {static_creator_lambda} }}')) + + result = Text() + for ind, group in enumerate(groups): + result += group + if ind != (len(groups) - 1): + result += EMPTY + return result + + +class KotlinProperty(Property): + @property + def declaration_name(self) -> str: + if isinstance(self.property_type, StaticString): + name = utils.constant_upper_case(self.name) + else: + name = utils.lower_camel_case(self.name) + return utils.fixing_first_digit(name) + + @property + def default_value_var_name(self) -> str: + return f'{utils.constant_upper_case(self.declaration_name)}_DEFAULT_VALUE' + + @property + def default_value_declaration(self) -> Optional[str]: + default_value_definition = self.default_value_definition + if default_value_definition is not None: + return f'private val {self.default_value_var_name} = {default_value_definition}' + return None + + @property + def default_value_definition(self) -> Optional[str]: + if self.default_value is not None: + declaration = cast(KotlinPropertyType, self.property_type).declaration_by_default_value( + default_value=self.default_value, + string_enum_prefixed=self.mode.is_template + ) + if declaration is not None: + return declaration + empty_dict_deserialization = cast(KotlinPropertyType, self.property_type).empty_dict_deserialization + if empty_dict_deserialization is not None: + return empty_dict_deserialization + return None + + @property + def should_be_optional(self) -> bool: + prop_type = cast(KotlinPropertyType, self.property_type) + if self.mode.is_template: + return False + return self.optional and (self.default_value is None) and prop_type.empty_dict_deserialization is None + + @property + def parsed_value_is_optional(self) -> bool: + prop_type = cast(KotlinPropertyType, self.property_type) + return self.optional or self.default_value is not None or prop_type.empty_dict_deserialization is not None + + @property + def use_expression_type(self) -> bool: + prop = cast(KotlinPropertyType, self.property_type) + if prop.is_array_of_expressions: + return False + return self.supports_expressions or prop.is_enum_of_expressions + + @property + def type_declaration(self) -> str: + if self.mode.is_template: + if self.use_expression_type: + prefix = f'Field<{EXPRESSION_TYPE_NAME}<' + suffix = '>>' + else: + prefix = 'Field<' + suffix = '>' + else: + if self.use_expression_type: + prefix = f'{EXPRESSION_TYPE_NAME}<' + suffix = '>' + else: + prefix = '' + suffix = '' + type_decl = cast(KotlinPropertyType, self.property_type).declaration(self.mode) + return f'{prefix}{type_decl}{suffix}{"?" if self.should_be_optional else ""}' + + def declaration(self, overridden: bool, in_interface: bool, with_comma: bool, with_default: bool) -> Text: + if isinstance(self.property_type, StaticString): + assert not overridden and not with_comma + return Text(f'const val {self.declaration_name} = "{self.property_type.value}"') + if overridden: + prefix = 'override ' + assert not in_interface + elif not in_interface: + prefix = '@JvmField final ' + else: + prefix = '' + comma = ',' if with_comma else '' + default_assignment = '' + if with_default: + if self.should_be_optional: + default_assignment = ' = null' + elif self.default_value_declaration is not None: + default_assignment = f' = {self.default_value_var_name}' + comment = declaration_comment(self, _kotlin_default_value_declaration_comment) + return Text(f'{prefix}val {self.declaration_name}: {self.type_declaration}{default_assignment}{comma}{comment}') + + def deserialization_declaration(self, mode: GenerationMode) -> str: + deserialization_expr = self.deserialization_expression(mode=mode, reuse_logger_instance=True) + return f'{self.declaration_name} = {deserialization_expr}{self.default_value_coalescing(mode)}' + + def deserialization_expression(self, mode: GenerationMode, reuse_logger_instance: bool) -> str: + if isinstance(self.property_type, Array): + strict = 'Strict' if self.property_type.strict_parsing else '' + if self.supports_expressions and cast(KotlinPropertyType, self.property_type).is_array_of_expressions: + list_or_empty = f'{strict}{EXPRESSION_LIST_TYPE_NAME}' + else: + list_or_empty = f'{strict}List' + expression_or_empty = '' + expression_suffix_or_empty = '' + else: + if self.supports_expressions or cast(KotlinPropertyType, self.property_type).is_enum_of_expressions: + expression_or_empty = EXPRESSION_TYPE_NAME + expression_suffix_or_empty = 'WithExpression' + else: + expression_or_empty = '' + expression_suffix_or_empty = '' + list_or_empty = '' + optionality = 'Optional' if self.parsed_value_is_optional else '' + if reuse_logger_instance: + logger_arg = 'logger' + else: + logger_arg = 'env.logger' + if mode.is_template: + method_name = f'read{optionality}{list_or_empty}Field{expression_suffix_or_empty}' + key_value = f'"{self.dict_field}"' + template_args = f'topLevel, parent?.{self.declaration_name}' + else: + method_name = f'read{optionality}{expression_or_empty}{list_or_empty}' + key_value = 'key' if self.mode.is_template else f'"{self.dict_field}"' + template_args = '' + creator = self.creator_declaration(mode=mode) or '' + kotlin_type = cast(KotlinPropertyType, self.property_type) + transform = kotlin_type.deserialization_transform( + string_enum_prefixed=self.mode.is_template + ) + arg_list = ['json', key_value, template_args, creator, transform, + self.validator_arg(with_template_validators=mode.is_template), logger_arg, 'env'] + + if self.supports_expressions and not mode.is_template and self.default_value_definition is not None: + arg_list.append(self.default_value_var_name) + + if self.supports_expressions or kotlin_type.is_array_of_expressions or kotlin_type.is_enum_of_expressions: + arg_list.append(kotlin_type.type_helper_reference(self)) + + args = ', '.join(filter(lambda s: s, arg_list)) + receiver = 'JsonTemplateParser.' if mode.is_template else 'JsonParser.' + return f'{receiver}{method_name}({args})' + + def creator_declaration(self, mode: GenerationMode) -> Optional[str]: + creator_type = None + if isinstance(self.property_type, Object) and not isinstance(self.property_type.object, StringEnumeration): + creator_type = self.property_type + elif isinstance(self.property_type, Array): + item = self.property_type.property_type + if isinstance(item, Object) and not isinstance(item.object, StringEnumeration): + creator_type = item + + if creator_type is None: + return None + + type_decl = cast(KotlinPropertyType, creator_type).declaration_by_prefixed( + prefixed=self.mode.is_template and self.mode != mode, + mode=mode + ) + + return f'{type_decl}.{ENTITY_STATIC_CREATOR}' + + def validator_arg(self, with_template_validators: bool) -> str: + validator_definition = self.validator_definition + if validator_definition is None: + return '' + return self.validator_instance_name(with_template_validators) + + def validator_instance_name(self, with_templates: bool) -> str: + name = utils.constant_upper_case(self.name) + return f'{name}_TEMPLATE_VALIDATOR' if with_templates else f'{name}_VALIDATOR' + + @property + def validator_definition(self) -> Optional[str]: + if isinstance(self.property_type, Array) and self.property_type.min_items > 0: + return f'{{ it: List<*> -> it.size >= {self.property_type.min_items} }}' + elif isinstance(self.property_type, String) and ( + self.property_type.min_length > 0 or + self.optional or self.property_type.regex is not None): + expressions = [] + length_field = 'rawLength' if self.property_type.formatted else 'length' + min_length = self.property_type.min_length + actual_min_length = 1 if min_length == 0 and self.optional else min_length + if actual_min_length > 0: + expressions.append(f'it.{length_field} >= {actual_min_length}') + regex = self.property_type.regex + if regex is not None: + escaped_pattern = regex.pattern.replace('\\', '\\\\') + expressions.append(f'it: String -> it.doesMatch("{escaped_pattern}")') + if not expressions: + return '' + return f'{{ it: String -> {" && ".join(expressions)} }}' + elif isinstance(self.property_type, Url) and self.property_type.schemes is not None: + scheme_list = ', '.join(map(lambda s: f'"{s}"', self.property_type.schemes)) + return f'{{ it.hasScheme(listOf({scheme_list})) }}' + elif isinstance(self.property_type, Int): + return _number_validator_decl('Int', self.property_type.constraint) + elif isinstance(self.property_type, Double): + return _number_validator_decl('Double', self.property_type.constraint) + else: + return None + + def default_value_coalescing(self, mode: GenerationMode) -> str: + if not mode.is_template and self.default_value_definition is not None: + return f' ?: {self.default_value_var_name}' + return '' + + def make_template_deserialization(self, dict_field: str, value_override: Optional[str]) -> str: + optionality = 'Optional' if self.optional else '' + plain_or_empty = 'Template' if self.property_type.can_be_templated else '' + if value_override is None: + value_override = '' + else: + value_override = f', valueOverride = {value_override}' + reader = self.reader_declaration_name + if isinstance(self.property_type, Array): + kotlin_prop = cast(KotlinPropertyType, self.property_type) + if kotlin_prop.is_array_of_expressions: + list_or_empty = 'ExpressionList' + validator = '' + expression_or_empty = '' + else: + list_or_empty = 'List' + validator = f', {self.validator_arg(with_template_validators=False)}' + expression_or_empty = '' + if self.supports_expressions and not kotlin_prop.is_enum_of_expressions: + expression_or_empty = EXPRESSION_TYPE_NAME + else: + list_or_empty = '' + validator = '' + expression_or_empty = '' + method_name = f'.resolve{optionality}{plain_or_empty}{expression_or_empty}{list_or_empty}' + method_args = f'env = env, key = "{dict_field}", data = data{validator}{value_override}, reader = {reader}' + def_val = self.default_value_coalescing(mode=GenerationMode.NORMAL_WITHOUT_TEMPLATES) + return f'{self.declaration_name} = {self.declaration_name}{method_name}({method_args}){def_val}' + + @property + def reader_declaration_name(self) -> str: + return f'{utils.constant_upper_case(self.name)}_READER' + + @property + def serialization_declaration(self) -> Text: + is_field = self.mode.is_template and not isinstance(self.property_type, StaticString) + suffix = 'Field' if is_field else '' + value_arg = 'field' if is_field else 'value' + if self.use_expression_type: + if is_field: + expression_prefix_or_empty = '' + expression_suffix_or_empty = 'WithExpression' + else: + expression_prefix_or_empty = EXPRESSION_TYPE_NAME + expression_suffix_or_empty = '' + else: + expression_prefix_or_empty = '' + if cast(KotlinPropertyType, self.property_type).is_array_of_expressions: + expression_prefix_or_empty = EXPRESSION_LIST_TYPE_NAME + expression_suffix_or_empty = '' + serialization_transform = cast(KotlinPropertyType, self.property_type).serialization_transform( + string_enum_prefixed=self.mode.is_template + ) + args = f'key = "{self.dict_field}", {value_arg} = {self.declaration_name}{serialization_transform}' + return Text(f'json.write{expression_prefix_or_empty}{suffix}{expression_suffix_or_empty}({args})') + + @property + def type_helper_declaration(self) -> str: + type_decl = cast(KotlinPropertyType, self.property_type).prefixed_declaration( + mode=GenerationMode.NORMAL_WITHOUT_TEMPLATES) + definition = f'TypeHelper.from(default = {type_decl}.values().first()) {{ it is {type_decl} }}' + return f'private val {cast(KotlinPropertyType, self.property_type).type_helper_reference(self)} = {definition}' + + def static_validator_expression(self, with_template_validators: bool) -> Text: + result = Text() + definition = self.validator_definition or '' + if not definition: + return result + + if isinstance(self.property_type, Array) and self.property_type.min_items > 0: + item_type = cast(KotlinPropertyType, self.property_type.property_type) + list_type = item_type.prefixed_declaration( + GenerationMode.NORMAL_WITHOUT_TEMPLATES) + validator_instance_name = self.validator_instance_name(with_templates=False) + result += f'private val {validator_instance_name} = ListValidator<{list_type}> {definition}' + + if with_template_validators: + templated_list_type = item_type.prefixed_declaration(GenerationMode.TEMPLATE) + validator_instance_name = self.validator_instance_name(with_templates=True) + result += f'private val {validator_instance_name} = ListValidator<{templated_list_type}> {definition}' + else: + prop_type = cast(KotlinPropertyType, self.property_type) + validator_type = prop_type.prefixed_declaration(GenerationMode.NORMAL_WITH_TEMPLATES) + validator_instance_name_with = self.validator_instance_name(with_templates=True) + validator_instance_name_without = self.validator_instance_name(with_templates=False) + result += f'private val {validator_instance_name_with} = ValueValidator<{validator_type}> {definition}' + result += f'private val {validator_instance_name_without} = ValueValidator<{validator_type}> {definition}' + + return result + + @property + def static_reader_deserialization_expression(self) -> str: + lambda_val = 'key, json, env -> ' + self.deserialization_expression(mode=GenerationMode.NORMAL_WITH_TEMPLATES, + reuse_logger_instance=False) + reader_type = cast(KotlinPropertyType, self.property_type).declaration_by_prefixed( + prefixed=True, + mode=GenerationMode.NORMAL_WITH_TEMPLATES + ) + if self.use_expression_type: + reader_type = f'{EXPRESSION_TYPE_NAME}<{reader_type}>' + + optional = '?' if self.parsed_value_is_optional else '' + return f'val {self.reader_declaration_name}: Reader<{reader_type}{optional}> = {{ {lambda_val} }}' + + +class KotlinPropertyType(PropertyType): + @property + def is_array_of_expressions(self) -> bool: + if isinstance(self, Array): + return self.property_type.supports_expressions and \ + not cast(KotlinPropertyType, self.property_type).is_enum_of_expressions + return False + + @property + def is_enum_of_expressions(self) -> bool: + return isinstance(self, Object) and isinstance(self.object, StringEnumeration) + + @property + def empty_dict_deserialization(self) -> Optional[str]: + if isinstance(self, Object) and isinstance(self.object, Entity) and \ + self.object.all_properties_are_optional_except_default_values: + return f'{self.object.resolved_prefixed_declaration}()' + return None + + def declaration_by_default_value(self, default_value: str, string_enum_prefixed: bool) -> Optional[str]: + def wrap(value: str) -> str: + if self.supports_expressions and not self.is_array_of_expressions and not self.is_enum_of_expressions: + return f'{EXPRESSION_TYPE_NAME}.constant({value})' + return value + + if isinstance(self, (Int, Bool, BoolInt)): + return wrap(default_value) + elif isinstance(self, Double): + return wrap(str(default_value) if '.' in str(default_value) else f'{default_value}.0') + elif isinstance(self, Color): + return wrap(f'{default_value.replace("#", "0x")}.toInt()') + elif isinstance(self, String): + escaping = default_value.replace("\"", "\\\"") + return wrap(f'"{escaping}"') + elif isinstance(self, Url): + return wrap(f'Uri.parse("{default_value}")') + elif isinstance(self, Array): + without_whitespaces = default_value.replace(' ', '').replace('\n', '') + if not without_whitespaces.startswith('[') or not without_whitespaces.endswith(']'): + return None + if without_whitespaces == '[]': + values = [] + else: + values = without_whitespaces[1:-1].split(',') + item_type = cast(KotlinPropertyType, self.property_type) + declarations = list(filter(None, map( + lambda value: item_type.declaration_by_default_value(value, string_enum_prefixed), + values))) + if len(values) != len(declarations): + return None + joined = ', '.join(declarations) + return f'listOf({joined})' + elif isinstance(self, Object): + if self.object is None: + return None + + if isinstance(self.object, StringEnumeration): + if string_enum_prefixed: + name = self.object.resolved_prefixed_declaration + else: + name = utils.capitalize_camel_case(self.object.name) + def_val = utils.fixing_first_digit(utils.constant_upper_case(default_value)) + return wrap(f'Expression.constant({name}.{def_val})') + + default_value_dict = utils.json_dict(default_value) + if isinstance(self.object, EntityEnumeration): + type_val = default_value_dict.get('type') + enum_case = None + for case in self.object.entities: + ent = case[1] + if isinstance(ent, Entity) and ent.static_type == type_val: + enum_case = case + if enum_case is None: + raise ValueError(type_val) + obj = cast(KotlinPropertyType, Object(name='', object=enum_case[1], format=ObjectFormat.DEFAULT)) + case_constructor: Optional[str] = obj.declaration_by_default_value(default_value, True) + if case_constructor is None: + return None + obj_name = self.object.resolved_prefixed_declaration + self.object.__class__ = KotlinEntityEnumeration + case_name = cast(KotlinEntityEnumeration, self.object).case_naming.format_case_name( + utils.capitalize_camel_case(enum_case[0]) + ) + return wrap(f'{obj_name}.{case_name}({case_constructor})') + + entity: KotlinEntity = cast(KotlinEntity, self.object) + entity.__class__ = KotlinEntity + args = [] + for prop in entity.instance_properties_kotlin: + str_type = default_value_dict.get(prop.dict_field) + if str_type is None: + continue + declaration = cast(KotlinPropertyType, prop.property_type).declaration_by_default_value(str_type, True) + args.append(f'{prop.declaration_name} = {declaration}') + args = ', '.join(args) + return wrap(f'{entity.resolved_prefixed_declaration}({args})') + else: + return None + + def declaration(self, mode: GenerationMode) -> str: + return self.declaration_by_prefixed(prefixed=False, mode=mode) + + def prefixed_declaration(self, mode: GenerationMode) -> str: + return self.declaration_by_prefixed(prefixed=True, mode=mode) + + def declaration_by_prefixed(self, prefixed: bool, mode: GenerationMode) -> str: + if isinstance(self, (Int, Color)): + return 'Int' + elif isinstance(self, Double): + return 'Double' + elif isinstance(self, (Bool, BoolInt)): + return 'Boolean' + elif isinstance(self, String): + return 'CharSequence' if self.formatted else 'String' + elif isinstance(self, Dictionary): + return 'JSONObject' + elif isinstance(self, StaticString): + return 'String' + elif isinstance(self, Url): + return 'Uri' + elif isinstance(self, Array): + item_type = cast(KotlinPropertyType, self.property_type) + item_decl = item_type.declaration_by_prefixed(prefixed, mode) + if item_type.supports_expressions and not item_type.is_enum_of_expressions: + return f'{EXPRESSION_LIST_TYPE_NAME}<{item_decl}>' + return f'List<{item_decl}>' + elif isinstance(self, Object): + if self.name.startswith('$predefined_'): + return self.name.replace('$predefined_', '') + obj_name = None + if mode.is_template: + if isinstance(self.object, StringEnumeration): + string_enum: StringEnumeration = self.object + return string_enum.resolved_prefixed_declaration + else: + obj_name = utils.capitalize_camel_case(self.object.resolved_name + mode.name_suffix) + elif self.object is not None: + obj_name = utils.capitalize_camel_case(self.object.resolved_name) + prefix = '' + if prefixed: + prefix = self.object.declaration_prefix if mode.is_template else self.object.resolved_declaration_prefix + return f'{prefix}{obj_name or utils.capitalize_camel_case(self.name)}' + + def deserialization_transform(self, string_enum_prefixed: bool) -> str: + if isinstance(self, Url): + return 'STRING_TO_URI' + elif isinstance(self, Color): + return 'STRING_TO_COLOR_INT' + elif isinstance(self, (Bool, BoolInt)): + return 'NUMBER_TO_BOOLEAN' + elif isinstance(self, Object) and isinstance(self.object, StringEnumeration): + if string_enum_prefixed: + typename = self.object.resolved_prefixed_declaration + else: + typename = utils.capitalize_camel_case(self.object.name) + return f'{typename}.Converter.FROM_STRING' + elif isinstance(self, Double): + return 'NUMBER_TO_DOUBLE' + elif isinstance(self, Int): + return 'NUMBER_TO_INT' + elif isinstance(self, String): + return '::HtmlString' if self.formatted else '' + elif isinstance(self, Array): + return cast(KotlinPropertyType, self.property_type).deserialization_transform(string_enum_prefixed) + else: + return '' + + def type_helper_reference(self, p: KotlinProperty) -> str: + prefix = 'TYPE_HELPER_' + if isinstance(self, (Bool, BoolInt)): + return f'{prefix}BOOLEAN' + elif isinstance(self, String): + return f'{prefix}STRING' + elif isinstance(self, Url): + return f'{prefix}URI' + elif isinstance(self, Color): + return f'{prefix}COLOR' + elif isinstance(self, Int): + return f'{prefix}INT' + elif isinstance(self, Double): + return f'{prefix}DOUBLE' + elif isinstance(self, Array): + if self.property_type.supports_expressions: + return cast(KotlinPropertyType, self.property_type).type_helper_reference(p) + return '' + elif isinstance(self, Object): + return f'{prefix}{utils.constant_upper_case(p.name)}' + else: + return '' + + def serialization_transform(self, string_enum_prefixed: bool) -> str: + prefix = ', converter = ' + if isinstance(self, (Bool, BoolInt)): + return f'{prefix}BOOLEAN_TO_INT' + elif isinstance(self, String): + return f'{prefix}SPANNED_TO_HTML' if self.formatted else '' + elif isinstance(self, Url): + return f'{prefix}URI_TO_STRING' + elif isinstance(self, Object) and isinstance(self.object, StringEnumeration): + if string_enum_prefixed: + typename = self.object.resolved_prefixed_declaration + else: + typename = utils.capitalize_camel_case(self.object.name) + return f'{prefix}{{ v: {typename} -> {typename}.toString(v) }}' + elif isinstance(self, Color): + return f'{prefix}COLOR_INT_TO_STRING' + elif isinstance(self, Array): + return cast(KotlinPropertyType, self.property_type).serialization_transform(string_enum_prefixed) + else: + return '' + + +class CaseNaming: + def format_case_name(self, name: str) -> str: + if isinstance(self, Suffix): + return name + 'Case' + elif isinstance(self, RemoveCommonPart): + return name[len(self.prefix):len(name) - len(self.suffix)] + + +class Suffix(CaseNaming): + pass + + +@dataclasses.dataclass +class RemoveCommonPart(CaseNaming): + prefix: str + suffix: str + + +class KotlinEntityEnumeration(EntityEnumeration): + @property + def case_naming(self) -> CaseNaming: + components = utils.name_components(self.name) + entity_name_components = list(map(utils.name_components, self.entity_names)) + + def find_common_prefix(a: List[str], b: List[str]) -> List[str]: + common = [] + for a_el, b_el in zip(a, b): + if a_el == b_el: + common.append(a_el) + else: + break + return common + + names = [components] + entity_name_components + common_prefix = reduce(find_common_prefix, names) + for element in names: + element.reverse() + common_suffix = reduce(find_common_prefix, names) + if common_prefix or common_suffix: + return RemoveCommonPart(prefix=utils.lower_camel_case('_'.join(common_prefix)), + suffix=utils.lower_camel_case('_'.join(common_suffix))) + else: + return Suffix() diff --git a/api_generator/api_generator/generators/swift/__init__.py b/api_generator/api_generator/generators/swift/__init__.py new file mode 100644 index 000000000..690222c0d --- /dev/null +++ b/api_generator/api_generator/generators/swift/__init__.py @@ -0,0 +1,3 @@ +from .generator import SwiftGenerator + +__all__ = [SwiftGenerator] diff --git a/api_generator/api_generator/generators/swift/generator.py b/api_generator/api_generator/generators/swift/generator.py new file mode 100644 index 000000000..ac0f644cd --- /dev/null +++ b/api_generator/api_generator/generators/swift/generator.py @@ -0,0 +1,332 @@ +from typing import cast, List, Optional + +from .swift_entities import ( + SwiftEntity, + SwiftPropertyType, + SwiftProperty, + SwiftEntityEnumeration, + SwiftAccessLevel, + swift_template_deserializable_args_decl, + swift_template_deserializable_args, + _swift_default_value_declaration_comment +) +from ..base import Generator, declaration_comment +from ... import utils +from ...schema.modeling.entities import ( + StringEnumeration, + EntityEnumeration, + Entity, + Declarable, + Property, + String +) +from ...schema.modeling.text import Text, EMPTY +from ...config import Config, GenerationMode, GeneratedLanguage +from . import utils as swift_utils + + +def _make_equatable_func_body(properties: List[SwiftProperty]) -> Text: + if not properties: + return Text(' return true') + equatable_func_body = Text() + max_guard_block_length = 3 # Splitting guard conditions by chunks of maxGuardBlockLength to improve build time + + def chunks(lst, n): + for i in range(0, len(lst), n): + yield lst[i:i + n] + + def make_equatable_guard_block(field_names: List[str]) -> Text: + result = Text() + if not field_names: + return result + result += 'guard' + comparisons = list(map(lambda field_name: f' lhs.{field_name} == rhs.{field_name}', field_names)) + result += ',\n'.join(comparisons) + result += 'else {' + result += ' return false' + result += '}' + return result + + for guard_group in chunks(list(map(lambda p: p.declaration_name, properties)), max_guard_block_length): + equatable_func_body += make_equatable_guard_block(guard_group).indented(indent_width=4) + equatable_func_body += ' return true' + return equatable_func_body + + +def _string_id_property(obj: Declarable) -> Optional[Property]: + if not isinstance(obj, Entity): + return None + p = next((p for p in obj.properties if p.name == 'id'), None) + if p is None: + return None + if isinstance(p.property_type, String): + return p + return None + + +class SwiftGenerator(Generator): + def __init__(self, config: Config): + super(SwiftGenerator, self).__init__(config) + self._access_level = SwiftAccessLevel.PUBLIC + + def _filename(self, name: str) -> str: + return f'{utils.capitalize_camel_case(name)}.swift' + + def _main_declaration(self, obj: Declarable) -> Text: + main_decl = super()._main_declaration(obj) + extension = '\n\n'.join(self.__extensions_declaration(obj)) + if extension: + return main_decl + EMPTY + extension + else: + return main_decl + + def __extensions_declaration(self, obj: Declarable) -> List[str]: + if isinstance(obj, Entity): + return self.__entity_extensions_declaration(cast(SwiftEntity, obj)) + elif isinstance(obj, EntityEnumeration): + return self.__entity_enumeration_extensions_declaration(obj) + else: + return [] + + def __entity_extensions_declaration(self, entity: SwiftEntity) -> List[str]: + self_extensions = [] + if not entity.generate_as_protocol and not entity.generation_mode.is_template: + equatable_extension = Text('#if DEBUG') + props = entity.instance_properties_swift + equatable_properties = list(filter(lambda p: cast(SwiftPropertyType, p.property_type).is_equatable, + props)) + if len(equatable_properties) != len(props): + equatable_extension += '// WARNING: this == is incomplete because of [String: Any] in class fields' + pref_decl = entity.prefixed_declaration + equatable_extension += f'extension {pref_decl}: Equatable {{' + access_modifier = self._access_level.value + equatable_extension += f' {access_modifier}static func ==(lhs: {pref_decl}, rhs: {pref_decl}) -> Bool {{' + equatable_extension += _make_equatable_func_body(equatable_properties) + equatable_extension += ' }' + equatable_extension += '}' + equatable_extension += '#endif' + self_extensions.append(str(equatable_extension)) + + extension_prefix = f'extension {entity.prefixed_declaration}' + serialization = Text(f'{extension_prefix}: Serializable {{') + serialization += ' public func toDictionary() -> [String: ValidSerializationValue] {' + serialization += ' var result: [String: ValidSerializationValue] = [:]' + if entity.has_static_type: + serialization += ' result["type"] = Self.type' + for prop in props: + serialization += Text(prop.serialization_declaration).indented(indent_width=4) + serialization += ' return result' + serialization += ' }' + serialization += '}' + self_extensions.append(str(serialization)) + + inner_types_extensions = [] + for inner_type in entity.inner_types: + inner_types_extensions += self.__extensions_declaration(inner_type) + + return self_extensions + sorted(inner_types_extensions) + + def __entity_enumeration_extensions_declaration(self, entity_enumeration: EntityEnumeration) -> List[str]: + protocol = swift_utils.implemented_swift_protocol(entity_enumeration.mode) or '' + access_modifier = self._access_level.value + args_decl = swift_template_deserializable_args_decl(entity_enumeration.mode) + deserializable_extension = Text(f'extension {entity_enumeration.prefixed_declaration}: {protocol} {{') + deserializable_extension += f' {access_modifier}init(dictionary: [String: Any]{args_decl}) throws {{' + body = Text() + if entity_enumeration.mode.is_template: + body += 'let receivedType = try dictionary.getField("type") as String' + body += 'let blockType = templateToType[receivedType] ?? receivedType' + else: + body += 'let blockType = try dictionary.getField("type") as String' + body += 'switch blockType {' + entity_names = entity_enumeration.entity_names + for entity, name in zip(entity_enumeration.entities, entity_names): + obj_t = entity[1].prefixed_declaration if entity[1] is not None else utils.capitalize_camel_case(entity[0]) + low_name = utils.lower_camel_case(name) + args = swift_template_deserializable_args(entity_enumeration.mode) + body += f'case {obj_t}.type:' + body += f' self = .{low_name}(try {obj_t}(dictionary: dictionary{args}))' + body += 'default:' + args = f'field: "{entity_enumeration.name}", representation: dictionary' + body += f' throw DeserializationError.invalidFieldRepresentation({args})' + body += '}' + deserializable_extension += body.indented(level=2) + deserializable_extension += ' }' + deserializable_extension += '}' + deserializable_extension = str(deserializable_extension) + + equatable_extension = Text('#if DEBUG') + pref = entity_enumeration.prefixed_declaration + equatable_extension += f'extension {pref}: Equatable {{' + equatable_extension += f' {access_modifier}static func ==(lhs: {pref}, rhs: {pref}) -> Bool {{' + equatable_extension += ' switch (lhs, rhs) {' + for name in entity_names: + low_name = utils.lower_camel_case(name) + equatable_extension += f' case let (.{low_name}(l), .{low_name}(r)):' + equatable_extension += ' return l == r' + if len(entity_names) > 1: + equatable_extension += ' default:' + equatable_extension += ' return false' + equatable_extension += ' }' + equatable_extension += ' }' + equatable_extension += '}' + equatable_extension += '#endif' + equatable_extension = str(equatable_extension) + + if entity_enumeration.mode is GenerationMode.NORMAL_WITHOUT_TEMPLATES: + result = [deserializable_extension, equatable_extension] + elif entity_enumeration.mode.is_template: + result = [deserializable_extension] + else: + result = [equatable_extension] + + if not entity_enumeration.mode.is_template: + serialization_extension = Text(f'extension {entity_enumeration.prefixed_declaration}: Serializable {{') + serialization_extension += f' {access_modifier}func toDictionary() -> [String: ValidSerializationValue] {{' + serialization_extension += ' return value.toDictionary()' + serialization_extension += ' }' + serialization_extension += '}' + result.append(str(serialization_extension)) + + return result + + def _entity_declaration(self, entity: Entity) -> Text: + entity: SwiftEntity = cast(SwiftEntity, entity) + entity.__class__ = SwiftEntity + entity.update_bases() + + if entity.generate_as_protocol: + return self.__declaration_as_protocol(entity) + result: Text = Text(self.__main_declaration_header(entity)) + + if entity.inner_types: + inner_type_declarations = [] + for inner_type in entity.inner_types: + inner_type_declarations.append(str(super()._main_declaration(inner_type).indented())) + result += '\n\n'.join(sorted(inner_type_declarations)) + result += EMPTY + + properties_to_declare = entity.properties_to_declare_swift + if properties_to_declare: + for prop in properties_to_declare: + result += prop.declaration(self._access_level).indented() + result += EMPTY + + filtered_props = list(filter( + lambda p: p.supports_expressions and not p.mode.is_template, + properties_to_declare)) + for prop in filtered_props: + result += prop.expression_resolving_method(self._access_level).indented() + + for prop in entity.instance_properties_swift: + validator_decl = prop.validator_declaration + if validator_decl is not None: + result += validator_decl.indented() + result += EMPTY + + if entity.generation_mode is GenerationMode.NORMAL_WITHOUT_TEMPLATES or \ + entity.generation_mode.is_template: + result += entity.deserializing_constructor_declaration.indented() + result += EMPTY + + result += entity.plain_constructor_declaration.indented() + + if entity.generation_mode.is_template: + result += EMPTY + result += entity.resolve_value_only_by_links_body.indented() + result += EMPTY + result += entity.resolve_value_declaration.indented() + result += EMPTY + result += entity.resolve_template_declaration.indented() + + result += '}' + + return result + + def __declaration_as_protocol(self, entity: SwiftEntity) -> Text: + super_protocol = '' + if entity.swift_super_protocol is not None: + super_protocol = f': {entity.swift_super_protocol}' + access_modifier = self._access_level.value + result = Text(f'{access_modifier}protocol {utils.capitalize_camel_case(entity.name)}{super_protocol} {{') + props = entity.instance_properties_swift + for prop in props: + name = prop.declaration_name + type_decl = prop.type_declaration + comment = declaration_comment(prop, _swift_default_value_declaration_comment) + result += Text(f'var {name}: {type_decl} {{ get }}{comment}').indented() + for prop in filter(lambda p: p.supports_expressions and not p.mode.is_template, props): + result += Text(prop.expression_resolving_method_declaration(SwiftAccessLevel.INTERNAL)).indented() + result += '}' + return result + + def __main_declaration_header(self, entity: SwiftEntity) -> str: + protocols = list(filter(None, [entity.protocol_plus_super_entities, + swift_utils.implemented_swift_protocol(entity.generation_mode)])) + conformance = '' if not protocols else f': {", ".join(protocols)}' + access_modifier = self._access_level.value + return f'{access_modifier}final class {utils.capitalize_camel_case(entity.name)}{conformance} {{' + + def _entity_enumeration_declaration(self, entity_enumeration: EntityEnumeration) -> Text: + entity_enumeration = cast(SwiftEntityEnumeration, entity_enumeration) + entity_enumeration.__class__ = SwiftEntityEnumeration + access_modifier = self._access_level.value + name = utils.capitalize_camel_case(entity_enumeration.name) + protocol = '' + if entity_enumeration.mode.is_template: + protocol = ': TemplateValue' + header = f'{access_modifier}enum {name}{protocol} {{' + result = Text(header) + + for name in entity_enumeration.entity_names: + result += Text(f'case {utils.lower_camel_case(name)}({utils.capitalize_camel_case(name)})').indented() + result += EMPTY + if entity_enumeration.mode.is_template: + value_type = 'Any' + else: + value_type = entity_enumeration.common_interface(GeneratedLanguage.SWIFT) + if value_type is None: + value_type = 'Any' + result += f' {access_modifier}var value: {value_type} {{' + result += ' switch self {' + for name in entity_enumeration.entity_names: + result += f' case let .{utils.lower_camel_case(name)}(value):' + result += ' return value' + result += ' }' + result += ' }' + + if entity_enumeration.mode.is_template: + result += EMPTY + result += entity_enumeration.resolve_parent_implementation(self._access_level).indented() + result += EMPTY + result += entity_enumeration.resolve_value_implementation(self._access_level).indented() + result += EMPTY + result += entity_enumeration.resolve_unknown_value_implementation.indented() + else: + id_properties = list(filter(None, map(lambda x: _string_id_property(x[1]), entity_enumeration.entities))) + if len(id_properties) == len(entity_enumeration.entities): + id_optionality = '?' if any(p.optional for p in id_properties) else '' + result += EMPTY + result += f' {access_modifier}var id: String{id_optionality} {{' + result += ' switch self {' + for name in entity_enumeration.entity_names: + result += f' case let .{utils.lower_camel_case(name)}(value):' + result += ' return value.id' + result += ' }' + result += ' }' + + result += '}' + return result + + def _string_enumeration_declaration(self, string_enumeration: StringEnumeration) -> Text: + access_modifier = self._access_level.value + formatted_name = utils.capitalize_camel_case(string_enumeration.name) + if string_enumeration.parent is not None and string_enumeration.parent.generation_mode.is_template: + prefix = string_enumeration.resolved_declaration_prefix + return Text(f'{access_modifier}typealias {formatted_name} = {prefix}{formatted_name}') + result = Text(f'{access_modifier}enum {formatted_name}: String, CaseIterable {{') + for case in string_enumeration.cases: + case_name = swift_utils.fixing_keywords(utils.fixing_first_digit(utils.lower_camel_case(case[0]))) + result += f' case {case_name} = "{case[1]}"' + result += '}' + return result diff --git a/api_generator/api_generator/generators/swift/swift_entities.py b/api_generator/api_generator/generators/swift/swift_entities.py new file mode 100644 index 000000000..d0c3aa838 --- /dev/null +++ b/api_generator/api_generator/generators/swift/swift_entities.py @@ -0,0 +1,915 @@ +from __future__ import annotations +from typing import List, Optional, cast, Tuple +from enum import Enum +from .utils import fixing_keywords +from ..base import declaration_comment +from ...schema.modeling.entities import ( + Entity, + EntityEnumeration, + StringEnumeration, + Property, + PropertyType, + Int, + Bool, + BoolInt, + Double, + StaticString, + Object, + Array, + Url, + Color, + String, + Dictionary, + ObjectFormat +) +from ...config import GenerationMode +from ... import utils +from ...schema.modeling.text import Text, EMPTY + +PARENT_PROPERTY = Property( + name='parent', + description=None, + description_translations={}, + dict_field='type', + property_type=String( + min_length=1, + formatted=False, + regex=None, + enable_optimization=False + ), + optional=True, + is_deprecated=False, + mode=GenerationMode.NORMAL_WITHOUT_TEMPLATES, + supports_expressions_flag=False, + default_value=None, + platforms=None +) + + +class SwiftAccessLevel(str, Enum): + PUBLIC = 'public ' + INTERNAL = '' + + +def swift_template_deserializable_args_decl(mode: GenerationMode): + return ', templateToType: TemplateToType' if mode.is_template else '' + + +def swift_template_deserializable_args(mode: GenerationMode): + return ', templateToType: templateToType' if mode.is_template else '' + + +def _swift_default_value_declaration_comment(p: Property) -> str: + if isinstance(p.property_type, Object) and not isinstance(p.property_type.object, StringEnumeration): + property_type = cast(SwiftPropertyType, Object(name='', + object=p.property_type.object, + format=ObjectFormat.DEFAULT)) + comment_value = property_type.internal_declaration(p.default_value) + else: + comment_value = p.default_value + return comment_value + + +class SwiftEntity(Entity): + def update_bases(self): + Int.__bases__ = (SwiftPropertyType, PropertyType,) + Bool.__bases__ = (SwiftPropertyType, PropertyType,) + BoolInt.__bases__ = (SwiftPropertyType, PropertyType,) + Double.__bases__ = (SwiftPropertyType, PropertyType,) + StaticString.__bases__ = (SwiftPropertyType, PropertyType,) + Object.__bases__ = (SwiftPropertyType, PropertyType,) + Array.__bases__ = (SwiftPropertyType, PropertyType,) + Url.__bases__ = (SwiftPropertyType, PropertyType,) + Color.__bases__ = (SwiftPropertyType, PropertyType,) + String.__bases__ = (SwiftPropertyType, PropertyType,) + Dictionary.__bases__ = (SwiftPropertyType, PropertyType,) + for prop in self.properties: + prop.__class__ = SwiftProperty + + @property + def has_static_type(self) -> bool: + return any(p.name == 'type' and isinstance(p.property_type, StaticString) for p in self.properties) + + @property + def has_parent_property(self) -> bool: + return self.has_static_type + + @property + def properties_swift(self) -> List[SwiftProperty]: + self.update_bases() + if self.generation_mode.is_template and self.has_parent_property: + PARENT_PROPERTY.__class__ = SwiftProperty + return cast(List[SwiftProperty], [PARENT_PROPERTY] + self.properties) + else: + return cast(List[SwiftProperty], self.properties) + + @property + def properties_to_declare_swift(self) -> List[SwiftProperty]: + return sorted(self.properties_swift, key=lambda p: not isinstance(p.property_type, StaticString)) + + @property + def instance_properties_swift(self) -> List[SwiftProperty]: + return list(filter(lambda p: not isinstance(p.property_type, StaticString), self.properties_swift)) + + @property + def deserializing_constructor_declaration(self) -> Text: + props = self.instance_properties_swift + args_decl = swift_template_deserializable_args_decl(self.generation_mode) + if not props: + return Text(f'public init(dictionary: [String: Any]{args_decl}) throws {{}}') + result = Text(f'public convenience init(dictionary: [String: Any]{args_decl}) throws {{') + tails = [','] * (len(props) - 1) + [''] + properties_deserialization = [Text('self.init(')] + for tail, prop in zip(tails, props): + line = f'{prop.declaration_name}: try dictionary.{prop.deserialization_expression(False)}{tail}' + properties_deserialization += [Text(indent_width=2, init_lines=line)] + properties_deserialization += [Text(')')] + if not any(not p.optional for p in props): + result += list(map(lambda t: t.indented(), properties_deserialization)) + else: + result += ' do {' + result += list(map(lambda t: t.indented(indent_width=4), properties_deserialization)) + result += ' } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) {' + result += f' throw DeserializationError.invalidFieldRepresentation(field: "{self.name}." + field, representation: representation)' + result += ' }' + result += '}' + return result + + @property + def plain_constructor_declaration(self) -> Text: + props = self.instance_properties_swift + if not props: + return Text('init() {}') + + result = Text('init(') + tails = [','] * (len(props) - 1) + [''] + for prop, tail in zip(props, tails): + if not self.generation_mode.is_template: + if prop.parsed_value_is_optional: + nullability = '' if prop.should_be_optional else '?' + optionality = ' = nil' if self._generate_swift_optional_args else '' + else: + nullability = '' + optionality = '' + else: + nullability = '' + optionality = ' = nil' if prop.mode.is_template else '' + result += f' {prop.declaration_name}: {prop.type_declaration}{nullability}{optionality}{tail}' + result += ') {' + + for prop in props: + decl = f'self.{prop.declaration_name} = {fixing_keywords(prop.declaration_name)}' + if self.generation_mode.is_template: + res = decl + else: + res = prop.add_default_value_to(decl) + result += Text(indent_width=2, init_lines=res) + result += '}' + + return result + + @property + def resolve_value_only_by_links_body(self) -> Text: + result = Text() + + template_props = list(filter(lambda p: p.mode.is_template, self.instance_properties_swift)) + required_props = list(filter(lambda p: not p.parsed_value_is_optional, template_props)) + return_type = self.resolved_declaration_prefix + utils.capitalize_camel_case(self.resolved_name) + fun_name = 'private static func resolveOnlyLinks' + name = utils.capitalize_camel_case(self.name) + result += f'{fun_name}(context: Context, parent: {name}?) -> DeserializationResult<{return_type}> {{' + + if not template_props: + result += f' return .success({return_type}())' + result += '}' + return result + + for prop in template_props: + var_name = prop.value_resolving_local_var_name + result += f' let {var_name} = parent?.{prop.declaration_name}?.{prop.resolve_value_expression} ?? .noValue' + + result += f' {"let" if not required_props else "var"} errors = mergeErrors(' + for index, prop in enumerate(template_props): + level = '.warning' if prop.parsed_value_is_optional else '.error' + separator = '' if index == (len(template_props) - 1) else ',' + map_body = f'.right($0.asError(deserializing: "{prop.dict_field}", level: {level}))' + var_name = prop.value_resolving_local_var_name + result += Text(f' {var_name}.errorsOrWarnings?.map {{ {map_body} }}{separator}') + result += ' )' + + for prop in required_props: + result += f' if case .noValue = {prop.value_resolving_local_var_name} {{' + field_error = f'FieldError(fieldName: "{prop.dict_field}", level: .error, error: .requiredFieldIsMissing)' + result += f' errors.append(.right({field_error}))' + result += ' }' + + if required_props: + result += ' guard' + for prop in required_props[:-1]: + result += f' let {prop.declaration_name}NonNil = {prop.value_resolving_local_var_name}.value,' + last_prop = required_props[-1] + result += f' let {last_prop.declaration_name}NonNil = {last_prop.value_resolving_local_var_name}.value' + result += ' else {' + result += ' return .failure(NonEmptyArray(errors)!)' + result += ' }' + + result += f' let result = {return_type}(' + for index, prop in enumerate(template_props): + prop_name = prop.declaration_name + suffix = 'Value.value' if prop.parsed_value_is_optional else 'NonNil' + separator = '' if index == (len(template_props) - 1) else ',' + result += Text(f' {prop_name}: {prop_name}{suffix}{separator}') + result += ' )' + partial_success = '.partialSuccess(result, warnings: NonEmptyArray(errors)!)' + result += f' return errors.isEmpty ? .success(result) : {partial_success}' + result += '}' + + return result + + @property + def resolve_value_declaration(self) -> Text: + result = Text() + return_type = self.resolved_declaration_prefix + utils.capitalize_camel_case(self.resolved_name) + name = utils.capitalize_camel_case(self.name) + fun_name = 'public static func resolveValue' + params = f'context: Context, parent: {name}?, useOnlyLinks: Bool' + result += f'{fun_name}({params}) -> DeserializationResult<{return_type}> {{' + + template_props = list(filter(lambda p: p.mode.is_template, self.instance_properties_swift)) + if not template_props: + result += f' return .success({return_type}())' + result += '}' + return result + + result += ' if useOnlyLinks {' + result += ' return resolveOnlyLinks(context: context, parent: parent)' + result += ' }' + + for prop in template_props: + if prop.property_type.can_be_templated: + initial_value = ' = .noValue' + else: + name = prop.declaration_name + if prop.internal_validator_declaration(GenerationMode.NORMAL_WITH_TEMPLATES) is None or \ + prop.supports_expressions: + value = '' + else: + value = f'validatedBy: ResolvedValue.{prop.validator_var_name}' + initial_value = f' = parent?.{name}?.value({value}) ?? .noValue' + mode = SwiftProperty.SwiftMode(value=GenerationMode.NORMAL_WITH_TEMPLATES, + use_expressions=prop.supports_expressions) + val_type = cast(SwiftPropertyType, prop.property_type).prefixed_declaration(mode) + result += f' var {prop.value_resolving_local_var_name}: DeserializationResult<{val_type}>{initial_value}' + + result += ' context.templateData.forEach { key, __dictValue in' + result += ' switch key {' + for prop in template_props: + result += f' case "{prop.dict_field}":' + local_var_name = prop.value_resolving_local_var_name + deserialize = cast(SwiftProperty, prop).deserialize_from_value_expression + result += f' {local_var_name} = {deserialize}.merged(with: {local_var_name})' + for prop in template_props: + result += f' case parent?.{prop.declaration_name}?.link:' + local_var_name = prop.value_resolving_local_var_name + deserialize = cast(SwiftProperty, prop).deserialize_from_value_expression + result += f' {local_var_name} = {local_var_name}.merged(with: {deserialize})' + result += ' default: break' + result += ' }' + result += ' }' + + templateable_props = list(filter(lambda p: p.property_type.can_be_templated, template_props)) + if templateable_props: + result += ' if let parent = parent {' + for prop in templateable_props: + local_var_name = prop.value_resolving_local_var_name + merged_with = f'parent.{prop.declaration_name}?.{prop.resolve_value_expression}' + result += f' {local_var_name} = {local_var_name}.merged(with: {merged_with})' + result += ' }' + + required_props = list(filter(lambda p: not p.parsed_value_is_optional, template_props)) + result += f' {"let" if not required_props else "var"} errors = mergeErrors(' + for index, prop in enumerate(template_props): + level = '.warning' if prop.parsed_value_is_optional else '.error' + separator = '' if index == (len(template_props) - 1) else ',' + map_body = f'Either.right($0.asError(deserializing: "{prop.dict_field}", level: {level}))' + var_name = prop.value_resolving_local_var_name + result += f' {var_name}.errorsOrWarnings?.map {{ {map_body} }}{separator}' + result += ' )' + + for prop in required_props: + result += f' if case .noValue = {prop.value_resolving_local_var_name} {{' + field_error = f'FieldError(fieldName: "{prop.dict_field}", level: .error, error: .requiredFieldIsMissing)' + result += f' errors.append(.right({field_error}))' + result += ' }' + + if required_props: + result += ' guard' + for prop in required_props[:-1]: + result += f' let {prop.declaration_name}NonNil = {prop.value_resolving_local_var_name}.value,' + last_prop = required_props[-1] + result += f' let {last_prop.declaration_name}NonNil = {last_prop.value_resolving_local_var_name}.value' + result += ' else {' + result += ' return .failure(NonEmptyArray(errors)!)' + result += ' }' + + result += f' let result = {return_type}(' + for index, prop in enumerate(template_props): + name = prop.declaration_name + suffix = 'Value.value' if prop.parsed_value_is_optional else 'NonNil' + separator = '' if index == (len(template_props) - 1) else ',' + result += f' {name}: {name}{suffix}{separator}' + result += ' )' + partial_success = '.partialSuccess(result, warnings: NonEmptyArray(errors)!' + result += f' return errors.isEmpty ? .success(result) : {partial_success})' + result += '}' + + return result + + @property + def resolve_template_declaration(self) -> Text: + self_type = utils.capitalize_camel_case(self.name) + + result = Text(f'private func mergedWithParent(templates: Templates) throws -> {self_type} {{') + props = cast(List[SwiftProperty], self.instance_properties) + if self.has_parent_property and props: + compare_with_self_type = '' + if self.static_type is not None: + compare_with_self_type = ', parent != Self.type' + + result += f' guard let parent = parent{compare_with_self_type} else {{ return self }}' + result += f' guard let parentTemplate = templates[parent] as? {self_type} else {{' + result += ' throw DeserializationError.unknownType(type: parent)' + result += ' }' + result += ' let mergedParent = try parentTemplate.mergedWithParent(templates: templates)' + result += EMPTY + result += f' return {self_type}(' + result += ' parent: nil,' + for prop in props[:-1]: + result += Text(f'{prop.parent_template_constructor_line},').indented(indent_width=4) + result += f' {props[-1].parent_template_constructor_line}' + result += ' )' + else: + result += ' return self' + result += '}' + result += EMPTY + result += f'public func resolveParent(templates: Templates) throws -> {self_type} {{' + if any(p.property_type.can_be_templated for p in props): + result += ' let merged = try mergedWithParent(templates: templates)' + result += EMPTY + result += f' return {self_type}(' + if self.has_parent_property: + result += ' parent: nil,' + tails = [','] * (len(props) - 1) + [''] + for prop, tail in zip(props, tails): + name = f'merged.{prop.declaration_name}' + if prop.property_type.can_be_templated: + try_directive = '' if prop.parsed_value_is_optional else 'try ' + resolve_parent_function = 'tryResolveParent' if prop.parsed_value_is_optional else 'resolveParent' + validator_decl = prop.validator_declaration + validator = '' if validator_decl is None else f', validator: Self.{prop.validator_var_name}' + expr = f'{try_directive}{name}?.{resolve_parent_function}(templates: templates{validator})' + else: + expr = name + result += Text(indent_width=4, init_lines=f'{prop.declaration_name}: {expr}{tail}') + result += ' )' + elif props: + result += ' return try mergedWithParent(templates: templates)' + else: + result += ' return self' + + result += '}' + return result + + +class SwiftProperty(Property): + class SwiftMode: + def __init__(self, value: GenerationMode, use_expressions: bool): + self.value: GenerationMode = value + self.use_expressions: bool = use_expressions + + @property + def swift_mode(self) -> SwiftMode: + return SwiftProperty.SwiftMode(value=self.mode, use_expressions=self.supports_expressions) + + @property + def declaration_name(self) -> str: + return utils.fixing_first_digit(utils.lower_camel_case(self.name)) + + @property + def value_resolving_local_var_name(self) -> str: + return self.declaration_name + 'Value' + + @property + def resolve_value_expression(self) -> str: + optional_or_empty = 'Optional' if self.parsed_value_is_optional else '' + resolved_validator_arg = self.validator_arg(entity_name='ResolvedValue', + mode=GenerationMode.NORMAL_WITH_TEMPLATES) + use_only_links_arg = ', useOnlyLinks: true' if self.property_type.can_be_templated else '' + transformed = cast(SwiftPropertyType, self.property_type).transform_arg + expr = f'context: context{transformed}{resolved_validator_arg}{use_only_links_arg}' + return f'resolve{optional_or_empty}Value({expr})' + + @property + def should_be_optional(self) -> bool: + prop_type = cast(SwiftPropertyType, self.property_type) + return self.optional and (self.default_value is None) and prop_type.empty_constructor is None + + def declaration(self, access_level: SwiftAccessLevel) -> Text: + if isinstance(self.property_type, StaticString): + name = utils.lower_camel_case(self.name) + value = self.property_type.value + return Text(f'{access_level.value}static let {name}: String = "{value}"') + else: + name = fixing_keywords(self.declaration_name) + type_declaration = self.type_declaration + comment = declaration_comment(self, _swift_default_value_declaration_comment) + return Text(f'{access_level.value}let {name}: {type_declaration}{comment}') + + def expression_resolving_method(self, access_level: SwiftAccessLevel) -> Text: + result = Text(f'{self.expression_resolving_method_declaration(access_level)} {{') + if isinstance(self.property_type, Array): + item_type = cast(SwiftPropertyType, self.property_type.property_type) + method, initializer = self.expression_resolving_method_parts(item_type) + map_text = f'resolver.{method}(expression: $0{initializer})' + result += Text(f'{self.declaration_name}.map {{ {map_text} }}.compactMap {{ $0 }}').indented() + else: + prop_type = cast(SwiftPropertyType, self.property_type) + method, initializer = self.expression_resolving_method_parts(prop_type) + default_value = self.expression_resolving_method_default_value(prop_type) + expression_str = f'expression: {self.declaration_name}{initializer}' + result += Text(f'resolver.{method}({expression_str}){default_value}').indented() + result += '}' + result += EMPTY + return result + + def expression_resolving_method_parts(self, property_type: SwiftPropertyType) -> Tuple[str, str]: + resolve_string_based_value = 'resolveStringBasedValue' + initializer_prefix = ', initializer: ' + if isinstance(property_type, Color): + method = resolve_string_based_value + initializer = f'{initializer_prefix}Color.color(withHexString:)' + elif isinstance(property_type, String): + method = resolve_string_based_value + optimized = 'as CFString' if property_type.enable_optimization else '' + initializer = f'{initializer_prefix}{{ $0 {optimized}}}' + elif isinstance(property_type, Url): + method = resolve_string_based_value + initializer = f'{initializer_prefix}URL.init(string:)' + elif isinstance(property_type, (Int, Double, Bool, BoolInt)): + method = 'resolveNumericValue' + initializer = '' + elif isinstance(property_type, Object) and isinstance(property_type.object, StringEnumeration): + method = resolve_string_based_value + initializer = f'{initializer_prefix}{self.expression_declaration(optionality=False)}.init(rawValue:)' + elif isinstance(property_type, Array): + return self.expression_resolving_method_parts(cast(SwiftPropertyType, property_type.property_type)) + else: + raise NotImplementedError + return method, initializer + + @property + def type_declaration(self) -> str: + if self.mode in [GenerationMode.NORMAL_WITH_TEMPLATES, GenerationMode.NORMAL_WITHOUT_TEMPLATES]: + optionality = '?' if self.should_be_optional else '' + return cast(SwiftPropertyType, self.property_type).declaration(self.swift_mode) + optionality + elif self.mode is GenerationMode.TEMPLATE: + return f'Field<{cast(SwiftPropertyType, self.property_type).declaration(self.swift_mode)}>?' + + def expression_declaration(self, optionality: bool = False) -> str: + mode = SwiftProperty.SwiftMode(value=self.swift_mode.value, use_expressions=False) + return cast(SwiftPropertyType, self.property_type).declaration(mode) + ('?' if optionality else '') + + def expression_resolving_method_declaration(self, access_level: SwiftAccessLevel) -> str: + result = self.expression_declaration(optionality=self.default_value_declaration is None) + access = access_level.value + name = utils.capitalize_camel_case(self.name) + return f'{access}func resolve{name}(_ resolver: ExpressionResolver) -> {result}' + + @property + def default_value_declaration(self) -> Optional[str]: + if self.default_value is None: + return None + return cast(SwiftPropertyType, self.property_type).internal_declaration(self.default_value) + + def expression_resolving_method_default_value(self, prop_type: SwiftPropertyType) -> str: + default_value_declaration = self.default_value_declaration + if default_value_declaration is None: + return '' + prefix = '' + if isinstance(prop_type, Object) and isinstance(prop_type.object, StringEnumeration): + prefix = self.expression_declaration(optionality=False) + return f' ?? {prefix}{default_value_declaration}' + + @property + def validator_var_name(self) -> str: + return f'{self.declaration_name}Validator' + + @property + def validator_var_type(self) -> str: + if isinstance(self.property_type, Array): + array_or_empty = 'Array' + item_type = cast(SwiftPropertyType, self.property_type.property_type) + generic_arg = item_type.prefixed_declaration(self.swift_mode) + else: + array_or_empty = '' + prop_type = cast(SwiftPropertyType, self.property_type) + generic_arg = prop_type.prefixed_declaration(SwiftProperty.SwiftMode(value=self.swift_mode.value, + use_expressions=False)) + return f'Any{array_or_empty}ValueValidator<{generic_arg}>' + + @property + def validator_declaration(self) -> Optional[Text]: + validator = self.internal_validator_declaration(mode=self.mode) + if validator is None: + return validator + result = Text(f'static let {self.validator_var_name}: {self.validator_var_type} =') + result += utils.indented(validator) + return result + + def internal_validator_declaration(self, mode: GenerationMode) -> Optional[str]: + if mode is GenerationMode.TEMPLATE: + if isinstance(self.property_type, Array) and self.property_type.strict_parsing: + pass + else: + return None + if isinstance(self.property_type, Array) and \ + (self.property_type.min_items > 0 or self.property_type.strict_parsing): + validator_type = 'Strict' if self.property_type.strict_parsing else '' + validator_name = f'make{validator_type}ArrayValidator' + validator_args = [f'minItems: {self.property_type.min_items}'] + elif isinstance(self.property_type, String) and \ + (self.property_type.min_length > 0 or self.optional or self.property_type.regex is not None): + optimized = 'CFString' if self.property_type.enable_optimization else 'String' + validator_name = f'make{optimized}Validator' + min_length = self.property_type.min_length + actual_min_length = 1 if min_length == 0 and self.optional else min_length + validator_args = [] + if actual_min_length > 0: + validator_args.append(f'minLength: {actual_min_length}') + if self.property_type.regex is not None: + escaped_pattern = self.property_type.regex.pattern.replace('\\', '\\\\') + validator_args.append(f'regex: "{escaped_pattern}"') + elif isinstance(self.property_type, Url) and self.property_type.schemes is not None: + validator_name = 'makeURLValidator' + joined = ', '.join(map(lambda val: f'"{val}"', self.property_type.schemes)) + validator_args = [f'schemes: [{joined}]'] + elif isinstance(self.property_type, (Int, Double)): + constraint = self.property_type.constraint + if constraint is None: + return None + constraint = constraint.replace('number', '$0') + validator_name = 'makeValueValidator' + validator_args = [f'valueValidator: {{ {constraint} }}'] + elif isinstance(self.property_type, Array) and self.optional: + validator_name = 'makeNoOpArrayValidator' + validator_args = [] + else: + if self.optional: + validator_name = 'makeNoOpValueValidator' + validator_args = [] + else: + return None + + prefix = f'{validator_name}(' + separator = f',\n{" " * len(prefix)}' + return f'{prefix}{separator.join(validator_args)})' + + def validator_arg(self, entity_name: str, mode: GenerationMode) -> str: + if self.internal_validator_declaration(mode) is None: + return '' + return f', validator: {entity_name}.{self.validator_var_name}' + + @property + def parsed_value_is_optional(self) -> bool: + return self.optional or \ + self.default_value is not None or \ + cast(SwiftPropertyType, self.property_type).empty_constructor is not None + + def deserialization_expression(self, in_value_resolving: bool) -> str: + mode = SwiftProperty.SwiftMode(GenerationMode.TEMPLATE, self.supports_expressions) + if isinstance(self.property_type, Array): + type_suffix = 'Array' + item_type = cast(SwiftPropertyType, self.property_type.property_type) + type_str = item_type.declaration(mode) + else: + type_suffix = 'Field' + item_type = cast(SwiftPropertyType, self.property_type) + type_str = item_type.declaration(mode) + include_type = in_value_resolving and self.property_type.can_be_templated + type_arg = f', type: {type_str}.self' if include_type else '' + if not include_type and self.property_type.can_be_templated: + template_to_type_arg = swift_template_deserializable_args(self.mode) + else: + template_to_type_arg = '' + optional_suffix = 'Optional' if self.parsed_value_is_optional or self.mode.is_template else '' + expression_suffix = 'Expression' if self.supports_expressions else '' + if in_value_resolving: + validator_arg_string = self.validator_arg(entity_name='ResolvedValue', + mode=GenerationMode.NORMAL_WITH_TEMPLATES) + else: + validator_arg_string = self.validator_arg(entity_name='Self', + mode=self.mode) + transformed = cast(SwiftPropertyType, self.property_type).transform_arg + expr = f'"{self.dict_field}"{template_to_type_arg}{transformed}{validator_arg_string}{type_arg}' + return f'get{optional_suffix}{expression_suffix}{type_suffix}({expr})' + + def add_default_value_to(self, declaration: str) -> str: + prop_type = cast(SwiftPropertyType, self.property_type) + if self.default_value is not None: + default_value_declaration_to_use = prop_type.internal_declaration(self.default_value) + elif (empty_dict_deserialization := prop_type.empty_constructor) is not None: + default_value_declaration_to_use = empty_dict_deserialization + else: + return declaration + + if self.supports_expressions: + default_value_declaration_to_use = f'.value({default_value_declaration_to_use})' + + return f'{declaration} ?? {default_value_declaration_to_use}' + + @property + def deserialize_from_value_expression(self) -> str: + template_args = '' + if self.property_type.can_be_templated: + template_args = ', templates: context.templates, templateToType: context.templateToType' + validator_arg_str = self.validator_arg(entity_name='ResolvedValue', + mode=GenerationMode.NORMAL_WITH_TEMPLATES) + type_arg = '' + if self.property_type.can_be_templated: + if isinstance(self.property_type, Array): + prop = cast(SwiftPropertyType, self.property_type.property_type) + else: + prop = cast(SwiftPropertyType, self.property_type) + type_arg = f', type: {prop.prefixed_declaration(self.swift_mode)}.self' + transform = cast(SwiftPropertyType, self.property_type).transform_arg + return f'deserialize(__dictValue{template_args}{transform}{validator_arg_str}{type_arg})' + + @property + def parent_template_constructor_line(self) -> str: + return f'{self.declaration_name}: {self.declaration_name} ?? mergedParent.{self.declaration_name}' + + @property + def serialization_declaration(self) -> str: + prefix = f'result["{self.dict_field}"] = ' + prop = cast(SwiftPropertyType, self.property_type) + suffix = prop.serialization_suffix(self.supports_expressions) + return f'{prefix}{self.declaration_name}{"?" if self.should_be_optional and suffix else ""}{suffix}' + + +class SwiftPropertyType(PropertyType): + @property + def empty_constructor(self) -> Optional[str]: + if isinstance(self, Object) and isinstance(self.object, Entity) and \ + self.object.all_properties_are_optional_except_default_values: + return f'{self.object.prefixed_declaration}()' + return None + + def declaration(self, mode: SwiftProperty.SwiftMode) -> str: + return self._declaration(prefixed=False, mode=mode) + + def prefixed_declaration(self, mode: SwiftProperty.SwiftMode) -> str: + return self._declaration(prefixed=True, mode=mode) + + def _declaration(self, prefixed: bool, mode: SwiftProperty.SwiftMode) -> str: + result = self.__declaration(prefixed, mode) + if mode.use_expressions and not isinstance(self, Array): + return f'Expression<{result}>' + else: + return result + + def __declaration(self, prefixed: bool, mode: SwiftProperty.SwiftMode) -> str: + if isinstance(self, Int): + return 'Int' + elif isinstance(self, Double): + return 'Double' + elif isinstance(self, (Bool, BoolInt)): + return 'Bool' + elif isinstance(self, String): + return 'CFString' if self.enable_optimization else 'String' + elif isinstance(self, StaticString): + raise TypeError + elif isinstance(self, Url): + return 'URL' + elif isinstance(self, Color): + return 'Color' + elif isinstance(self, Dictionary): + return '[String: Any]' + elif isinstance(self, Object): + if self.name.startswith('$predefined_'): + return self.name.replace('$predefined_', '') + prefix: str = '' + if prefixed: + obj = self.object + if obj is not None: + if mode.value in [GenerationMode.NORMAL_WITH_TEMPLATES, + GenerationMode.NORMAL_WITHOUT_TEMPLATES]: + prefix = obj.resolved_declaration_prefix + elif mode.value is GenerationMode.TEMPLATE: + prefix = obj.template_declaration_prefix + name_to_use: str + obj = self.object + if isinstance(obj, Entity): + name_to_use = obj.resolved_name + mode.value.name_suffix + elif isinstance(obj, EntityEnumeration): + name_to_use = obj.resolved_name + mode.value.name_suffix + elif isinstance(obj, StringEnumeration): + name_to_use = obj.name + else: + raise TypeError + return prefix + utils.capitalize_camel_case(name_to_use) + elif isinstance(self, Array): + element_type = cast(SwiftPropertyType, self.property_type) + return f'[{element_type._declaration(prefixed, mode)}]' + + def internal_declaration(self, default_value: str) -> Optional[str]: + if isinstance(self, (Int, Bool, BoolInt, Double)): + return default_value + elif isinstance(self, String): + value_with_escaping_quotes = default_value.replace('"', '\"') + return f'"{value_with_escaping_quotes}"' + elif isinstance(self, Url): + return f'URL(string: "{default_value}")!' + elif isinstance(self, Color): + color_value = default_value[1::].upper() + if len(color_value) == 3: + joined = ''.join(c + c for c in color_value) + color_argb_hex = f'FF{joined}' + elif len(color_value) == 4: + color_argb_hex = ''.join(c + c for c in color_value) + elif len(color_value) == 6: + color_argb_hex = f'FF{color_value}' + elif len(color_value) == 8: + color_argb_hex = color_value + else: + raise ValueError + return f'Color.colorWithARGBHexCode(0x{color_argb_hex})' + elif isinstance(self, Array): + without_whitespaces = default_value.replace(' ', '').replace('\n', '') + if not without_whitespaces.startswith('[') or not without_whitespaces.endswith(']'): + return None + if without_whitespaces == '[]': + values = [] + else: + values = without_whitespaces[1:-1].split(',') + item_type = cast(SwiftPropertyType, self.property_type) + declarations = list(filter(None, map(lambda value: item_type.internal_declaration(value), values))) + if len(values) != len(declarations): + return None + joined = ', '.join(declarations) + return f'[{joined}]' + elif isinstance(self, Object): + if self.object is None: + return None + + if isinstance(self.object, StringEnumeration): + enum_case = next((case for case in self.object.cases if case[1] == default_value), None) + if enum_case is None: + raise ValueError(default_value) + return f'.{utils.fixing_first_digit(utils.lower_camel_case(enum_case[0]))}' + default_value_dict = utils.json_dict(default_value) + + if isinstance(self.object, EntityEnumeration): + type_val = default_value_dict.get('type') + enum_case = None + for case in self.object.entities: + ent = case[1] + if isinstance(ent, Entity) and ent.static_type == type_val: + enum_case = case + if enum_case is None: + raise ValueError(type_val) + obj = cast(SwiftPropertyType, Object(name='', object=enum_case[1], format=ObjectFormat.DEFAULT)) + case_constructor: Optional[str] = obj.internal_declaration(default_value) + if case_constructor is None: + return None + return f'.{utils.lower_camel_case(enum_case[0])}({case_constructor})' + + entity: SwiftEntity = cast(SwiftEntity, self.object) + entity.__class__ = SwiftEntity + args = [] + for prop in entity.instance_properties_swift: + str_type = default_value_dict.get(prop.dict_field) + if str_type is None: + continue + declaration = cast(SwiftPropertyType, prop.property_type).internal_declaration(str_type) + default_value = f'.value({declaration})' if prop.supports_expressions else declaration + args.append(f'{prop.declaration_name}: {default_value}') + args = ', '.join(args) + return f'{entity.declaration_prefix}{utils.capitalize_camel_case(entity.original_name)}({args})' + else: + return None + + @property + def transform_arg(self) -> str: + prefix = ', transform: ' + if isinstance(self, Url): + return f'{prefix}URL.init(string:)' + elif isinstance(self, Color): + return f'{prefix}Color.color(withHexString:)' + elif isinstance(self, Object): + if self.format is ObjectFormat.DEFAULT: + return '' + return f'{prefix}{utils.capitalize_camel_case(self.name)}.init(JSONString:)' + elif isinstance(self, Array): + return cast(SwiftPropertyType, self.property_type).transform_arg + else: + return '' + + @property + def is_equatable(self) -> bool: + if isinstance(self, Dictionary): + return False + elif isinstance(self, Array): + return cast(SwiftPropertyType, self.property_type).is_equatable + else: + return True + + def serialization_suffix(self, use_expressions: bool) -> str: + if isinstance(self, Dictionary): + return '' + elif isinstance(self, (String, Int, Double, Bool, BoolInt)): + return '.toValidSerializationValue()' if use_expressions else '' + elif isinstance(self, Object): + if isinstance(self.object, StringEnumeration): + return '.toValidSerializationValue()' if use_expressions else '.rawValue' + else: + return '.toDictionary()' + elif isinstance(self, Url): + return '.toValidSerializationValue()' if use_expressions else '.absoluteString' + elif isinstance(self, Color): + return '.toValidSerializationValue()' if use_expressions else '.hexString' + elif isinstance(self, Array): + suffix = cast(SwiftPropertyType, self.property_type).serialization_suffix(use_expressions) + return '' if not suffix else f'.map {{ $0{suffix} }}' + elif isinstance(self, StaticString): + raise TypeError('Can\'t serialize static value') + + +class SwiftEntityEnumeration(EntityEnumeration): + def resolve_parent_implementation(self, access_level: SwiftAccessLevel) -> Text: + access = access_level.value + name = utils.capitalize_camel_case(self.name) + result = Text(f'{access}func resolveParent(templates: Templates) throws -> {name} {{') + result += ' switch self {' + for name in self._resolved_entity_names: + lower_name = utils.lower_camel_case(name) + result += f' case let .{lower_name}Template(value):' + result += f' return .{lower_name}Template(try value.resolveParent(templates: templates))' + result += ' }' + result += '}' + return result + + def resolve_value_implementation(self, access_level: SwiftAccessLevel) -> Text: + access = access_level.value + name = utils.capitalize_camel_case(self.name) + params = f'context: Context, parent: {name}?, useOnlyLinks: Bool' + return_type = f'DeserializationResult<{self.resolved_prefixed_declaration}>' + result = Text(f'{access}static func resolveValue({params}) -> {return_type} {{') + result += ' guard let parent = parent else {' + result += ' if useOnlyLinks {' + result += ' return .failure(NonEmptyArray(.missingType(representation: context.templateData)))' + result += ' } else {' + result += ' return resolveUnknownValue(context: context, useOnlyLinks: useOnlyLinks)' + result += ' }' + result += ' }' + result += EMPTY + result += ' switch parent {' + for name in self._resolved_entity_names: + lower_name = utils.lower_camel_case(name) + result += f' case let .{lower_name}Template(value):' + result += ' let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks)' + result += ' switch result {' + result += f' case let .success(value): return .success(.{lower_name}(value))' + result += f' case let .partialSuccess(value, warnings): return .partialSuccess(.{lower_name}(value), warnings: warnings)' + result += ' case let .failure(errors): return .failure(errors)' + result += ' case .noValue: return .noValue' + result += ' }' + result += ' }' + result += '}' + return result + + @property + def resolve_unknown_value_implementation(self) -> Text: + params = 'context: Context, useOnlyLinks: Bool' + return_type = f'DeserializationResult<{self.resolved_prefixed_declaration}>' + result = Text(f'private static func resolveUnknownValue({params}) -> {return_type} {{') + result += ' guard let type = (context.templateData["type"] as? String).flatMap({ context.templateToType[$0] ?? $0 }) else {' + result += ' return .failure(NonEmptyArray(FieldError(fieldName: "type", level: .error, error: .requiredFieldIsMissing)))' + result += ' }' + result += EMPTY + result += ' switch type {' + for name in self._resolved_entity_names: + upper_name = utils.capitalize_camel_case(name) + lower_name = utils.lower_camel_case(name) + result += f' case {upper_name}.type:' + result += f' let result = {upper_name}Template.resolveValue(context: context, useOnlyLinks: useOnlyLinks)' + result += ' switch result {' + result += f' case let .success(value): return .success(.{lower_name}(value))' + result += f' case let .partialSuccess(value, warnings): return .partialSuccess(.{lower_name}(value), warnings: warnings)' + result += ' case let .failure(errors): return .failure(errors)' + result += ' case .noValue: return .noValue' + result += ' }' + result += ' default:' + result += ' return .failure(NonEmptyArray(FieldError(fieldName: "type", level: .error, error: .requiredFieldIsMissing)))' + result += ' }' + result += '}' + return result diff --git a/api_generator/api_generator/generators/swift/utils.py b/api_generator/api_generator/generators/swift/utils.py new file mode 100644 index 000000000..6969368b9 --- /dev/null +++ b/api_generator/api_generator/generators/swift/utils.py @@ -0,0 +1,17 @@ +from ...config import GenerationMode +from typing import Optional + + +def fixing_keywords(string: str) -> str: + if string == 'default': + return '`default`' + return string + + +def implemented_swift_protocol(mode: GenerationMode) -> Optional[str]: + if mode is GenerationMode.NORMAL_WITHOUT_TEMPLATES: + return 'Deserializable' + elif mode is GenerationMode.TEMPLATE: + return 'TemplateDeserializable' + else: + return None diff --git a/api_generator/api_generator/generators/type_script/__init__.py b/api_generator/api_generator/generators/type_script/__init__.py new file mode 100644 index 000000000..b8b2e71b5 --- /dev/null +++ b/api_generator/api_generator/generators/type_script/__init__.py @@ -0,0 +1,3 @@ +from .generator import TypeScriptGenerator + +__all__ = [TypeScriptGenerator] diff --git a/api_generator/api_generator/generators/type_script/generator.py b/api_generator/api_generator/generators/type_script/generator.py new file mode 100644 index 000000000..856224336 --- /dev/null +++ b/api_generator/api_generator/generators/type_script/generator.py @@ -0,0 +1,102 @@ +from typing import cast, List + +from .typescript_entities import ( + TypeScriptEntity, + _type_script_full_name, + _make_type_script_imports +) +from ..base import Generator +from ... import utils +from ...schema.modeling.entities import ( + StringEnumeration, + EntityEnumeration, + Entity, + Declarable, +) +from ...schema.modeling.text import Text, EMPTY + + +class TypeScriptGenerator(Generator): + def _filename(self, name: str) -> str: + return f'{utils.capitalize_camel_case(name)}.ts' + + def generate(self, objects: List[Declarable]): + super(TypeScriptGenerator, self).generate(objects) + self.__generate_index_file(objects) + + def __generate_index_file(self, objects: List[Declarable]): + file_content = Text('// Generated code. Do not modify.') + file_content += EMPTY + object_names = sorted(map(lambda o: utils.capitalize_camel_case(o.name), objects)) + for obj_name in object_names: + file_content += f"export * from './{obj_name}';" + with open(f'{self._config.output_path}/index.ts', 'w') as file: + file.write(str(file_content)) + + def _entity_declaration(self, entity: Entity) -> Text: + result = Text() + entity.__class__ = TypeScriptEntity + entity = cast(TypeScriptEntity, entity) + imports = entity.imports + if imports is not None: + result += imports + result += self.__entity_declaration_without_imports(entity) + return result + + def __entity_declaration_without_imports(self, entity: TypeScriptEntity) -> Text: + if entity.should_generate_as_type_script_class: + result = entity.class_declaration + else: + result = entity.interface_declaration + if entity.inner_types: + result += EMPTY + result += self.__inner_types_declaration(entity) + return result + + def __inner_types_declaration(self, entity: TypeScriptEntity) -> Text: + def declaration_of(decl_type, decl_method) -> Text: + def sort_predicate(d: Declarable): + return d.name + inner_types_decl = Text() + inner_types = sorted(filter(lambda t: isinstance(t, decl_type), entity.inner_types), key=sort_predicate) + for ind, p in enumerate(inner_types): + if isinstance(p, decl_type): + inner_types_decl += decl_method(p) + inner_types_decl += EMPTY + return inner_types_decl + declarations = [declaration_of(StringEnumeration, self._string_enumeration_declaration), + declaration_of(EntityEnumeration, self.__entity_enumeration_declaration_without_imports), + declaration_of(Entity, self.__entity_declaration_without_imports)] + result = Text() + for decl in declarations: + if decl.lines: + result += decl + + return result + + def _entity_enumeration_declaration(self, entity_enumeration: EntityEnumeration) -> Text: + imports = sorted(filter(None, map(lambda e: _type_script_full_name(e[1]) if e[1] is not None else None, + entity_enumeration.entities))) + result = _make_type_script_imports(items=imports) + result += self.__entity_enumeration_declaration_without_imports(entity_enumeration) + return result + + @staticmethod + def __entity_enumeration_declaration_without_imports(entity_enumeration: EntityEnumeration) -> Text: + result = Text(f'export type {_type_script_full_name(entity_enumeration)} =') + names = list(filter(None, map(lambda e: _type_script_full_name(e[1]) if e[1] is not None else None, + entity_enumeration.entities))) + if entity_enumeration.generate_case_for_templates: + names.append('TemplateBlock') + for ind, name in enumerate(names): + ending = ';' if ind == (len(names) - 1) else '' + result += f' | {name}{ending}' + return result + + def _string_enumeration_declaration(self, string_enumeration: StringEnumeration) -> Text: + result = Text(f'export type {_type_script_full_name(string_enumeration)} =') + values = list(map(lambda case: case[1], string_enumeration.cases)) + for ind, value in enumerate(values): + ending = ';' if ind == (len(values) - 1) else '' + result += f" | '{value}'{ending}" + return result diff --git a/api_generator/api_generator/generators/type_script/typescript_entities.py b/api_generator/api_generator/generators/type_script/typescript_entities.py new file mode 100644 index 000000000..de159f03e --- /dev/null +++ b/api_generator/api_generator/generators/type_script/typescript_entities.py @@ -0,0 +1,246 @@ +from typing import List, cast, Optional + +from ... import utils +from ...schema.modeling.entities import ( + Entity, + Property, + PropertyType, + Int, + Bool, + BoolInt, + Double, + StaticString, + Object, + Array, + Url, + Color, + String, + Dictionary, + Declarable +) +from ...schema.modeling.text import Text, EMPTY + + +def _as_type_script_commentary_content(s: str) -> Text: + result = Text() + words = s.replace('
  • ', '').replace('
  • ', '').split(' ') + line = '' + for word in words: + if not line: + line = str(word) + elif (len(line) + len(word)) > 93: + result += f' * {line}' + line = str(word) + else: + line += f' {word}' + result += f' * {line}' + return result + + +def _as_type_script_commentary(s: str) -> Text: + if not s: + return EMPTY + return Text('/**') + _as_type_script_commentary_content(s) + Text(' */') + + +def _type_script_full_name(d: Declarable) -> str: + full_name = utils.capitalize_camel_case(d.name) + if d.parent is not None: + full_name = utils.capitalize_camel_case(d.parent.name) + full_name + if isinstance(d, Entity): + d.__class__ = TypeScriptEntity + if not cast(TypeScriptEntity, d).should_generate_as_type_script_class: + full_name = f'I{full_name}' + return full_name + + +def _type_script_type_name(property_type: PropertyType, supports_expressions: bool) -> str: + if isinstance(property_type, (Int, Double)): + return 'number' + elif isinstance(property_type, Bool): + return 'boolean' + elif isinstance(property_type, BoolInt): + return 'IntBoolean' + elif isinstance(property_type, (Color, String, Url)): + return 'string' + elif isinstance(property_type, StaticString): + raise TypeError('Is a static type') + elif isinstance(property_type, Dictionary): + return '{}' + elif isinstance(property_type, Array): + item_type = property_type.property_type + array_type_name = _type_script_type_name(item_type, supports_expressions) + if supports_expressions and not isinstance(item_type, Array): + array_type_name += ' | DivExpression' + return f'NonEmptyArray<{array_type_name}>' + elif isinstance(property_type, Object): + if property_type.object is None: + raise ValueError(f'Invalid {property_type}') + return _type_script_full_name(property_type.object) + else: + raise NotImplementedError + + +def _referenced_top_level_type_name(property_type: PropertyType) -> Optional[str]: + if isinstance(property_type, Object): + obj = property_type.object + if obj is not None and obj.parent is None: + return _type_script_full_name(obj) + return None + elif isinstance(property_type, Array): + return _referenced_top_level_type_name(property_type.property_type) + return None + + +def _make_type_script_imports(items: List[str]) -> Text: + if not items: + return EMPTY + result = Text() + result += 'import {' + for item in items: + result += f' {item},' + result += "} from './';" + result += EMPTY + return result + + +class TypeScriptEntity(Entity): + @property + def static_properties(self) -> List[Property]: + return list(filter(lambda p: isinstance(p.property_type, StaticString), self.properties)) + + @property + def should_generate_as_type_script_class(self) -> bool: + return len(self.static_properties) != 0 + + @property + def props_interface_name(self) -> str: + return f'{_type_script_full_name(self)}Props' + + @property + def referenced_top_level_types(self) -> List[str]: + return list(filter(None, map(lambda p: _referenced_top_level_type_name(p.property_type), self.properties))) + + @property + def imports(self) -> Optional[Text]: + types = self.referenced_top_level_types + for inner_type in self.inner_types: + if isinstance(inner_type, Entity): + inner_type.__class__ = TypeScriptEntity + types += cast(TypeScriptEntity, inner_type).referenced_top_level_types + type_script_full_name = _type_script_full_name(self) + unique_types = list(filter(lambda t: t != type_script_full_name, set(types))) + return None if not unique_types else _make_type_script_imports(sorted(unique_types)) + + @property + def class_declaration(self) -> Text: + result = Text() + commentary = _as_type_script_commentary(self.description_doc()) + if commentary != EMPTY: + result += commentary + prop_name = self.props_interface_name + result += f'export class {_type_script_full_name(self)} {{' + result += f' readonly _props?: Exact<{prop_name}, T>;' + + class_properties_declaration = self.class_properties_declaration + if class_properties_declaration.lines: + result += EMPTY + result += class_properties_declaration + + constructor_declaration = self.constructor_declaration + if constructor_declaration.lines: + result += EMPTY + result += constructor_declaration + result += '}' + result += EMPTY + result += f'interface {prop_name} {{' + dynamic_properties_declaration = self.dynamic_properties_declaration + if dynamic_properties_declaration.lines: + result += dynamic_properties_declaration + result += '}' + return result + + @property + def class_properties_declaration(self) -> Text: + result = Text() + for prop in self.static_properties: + prop.__class__ = TypeScriptProperty + prop = cast(TypeScriptProperty, prop) + commentary = prop.commentary + if commentary is not None: + result += commentary.indented(indent_width=4) + result += f' readonly {prop.escaped_name} = {prop.static_value};' + dynamic_properties_declaration = self.dynamic_properties_declaration + if dynamic_properties_declaration.lines: + result += dynamic_properties_declaration + return result + + @property + def constructor_declaration(self) -> Text: + optional_marker = '' if any(not p.optional for p in self.instance_properties) else '?' + result = Text(f' constructor(props{optional_marker}: Exact<{self.props_interface_name}, T>) {{') + for prop in self.instance_properties: + result += f' this.{prop.name} = props{optional_marker}.{prop.name};' + result += ' }' + return result + + @property + def dynamic_properties_declaration(self) -> Text: + result = Text() + for prop in self.instance_properties: + prop.__class__ = TypeScriptProperty + prop = cast(TypeScriptProperty, prop) + commentary = prop.commentary + if commentary is not None: + result += commentary.indented(indent_width=4) + result += f' {prop.make_declaration(is_templatable=self._type_script_templatable)};' + return result + + @property + def interface_declaration(self) -> Text: + result = Text() + commentary = _as_type_script_commentary(self.description_doc()) + if commentary != EMPTY: + result += commentary + result += f'export interface {_type_script_full_name(self)} {{' + result += self.dynamic_properties_declaration + result += '}' + return result + + +class TypeScriptProperty(Property): + @property + def commentary(self) -> Optional[Text]: + result = Text() + if self.description_doc() is not None: + result += _as_type_script_commentary_content(self.description_doc()) + + if self.is_deprecated: + if result.lines: + result += ' *' + result += ' * @deprecated' + + if result.lines: + result = Text('/**') + result + Text(' */') + return result + + return None + + @property + def escaped_name(self) -> str: + return self.name if '-' not in self.name else f"'{self.name}'" + + @property + def static_value(self) -> str: + if isinstance(self.property_type, StaticString): + return f"'{self.property_type.value}'" + else: + raise TypeError(f'{self.property_type.__class__} is not a static type') + + def make_declaration(self, is_templatable: bool) -> str: + optional_mark = '?' if self.optional else '' + type_name = _type_script_type_name(self.property_type, self.supports_expressions) + type_decl = f'Type<{type_name}>' if is_templatable else type_name + if self.supports_expressions and not isinstance(self.property_type, Array): + type_decl += ' | DivExpression' + return f'{self.escaped_name}{optional_mark}: {type_decl}' diff --git a/api_generator/api_generator/schema/__init__.py b/api_generator/api_generator/schema/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api_generator/api_generator/schema/modeling/__init__.py b/api_generator/api_generator/schema/modeling/__init__.py new file mode 100644 index 000000000..1dde61f3a --- /dev/null +++ b/api_generator/api_generator/schema/modeling/__init__.py @@ -0,0 +1,3 @@ +from .modeler import build_objects + +__all__ = [build_objects] diff --git a/api_generator/api_generator/schema/modeling/builders.py b/api_generator/api_generator/schema/modeling/builders.py new file mode 100644 index 000000000..f5a564363 --- /dev/null +++ b/api_generator/api_generator/schema/modeling/builders.py @@ -0,0 +1,276 @@ +import re +from typing import List, Tuple, Optional, Dict, Union, cast + +from .utils import ( + fixing_reserved_typename, + generate_cases_for_templates, + alias, + platforms +) +from ..utils import is_list_of_type, is_dict_with_keys_of_type, code_generation_disabled + +from ...config import Config, GenerationMode, GeneratedLanguage +from ..preprocessing.entities import ElementLocation +from .entities import ( + Entity, + EntityEnumeration, + StringEnumeration, + Declarable, + PropertyType, + Property, + default_value as property_default_value, + Int, + Bool, + Double, + StaticString, + Object, + Array, + ObjectFormat, + Url, + Color, + String, + Dictionary, BoolInt +) +from .errors import InvalidFieldRepresentationError, UnsupportedFormatTypeError + + +def __generate_templates(config: Config.GenerationConfig) -> bool: + return config.lang in [GeneratedLanguage.SWIFT, GeneratedLanguage.KOTLIN] + + +def __resolve_string_field(name: str, + mode: GenerationMode, + config: Config.GenerationConfig, + location: ElementLocation, + dictionary: Dict[str, any]) -> Tuple[PropertyType, List[Declarable]]: + force_instance_field: bool = dictionary.get('force_instance_field', False) + enum_cases: List[str] = dictionary.get('enum') + if not force_instance_field and enum_cases is not None: + if not is_list_of_type(enum_cases, str): + raise TypeError + force_enum_field: bool = dictionary.get('force_instance_field', False) + if len(enum_cases) > 1 or force_enum_field: + fixed_name = fixing_reserved_typename(name, config.lang) + enumeration = StringEnumeration(name=fixed_name, + original_name=name, + cases=enum_cases, + include_documentation_toc=dictionary.get('include_in_documentation_toc', + False)) + return Object(name=name, object=None, format=ObjectFormat.DEFAULT), [enumeration] + else: + if len(enum_cases) < 1: + raise InvalidFieldRepresentationError(location=location + 'enum', value=enum_cases) + return StaticString(value=enum_cases[0]), [] + else: + format_value: Optional[Union[str, Dict[str, any]]] = dictionary.get('format') + if format_value == 'uri': + return Url(schemes=dictionary.get('schemes')), [] + elif format_value == 'color': + return Color(), [] + elif is_dict_with_keys_of_type(format_value, str): + property_type, inner_declarations = type_property_build(dictionary=format_value, + outer_name=name, + location=location + 'format', + mode=mode, + config=config) + if not isinstance(property_type, Object): + raise UnsupportedFormatTypeError + assert property_type.format == ObjectFormat.DEFAULT + return Object(name=property_type.name, object=property_type.object, + format=ObjectFormat.JSON_STRING), inner_declarations + else: + pattern = dictionary.get('pattern') + regex = re.compile(pattern) if pattern is not None else None + return String(min_length=dictionary.get('minLength', 0), + formatted=format_value == 'formatted_string', + regex=regex, + enable_optimization=dictionary.get('client_optimized', False)), [] + + +def _entity_enumeration_build(entities: List[Dict[str, any]], + name: str, + original_name: str, + include_in_documentation_toc: bool, + root_entity: bool, + generate_case_for_templates: bool, + location: ElementLocation, + mode: GenerationMode, + config: Config.GenerationConfig) -> List[Declarable]: + resulting_declarations: List[Declarable] = [] + property_types: List[str] = [] + for index, entity in enumerate(entities): + entity_location = location + str(index) + preprocessor_typename: str = entity.get('$typename') + if preprocessor_typename is not None: + typename = preprocessor_typename + else: + defined_type = entity.get('type') + if not defined_type.startswith('$defined_'): + raise InvalidFieldRepresentationError(entity_location, entity) + typename = defined_type.replace('$defined_', '') + + property_type, declarations = type_property_build(dictionary=entity, + outer_name=typename, + location=entity_location, + mode=mode, + config=config) + + if isinstance(property_type, StaticString): + raise InvalidFieldRepresentationError(entity_location, entity) + elif isinstance(property_type, Object): + property_types.append(property_type.name) + else: + property_types.append(typename) + resulting_declarations.extend(declarations) + + return [cast(Declarable, EntityEnumeration(name=name, + original_name=original_name, + include_in_documentation_toc=include_in_documentation_toc, + root_entity=root_entity, + generate_case_for_templates=generate_case_for_templates, + entities=property_types, + mode=mode))] + resulting_declarations + + +def entity_enumeration_build(entities: List[Dict[str, any]], + name: str, + original_name: str, + include_in_documentation_toc: bool, + root_entity: bool, + generate_case_for_templates: bool, + location: ElementLocation, + config: Config.GenerationConfig) -> List[Declarable]: + def make_result(mode: GenerationMode) -> List[Declarable]: + return _entity_enumeration_build(entities=entities, + name=name, + original_name=original_name, + include_in_documentation_toc=include_in_documentation_toc, + root_entity=root_entity, + generate_case_for_templates=generate_case_for_templates, + location=location, + mode=mode, + config=config) + + normal_result: List[Declarable] = make_result(GenerationMode(GenerationMode.NORMAL_WITH_TEMPLATES)) + template_result: List[Declarable] = [] + if __generate_templates(config): + template_result += make_result(GenerationMode(GenerationMode.TEMPLATE)) + return normal_result + template_result + + +def entity_build(name: str, + dictionary: Dict[str, any], + location: ElementLocation, + config: Config.GenerationConfig) -> List[Entity]: + def make_result(mode: GenerationMode) -> Entity: + return Entity(name=name, + dictionary=dictionary, + location=location, + mode=mode, + config=config) + + normal: Entity = make_result(mode=GenerationMode.NORMAL_WITH_TEMPLATES) + if not normal.generate_as_protocol and __generate_templates(config): + return [normal, make_result(mode=GenerationMode.TEMPLATE)] + return [normal] + + +def type_property_build(dictionary: Dict[str, any], + outer_name: str, + location: ElementLocation, + mode: GenerationMode, + config: Config.GenerationConfig) -> Tuple[PropertyType, List[Declarable]]: + name: str = alias(config.lang, dictionary) or outer_name + any_of_entities: List[Dict[str, any]] = dictionary.get('anyOf') + if is_list_of_type(any_of_entities, Dict) and all(is_dict_with_keys_of_type(d, str) for d in any_of_entities): + name = fixing_reserved_typename(name, config.lang) + entity_enum: List[Declarable] = _entity_enumeration_build( + entities=any_of_entities, + name=name, + original_name=outer_name, + include_in_documentation_toc=dictionary.get('include_in_documentation_toc', False), + root_entity=dictionary.get('root_entity', False), + generate_case_for_templates=generate_cases_for_templates(config.lang, dictionary), + location=location + 'anyOf', + mode=mode, + config=config) + return Object(name=name, object=None, format=ObjectFormat.DEFAULT), entity_enum + + type_value: str = dictionary.get('type') + number_constraints: Optional[str] = dictionary.get('constraint') + + if type_value == 'integer': + if dictionary.get('format') == 'boolean': + return BoolInt(), [] + return Int(constraint=number_constraints), [] + elif type_value == 'number': + return Double(constraint=number_constraints), [] + elif type_value == 'boolean': + return Bool(), [] + elif type_value == 'string': + return __resolve_string_field(name=name, + mode=mode, + config=config, + location=location, + dictionary=dictionary) + elif type_value == 'array': + items_types: Dict[str, any] = dictionary.get('items') + single_name: str = name[:-1] if name.endswith('s') else name + min_items: int = dictionary.get('minItems', 1) + strict_parsing: bool = dictionary.get('strictParsing', False) + property_type, declarations = type_property_build(dictionary=items_types, + outer_name=single_name, + location=location + 'items', + mode=mode, + config=config) + return Array(property_type=property_type, min_items=min_items, strict_parsing=strict_parsing), declarations + elif type_value == 'object': + if dictionary.get('additionalProperties', False) and 'properties' not in dictionary: + return Dictionary(), [] + entity: Entity = Entity(name=name, + dictionary=dictionary, + location=location, + mode=mode, + config=config) + return Object(name=entity.original_name, object=None, format=ObjectFormat.DEFAULT), [entity] + else: + if not type_value.startswith('$defined_'): + raise InvalidFieldRepresentationError(location=location + 'type', value=dictionary) + return Object(name=type_value.replace('$defined_', ''), object=None, format=ObjectFormat.DEFAULT), [] + + +def property_build(properties: Dict[str, Dict[str, any]], + required: Optional[str], + location: ElementLocation, + mode: GenerationMode, + config: Config.GenerationConfig) -> Tuple[List[Property], List[Declarable]]: + properties_list: List[Property] = [] + inner_types_list: List[Declarable] = [] + for dict_field, dictionary in filter(lambda pair: not code_generation_disabled(config.lang, pair[1]), + properties.items()): + location_val = location + dict_field + property_type, inner_types = type_property_build(dictionary=dictionary, + outer_name=dict_field, + location=location_val, + mode=mode, + config=config) + default_value: str = property_default_value(lang=config.lang, + property_type=property_type, + location=location_val, + dictionary=dictionary) + is_required: bool = dict_field in required if required is not None else False + name = alias(lang=config.lang, dictionary=dictionary) or dict_field + properties_list.append(Property(name=name, + description=dictionary.get('description'), + description_translations=dictionary.get('description_translations', {}), + dict_field=dict_field, + property_type=property_type, + optional=not is_required, + is_deprecated=dictionary.get('deprecated', False), + mode=mode, + supports_expressions_flag=dictionary.get('supports_expressions', True), + default_value=default_value, + platforms=platforms(dictionary))) + inner_types_list.extend(inner_types) + + return sorted(properties_list, key=lambda p: isinstance(p.property_type, StaticString)), inner_types_list diff --git a/api_generator/api_generator/schema/modeling/entities.py b/api_generator/api_generator/schema/modeling/entities.py new file mode 100644 index 000000000..25e6e901a --- /dev/null +++ b/api_generator/api_generator/schema/modeling/entities.py @@ -0,0 +1,745 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import List, Optional, Dict, Union, Tuple, cast +from dataclasses import dataclass +from enum import Enum, auto +import validators +import re + +from ..utils import is_dict_with_keys_of_type, is_list_of_type +from .utils import ( + alias, + fixing_reserved_typename, + description_doc +) +from ...utils import capitalize_camel_case + +from ...config import Config, GeneratedLanguage, GenerationMode, Platform, TEMPLATE_SUFFIX, DescriptionLanguage +from ..preprocessing.entities import ElementLocation +from ..preprocessing.errors import UnresolvedReferenceError +from .errors import GenericError, InvalidFieldRepresentationError + +from . import builders + + +class Declarable(ABC): + def __init__(self) -> None: + self._parent: Optional[Declarable] = None + + @property + def parent(self) -> Optional[Entity]: + return self._parent + + @parent.setter + def parent(self, parent: Entity) -> None: + self._parent = parent + + @property + def resolved_declaration_prefix(self) -> str: + if self.parent is not None: + return f'{self.parent.resolved_declaration_prefix}{capitalize_camel_case(self.parent.resolved_name)}.' + return '' + + @property + def resolved_prefixed_declaration(self) -> str: + return self.resolved_declaration_prefix + capitalize_camel_case(self.resolved_name) + + @property + def prefixed_declaration(self) -> str: + return self.declaration_prefix + capitalize_camel_case(self.name) + + @property + def declaration_prefix(self) -> str: + if self.parent is not None: + return f'{self.parent.declaration_prefix}{capitalize_camel_case(self.parent.name)}.' + return '' + + @property + def template_declaration_prefix(self) -> str: + if self.parent is not None: + name = capitalize_camel_case(self.parent.resolved_name + TEMPLATE_SUFFIX) + return f'{self.parent.template_declaration_prefix}{name}.' + return '' + + @property + @abstractmethod + def name(self) -> str: + pass + + @property + @abstractmethod + def resolved_name(self) -> str: + pass + + @property + @abstractmethod + def original_name(self) -> str: + pass + + @property + @abstractmethod + def include_in_documentation_toc(self) -> bool: + pass + + @property + @abstractmethod + def as_json(self) -> Dict: + pass + + @abstractmethod + def resolve_dependencies(self, global_objects: List[Declarable]) -> None: + pass + + @abstractmethod + def check_dependencies_resolved(self, location: ElementLocation, stack: List[Declarable]) -> None: + pass + + +def _super_entities(config: Config.GenerationConfig, dictionary: Dict[str, any]) -> Optional[str]: + if config.lang in [GeneratedLanguage.SWIFT, GeneratedLanguage.DOCUMENTATION]: + super_entities_key = 'swift_protocols' + elif config.lang in [GeneratedLanguage.KOTLIN, GeneratedLanguage.KOTLIN_DSL]: + super_entities_key = 'kotlin_interfaces' + elif config.lang is GeneratedLanguage.TYPE_SCRIPT: + super_entities_key = 'typescript_interfaces' + elif config.lang is GeneratedLanguage.PYTHON: + super_entities_key = 'python_classes' + else: + raise NotImplementedError + + super_entities: Optional[str] = dictionary.get(super_entities_key, dictionary.get(f'{super_entities_key}_local')) + return super_entities + + +class Entity(Declarable): + _implemented_protocol: Optional[Entity] + _enclosing_enumeration: Optional[EntityEnumeration] = None + + def __init__(self, + name: str, + dictionary: Dict[str, any], + location: ElementLocation, + mode: GenerationMode, + config: Config.GenerationConfig): + super().__init__() + self._super_entities: Optional[str] = _super_entities(config, dictionary) + self._resolved_name: str = alias(config.lang, dictionary) or fixing_reserved_typename(name, config.lang) + self._name: str = self._resolved_name + mode.name_suffix + self._root_entity: bool = dictionary.get('root_entity', False) + self._display_name: str = dictionary.get('display_name', name) + self._description: str = dictionary.get('description', '') + self._description_object: Dict[str, str] = dictionary.get('description_translations', {}) + self._swift_super_protocol: Optional[str] = dictionary.get('swift_super_protocol') + self._include_in_documentation_toc: bool = dictionary.get('include_in_documentation_toc', False) + self._original_name: str = name + self._generation_mode: GenerationMode = mode + self._errors_collector_enabled: bool = not mode.is_template and name in config.errors_collectors + + input_properties: Dict[str, any] = dictionary.get('properties', dict()) + if not is_dict_with_keys_of_type(value=input_properties, key_type=str): + raise GenericError(location=location + 'properties', text='Must have format [String : Any]') + + input_properties_with_dict_values: Dict[str, Dict[str, any]] = input_properties + if not all(is_dict_with_keys_of_type(value, str) for value in input_properties_with_dict_values.values()): + raise GenericError(location=location + 'properties', text='Must have format [String : [String : Any]]') + properties, inner_types = builders.property_build(properties=input_properties_with_dict_values, + required=dictionary.get('required'), + location=location, + mode=mode, + config=config) + + self._properties: List[Property] = sorted(properties, key=lambda p: p.name) + + definitions: Optional[Dict[str, Dict[str, any]]] = dictionary.get('definitions') + inner_types_from_definitions: List[Declarable] = [] + if definitions is not None: + for name, d in definitions.items(): + _, defs = builders.type_property_build(dictionary=d, + outer_name=name, + location=location, + mode=mode, + config=config) + inner_types_from_definitions.extend(defs) + prot_names: Optional[Union[List[str], str]] = dictionary.get('protocol_name') + if prot_names is not None: + if not isinstance(prot_names, List): + prot_names = [prot_names] + if is_list_of_type(prot_names, str): + self._protocol_names: List[str] = list(map(lambda n: n + mode.name_suffix, prot_names)) + else: + self._protocol_names: List[str] = [] + else: + self._protocol_names: List[str] = [] + + self._is_deprecated: bool = dictionary.get('deprecated', False) + self._type_script_templatable: bool = dictionary.get('typescript_templatable', True) + self._generate_swift_optional_args: bool = dictionary.get('generate_swift_optional_arguments', True) + self._lang: GeneratedLanguage = config.lang + self._inner_types: List[Declarable] = inner_types_from_definitions + inner_types + for inner_type in self._inner_types: + inner_type.parent = self + self.__resolve_property_objects() + + def __str__(self): + joined = '\n'.join(map(lambda x: f'\t{self._name} property: {x}', self._properties)) + return f'Entity "{self._name}":\n{joined}' + + def __repr__(self): + return str(self) + + def __resolve_property_objects(self): + for ind in range(len(self._properties)): + self._properties[ind].property_type = self.__resolve_declaration(self._properties[ind].property_type) + + def __resolve_declaration(self, property_type: PropertyType) -> PropertyType: + if isinstance(property_type, Object) and property_type.object is None: + result: Optional[Declarable] = self.find_declaration(property_type.name) + if result is not None: + return Object(name=property_type.name, object=result, format=property_type.format) + return self.parent.__resolve_declaration(property_type) if self.parent is not None else property_type + elif isinstance(property_type, Array): + return Array(property_type=self.__resolve_declaration(property_type.property_type), + min_items=property_type.min_items, + strict_parsing=property_type.strict_parsing) + return property_type + + @property + def name(self) -> str: + return self._name + + @property + def resolved_name(self) -> str: + return self._resolved_name + + @property + def parent(self) -> Optional[Entity]: + return self._parent + + @property + def super_entities(self) -> Optional[str]: + return self._super_entities + + @property + def swift_super_protocol(self) -> Optional[str]: + return self._swift_super_protocol + + @property + def protocol_plus_super_entities(self) -> Optional[str]: + protocols = [] + if self._implemented_protocol is not None: + protocols.append(capitalize_camel_case(self._implemented_protocol.name)) + protocol_name = self.generation_mode.protocol_name(self._lang, self.resolved_prefixed_declaration) + if protocol_name is not None: + protocols.append(protocol_name) + if self.super_entities is not None: + protocols.append(self.super_entities) + if not protocols: + return None + return ', '.join(protocols) + + @property + def properties(self) -> List[Property]: + return self._properties + + @parent.setter + def parent(self, parent: Entity) -> None: + self._parent = parent + self.__resolve_property_objects() + for inner_type in self._inner_types: + inner_type.parent = self + + @property + def original_name(self) -> str: + return self._original_name + + @property + def include_in_documentation_toc(self) -> bool: + return self._include_in_documentation_toc + + @property + def generate_as_protocol(self) -> bool: + return self._name in self._protocol_names + + @property + def implemented_protocol(self) -> Optional[Entity]: + return self._implemented_protocol + + @property + def enclosing_enumeration(self) -> Optional[EntityEnumeration]: + return self._enclosing_enumeration + + @property + def inner_types(self) -> List[Declarable]: + return self._inner_types + + @property + def generation_mode(self) -> GenerationMode: + return self._generation_mode + + @property + def instance_properties(self) -> List[Property]: + return list(filter(lambda p: not isinstance(p.property_type, StaticString), self._properties)) + + @property + def all_properties_are_optional_except_default_values(self) -> bool: + all_properties_are_optional = all(p.optional for p in self.properties) + if not all_properties_are_optional: + return False + return any(p.default_value is not None for p in self.properties) + + @property + def static_type(self) -> Optional[str]: + prop = next((p for p in self._properties if p.name == 'type' and isinstance(p.property_type, StaticString)), None) + if prop is not None: + return cast(StaticString, prop.property_type).value + return None + + def description_doc(self, lang: DescriptionLanguage = DescriptionLanguage.EN) -> str: + return description_doc(self._description_object, lang, self._description) + + def __resolve_declaration_with_objects(self, + property_type: PropertyType, + global_objects: List[Declarable]) -> PropertyType: + if isinstance(property_type, Object) and property_type.object is None: + result = next((d for d in global_objects if property_type.name in [d.name, d.original_name]), None) + if result is not None: + if isinstance(result, StringEnumeration): + return Object(name=property_type.name, object=result, format=property_type.format) + else: + actual_name = property_type.name + self._generation_mode.name_suffix + valid_obj = next( + d for d in global_objects if d.name == actual_name or d.original_name == property_type.name) + return Object(name=actual_name, object=valid_obj, format=property_type.format) + return property_type + elif isinstance(property_type, Array): + return Array( + property_type=self.__resolve_declaration_with_objects(property_type=property_type.property_type, + global_objects=global_objects), + min_items=property_type.min_items, + strict_parsing=property_type.strict_parsing) + return property_type + + def resolve_dependencies(self, global_objects: List[Declarable]) -> None: + for i in range(len(self._properties)): + self._properties[i].property_type = self.__resolve_declaration_with_objects( + property_type=self._properties[i].property_type, + global_objects=global_objects) + for inner_type in self._inner_types: + inner_type.resolve_dependencies(global_objects) + + self._implemented_protocol = next((cast(Entity, d) for d in global_objects if d.name in self._protocol_names), + None) + + if self._lang is GeneratedLanguage.KOTLIN_DSL: + new_enumeration = None + for enumeration in global_objects: + if isinstance(enumeration, EntityEnumeration): + valid_names = [self._name, self._resolved_name, self._original_name] + new_enumeration = next((ent for ent in enumeration.entities if ent[0] in valid_names), None) + if new_enumeration is not None: + break + self._enclosing_enumeration = new_enumeration + + def check_dependencies_resolved(self, location: ElementLocation, stack: List[Declarable]) -> None: + if self in stack: + return + for p in self._properties: + name: Optional[str] = None + declarable: Optional[Declarable] = None + if isinstance(p.property_type, Object): + name = p.property_type.name + declarable = p.property_type.object + elif isinstance(p.property_type, Array) and isinstance(p.property_type.property_type, Object): + name = p.property_type.property_type.name + declarable = p.property_type.property_type.object + if name is not None: + if declarable is None: + raise UnresolvedReferenceError(location=location, + object_name=self._name, + field_name=p.dict_field, + unresolved_typename=name) + if declarable is self: + return + + declarable.check_dependencies_resolved(location=location, + stack=stack + [self]) + + @property + def as_json(self) -> Dict: + return { + 'type': 'entity', + 'name': self._name, + 'properties': list(map(lambda p: p.as_json, self._properties)) + } + + def find_declaration(self, name: str) -> Optional[Declarable]: + if name in [self._name, self._original_name]: + return self + result = next( + (d for d in self._inner_types if name in [d.name, d.resolved_name, d.original_name]), + None) + if result is not None: + return result + return self.parent.find_declaration(name) if self.parent is not None else None + + +class EntityEnumeration(Declarable): + def __init__(self, + name: str, + original_name: str, + include_in_documentation_toc: bool, + root_entity: bool, + generate_case_for_templates: bool, + entities: List[str], + mode: GenerationMode) -> None: + super().__init__() + self._resolved_name: str = name + self._name: str = self._resolved_name + mode.name_suffix + self._original_name: str = original_name + self._include_in_documentation_toc: bool = include_in_documentation_toc + self._root_entity: bool = root_entity + self._generate_case_for_templates: bool = generate_case_for_templates + self._resolved_entity_names: List[str] = entities + self._entities: List[Tuple[str, Optional[Declarable]]] = list(map( + lambda entity: (entity + mode.name_suffix, None), + entities + )) + self._mode = mode + + @property + def name(self) -> str: + return self._name + + @property + def resolved_name(self) -> str: + return self._resolved_name + + @property + def original_name(self) -> str: + return self._original_name + + @property + def include_in_documentation_toc(self) -> bool: + return self._include_in_documentation_toc + + @property + def parent(self) -> Optional[Entity]: + return self._parent + + @property + def mode(self) -> GenerationMode: + return self._mode + + @parent.setter + def parent(self, parent: Entity) -> None: + self._parent = parent + new_entities: List[Tuple[str, Optional[Declarable]]] = [] + for name, obj in self._entities: + new_obj = obj + if new_obj is None and self.parent is not None: + new_obj = self.parent.find_declaration(name) + new_name = name + if new_obj is not None: + new_name = new_obj.name + new_entities.append((new_name, new_obj)) + self._entities = new_entities + + @property + def entities(self) -> List[Tuple[str, Optional[Declarable]]]: + return self._entities + + @property + def entity_names(self) -> List[str]: + return list(map(lambda x: x[0], self._entities)) + + def resolve_dependencies(self, global_objects: List[Declarable]) -> None: + new_entities: List[Tuple[str, Optional[Declarable]]] = [] + for name, obj in self._entities: + new_obj = obj or next((d for d in global_objects if name in [d.name, d.resolved_name, d.original_name]), + None) + new_name = name + if new_obj is not None: + new_name = new_obj.name + new_entities.append((new_name, new_obj)) + self._entities = new_entities + + def check_dependencies_resolved(self, location: ElementLocation, stack: List[Declarable]) -> None: + if self in stack: + return + for _, entity in self._entities: + if entity is not None: + entity.check_dependencies_resolved(location=location + self._name, + stack=stack + [self]) + + @property + def as_json(self) -> Dict: + return { + 'type': 'entity_enumeration', + 'resolved_name': self._resolved_name, + 'entities': list(map(lambda x: { + 'name': x[0], + 'declarable': x[1].as_json if x[1] is not None else 'None' + }, self._entities)) + } + + def common_interface(self, lang: GeneratedLanguage) -> Optional[str]: + common_interface = self._common_interface_without_serializable + if lang is GeneratedLanguage.SWIFT: + if common_interface is not None: + common_interface = f' & {common_interface}' + else: + common_interface = '' + return f'Serializable{common_interface}' + return common_interface + + @property + def _common_interface_without_serializable(self) -> Optional[str]: + if self.entities: + interface: Optional[Entity] = cast( + Optional[Entity], + next( + (cast(Entity, e[1]).implemented_protocol for e in self.entities if isinstance(e[1], Entity)), + None + ) + ) + if interface is not None: + for entity in self.entities: + other_interface = entity[1] + if isinstance(other_interface, Entity) and other_interface.implemented_protocol is not interface: + return None + return capitalize_camel_case(interface.name) + return None + + @property + def generate_case_for_templates(self) -> bool: + return self._generate_case_for_templates + + +class StringEnumeration(Declarable): + def __init__(self, + name: str, + original_name: str, + cases: Union[List[str], List[Tuple[str, str]]], + include_documentation_toc: bool) -> None: + super().__init__() + self._name: str = name + self._original_name: str = original_name + if is_list_of_type(cases, str): + self._cases: List[Tuple[str, str]] = list(map(lambda case: (case, case), cases)) + else: + self._cases: List[Tuple[str, str]] = cases + self._include_documentation_toc: bool = include_documentation_toc + + @property + def name(self) -> str: + return self._name + + @property + def original_name(self) -> str: + return self._original_name + + @property + def resolved_name(self) -> str: + return self._name + + @property + def cases(self) -> List[Tuple[str, str]]: + return self._cases + + @property + def include_in_documentation_toc(self) -> bool: + return self._include_documentation_toc + + def resolve_dependencies(self, global_objects: List[Declarable]) -> None: + pass + + def check_dependencies_resolved(self, location: ElementLocation, stack: List[Declarable]) -> None: + pass + + @property + def as_json(self) -> Dict: + return { + 'type': 'string_enumeration', + 'name': self._name, + 'cases': list(map(lambda case: case[1], self._cases)) + } + + +def default_value(lang: GeneratedLanguage, + property_type: PropertyType, + location: ElementLocation, + dictionary: Dict[str, any]) -> Optional[str]: + generic_key = 'default_value' + specific_prefix = '' + if lang is GeneratedLanguage.KOTLIN: + specific_prefix = 'android_' + elif lang is GeneratedLanguage.KOTLIN_DSL: + specific_prefix = 'kotlin_' + elif lang in [GeneratedLanguage.SWIFT, GeneratedLanguage.DOCUMENTATION]: + specific_prefix = 'ios_' + elif lang is GeneratedLanguage.TYPE_SCRIPT: + specific_prefix = 'typescript_' + elif lang is GeneratedLanguage.PYTHON: + specific_prefix = 'python_' + specific_key = specific_prefix + generic_key + result: Optional[str] = dictionary.get(specific_key, dictionary.get(generic_key)) + if isinstance(result, str): + property_type.validate(value=result, location=location + specific_key) + return result + return None + + +class PropertyType(ABC): + @property + def supports_expressions(self) -> bool: + if isinstance(self, (Int, Double, Bool, BoolInt, String, Color, Url)): + return True + elif isinstance(self, (Dictionary, StaticString)): + return False + elif isinstance(self, Array): + return self.property_type.supports_expressions + elif isinstance(self, Object): + return isinstance(self.object, StringEnumeration) + + def validate(self, value: str, location: ElementLocation) -> None: + error = InvalidFieldRepresentationError(location, value) + if isinstance(self, Url): + if not validators.url(value): + raise error + elif isinstance(self, Color): + color_matcher = re.compile(r'^#([0-9a-fA-F]{2}){3,4}$') + if not color_matcher.fullmatch(value): + raise error + + @property + def can_be_templated(self) -> bool: + if isinstance(self, Array): + return self.property_type.can_be_templated + elif isinstance(self, Object): + return not isinstance(self.object, StringEnumeration) + else: + return False + + @property + def as_json(self) -> Dict: + if isinstance(self, (Int, Double, Bool, BoolInt, String, StaticString, Color, Url, Dictionary)): + return { + 'value': str(type(self).__name__) + } + elif isinstance(self, Array): + return { + 'type': 'array', + 'item_type': self.property_type.as_json + } + elif isinstance(self, Object): + return { + 'type': 'object', + 'name': self.name, + 'obj': self.object.as_json if self.object is not None else 'None' + } + + +@dataclass +class Int(PropertyType): + constraint: Optional[str] + + +@dataclass +class Double(PropertyType): + constraint: Optional[str] + + +@dataclass +class Bool(PropertyType): + pass + + +@dataclass +class BoolInt(PropertyType): + pass + + +@dataclass +class String(PropertyType): + min_length: int + formatted: bool + regex: Optional[re.Pattern] + enable_optimization: bool + + +@dataclass +class StaticString(PropertyType): + value: str + + +@dataclass +class Color(PropertyType): + pass + + +@dataclass +class Url(PropertyType): + schemes: Optional[List[str]] + + +@dataclass +class Dictionary(PropertyType): + pass + + +@dataclass +class Array(PropertyType): + property_type: PropertyType + min_items: int + strict_parsing: bool + + +class ObjectFormat(Enum): + DEFAULT = auto() + JSON_STRING = auto() + + +@dataclass +class Object(PropertyType): + name: str + object: Optional[Declarable] + format: ObjectFormat + + +@dataclass +class Property: + name: str + description: Optional[str] + description_translations: Dict[str, str] + dict_field: str + property_type: PropertyType + optional: bool + is_deprecated: bool + mode: GenerationMode + supports_expressions_flag: bool + default_value: Optional[str] + platforms: Optional[List[Platform]] + + def __str__(self) -> str: + return f'Property(name={self.name}, type={self.property_type})' + + def __repr__(self) -> str: + return str(self) + + @property + def supports_expressions(self) -> bool: + if self.property_type.supports_expressions: + return self.supports_expressions_flag + return False + + @property + def as_json(self) -> Dict: + return { + 'type': 'property', + 'name': self.name, + 'property_type': self.property_type.as_json + } + + def description_doc(self, lang: DescriptionLanguage = DescriptionLanguage.EN) -> str: + return description_doc(self.description_translations, lang, self.description) diff --git a/api_generator/api_generator/schema/modeling/errors.py b/api_generator/api_generator/schema/modeling/errors.py new file mode 100644 index 000000000..d378d64e8 --- /dev/null +++ b/api_generator/api_generator/schema/modeling/errors.py @@ -0,0 +1,23 @@ +from ..preprocessing.entities import ElementLocation + + +class InvalidFieldRepresentationError(Exception): + def __init__(self, location: ElementLocation, value: any): + self.location = location + self.value = value + message = f'Error {location}: invalid value "{value}"' + super(InvalidFieldRepresentationError, self).__init__(message) + + +class GenericError(Exception): + def __init__(self, location: ElementLocation, text: str): + self.location = location + self.text = text + message = f'Error {location}: "{text}"' + super(GenericError, self).__init__(message) + + +class UnsupportedFormatTypeError(Exception): + def __init__(self): + message = 'Only object type is supported as a complex string format' + super(UnsupportedFormatTypeError, self).__init__(message) diff --git a/api_generator/api_generator/schema/modeling/modeler.py b/api_generator/api_generator/schema/modeling/modeler.py new file mode 100644 index 000000000..b8fbfd9b7 --- /dev/null +++ b/api_generator/api_generator/schema/modeling/modeler.py @@ -0,0 +1,74 @@ +from typing import List, Dict, Tuple, Optional + +from ...config import Config +from ..utils import is_list_of_type, is_dict_with_keys_of_type +from ..preprocessing.entities import SchemaDirectory, SchemaFile, ElementLocation + +from . import builders +from .entities import Declarable, StringEnumeration +from .utils import ( + alias, + generate_cases_for_templates +) + + +def __generate_objects(file: SchemaFile, config: Config.GenerationConfig) -> List[Declarable]: + contents: Dict[str, any] = file.contents + name: str = file.name.replace('.json', '') + type_value: Optional[str] = contents.get('type') + location = ElementLocation(file) + if type_value == 'object': + return builders.entity_build(name=name, + dictionary=contents, + location=location, + config=config) + elif type_value == 'string' and contents.get('enum') is not None: + cases: List[str] = contents.get('enum') + if not is_list_of_type(cases, str): + raise TypeError(f'Enum at {file.name} must have format [String]') + return [StringEnumeration(name=alias(config.lang, contents) or name, + original_name=name, + cases=cases, + include_documentation_toc=contents.get('include_in_documentation_toc', False))] + elif contents.get('anyOf') is not None: + entities: List[Dict[str, any]] = contents.get('anyOf') + if not (is_list_of_type(entities, Dict) and all(is_dict_with_keys_of_type(entity, str) for entity in entities)): + raise TypeError('AnyOf must have format [[String : Any]]') + return builders.entity_enumeration_build( + entities=entities, + name=alias(config.lang, contents) or name, + original_name=name, + include_in_documentation_toc=contents.get('include_in_documentation_toc', False), + root_entity=contents.get('root_entity', False), + generate_case_for_templates=generate_cases_for_templates(config.lang, contents), + location=location, + config=config + ) + return [] + + +def __build_objects(schema_dir: SchemaDirectory, + config: Config.GenerationConfig) -> List[Tuple[ElementLocation, Declarable]]: + result: List[Tuple[ElementLocation, Declarable]] = [] + for item in schema_dir.items: + if isinstance(item, SchemaFile): + file: SchemaFile = item + objects: List[Declarable] = __generate_objects(file, config) + result.extend(map(lambda obj: (ElementLocation(file), obj), objects)) + elif isinstance(item, SchemaDirectory): + result.extend(__build_objects(item, config)) + return result + + +def build_objects(schema_dir: SchemaDirectory, config: Config.GenerationConfig) -> List[Declarable]: + result: List[Tuple[ElementLocation, Declarable]] = __build_objects(schema_dir, config) + + def get_entities() -> List[Declarable]: + return list(map(lambda pair: pair[1], result)) + + for _, entity in result: + entity.resolve_dependencies(global_objects=get_entities()) + for location, entity in result: + entity.check_dependencies_resolved(location=location, stack=[]) + + return get_entities() diff --git a/api_generator/api_generator/schema/modeling/text.py b/api_generator/api_generator/schema/modeling/text.py new file mode 100644 index 000000000..c5ed5af46 --- /dev/null +++ b/api_generator/api_generator/schema/modeling/text.py @@ -0,0 +1,54 @@ +from __future__ import annotations +from typing import Optional, Union, List +from ..utils import is_list_of_type +from ...utils import indented + + +class Text: + def __init__(self, init_lines: Optional[Union[str, List[str]]] = None, indent_width: int = 0): + self._lines = [] + self._indent_width = indent_width + if init_lines is not None: + if isinstance(init_lines, str): + self._lines.append(init_lines) + elif is_list_of_type(init_lines, str): + self._lines.extend(init_lines) + else: + raise TypeError + + def __str__(self): + def transform(string: str) -> str: + if not string or string.isspace(): + return '' + else: + return indented(string, indent_width=self.indent_width) + return '\n'.join(map(transform, self._lines)) + + def __add__(self, other) -> Text: + if isinstance(other, str): + return Text(init_lines=self._lines + [other], indent_width=self.indent_width) + elif is_list_of_type(other, str): + return Text(init_lines=self._lines + other, indent_width=self.indent_width) + elif is_list_of_type(other, Text): + sum_lines = self._lines.copy() + for other_text in other: + sum_lines.append(str(other_text)) + return Text(init_lines=sum_lines, indent_width=self.indent_width) + elif isinstance(other, Text): + return Text(init_lines=self._lines + [str(other)], indent_width=self.indent_width) + else: + raise TypeError + + def indented(self, level: int = 1, indent_width: int = 2) -> Text: + return Text(init_lines=self._lines, indent_width=self._indent_width + level * indent_width) + + @property + def indent_width(self) -> int: + return self._indent_width + + @property + def lines(self) -> List[str]: + return self._lines + + +EMPTY: Text = Text('') diff --git a/api_generator/api_generator/schema/modeling/utils.py b/api_generator/api_generator/schema/modeling/utils.py new file mode 100644 index 000000000..9d46a4136 --- /dev/null +++ b/api_generator/api_generator/schema/modeling/utils.py @@ -0,0 +1,33 @@ +from typing import Dict, Optional, List + +from ...config import GeneratedLanguage, Platform, DescriptionLanguage +from ..utils import get_value_with_optional_by_lang + + +def generate_cases_for_templates(lang: GeneratedLanguage, dictionary: Dict[str, any]) -> bool: + return get_value_with_optional_by_lang('generate_case_for_templates', lang, dictionary) or False + + +def alias(lang: GeneratedLanguage, dictionary: Dict[str, any]) -> Optional[str]: + return get_value_with_optional_by_lang('alias', lang, dictionary) + + +def fixing_reserved_typename(typename: str, lang: GeneratedLanguage) -> str: + if lang is GeneratedLanguage.SWIFT: + return 'kind' if typename.lower() == 'type' else typename + else: + return typename + + +def platforms(dictionary: Dict[str, any]) -> Optional[List[Platform]]: + platforms_list: List[str] = dictionary.get('platforms') + if platforms_list is None: + return None + return list(map(lambda raw: Platform[raw.upper()], platforms_list)) + + +def description_doc(description_translations: Dict[str, str], lang: DescriptionLanguage, description: str) -> str: + try: + return description_translations[lang.value] + except KeyError: + return description diff --git a/api_generator/api_generator/schema/preprocessing/__init__.py b/api_generator/api_generator/schema/preprocessing/__init__.py new file mode 100644 index 000000000..04a975630 --- /dev/null +++ b/api_generator/api_generator/schema/preprocessing/__init__.py @@ -0,0 +1,3 @@ +from .preprocessor import schema_preprocessing + +__all__ = [schema_preprocessing] diff --git a/api_generator/api_generator/schema/preprocessing/entities.py b/api_generator/api_generator/schema/preprocessing/entities.py new file mode 100644 index 000000000..b31c17f1e --- /dev/null +++ b/api_generator/api_generator/schema/preprocessing/entities.py @@ -0,0 +1,292 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Dict, List, Optional +import os +import json + +from ...config import GeneratedLanguage +from . import utils +from . import errors +from . import preprocessor + + +class SchemaFilesystemItem(ABC): + + def __str__(self) -> str: + return json.dumps( + obj=self.as_json, + default=lambda x: x.as_json if isinstance(x, SchemaFilesystemItem) else str(x), + indent=2 + ) + + @property + @abstractmethod + def parent_dir(self) -> SchemaFilesystemItem: + pass + + @property + @abstractmethod + def name(self) -> str: + pass + + @property + @abstractmethod + def has_unresolved_references(self) -> bool: + pass + + @abstractmethod + def merge_all_ofs(self, lang: GeneratedLanguage): + pass + + @abstractmethod + def resolve_references(self, lang: GeneratedLanguage): + pass + + @abstractmethod + def clean_unused_definitions(self): + pass + + @property + @abstractmethod + def as_json(self) -> Dict[str, any]: + pass + + +class SchemaDirectory(SchemaFilesystemItem): + def __init__(self, path: str, parent_dir: Optional[SchemaDirectory], lang: GeneratedLanguage): + self._name: str = os.path.basename(os.path.normpath(path)) + self._parent_dir: SchemaDirectory = parent_dir + self._items: List[SchemaFilesystemItem] = list( + filter( + lambda schema_file: not (isinstance(schema_file, SchemaFile) and utils.code_generation_disabled( + lang=lang, + dictionary=schema_file.contents + )), + map( + lambda content: preprocessor.internal_resolve_structure( + path=f'{path}/{content}', + lang=lang, + parent_dir=self + ), + filter( + lambda file: file.endswith('.json') or '.' not in file, + os.listdir(path) + ) + ) + ) + ) + + @property + def as_json(self) -> Dict[str, any]: + return {self._name: list(map(lambda i: i.as_json, self._items))} + + @property + def parent_dir(self) -> SchemaDirectory: + return self._parent_dir + + @property + def name(self) -> str: + return self._name + + @property + def items(self) -> List[SchemaFilesystemItem]: + return self._items + + @property + def has_unresolved_references(self) -> bool: + for item in self._items: + if item.has_unresolved_references: + return True + return False + + def merge_all_ofs(self, lang: GeneratedLanguage): + for item in self._items: + item.merge_all_ofs(lang) + + def resolve_references(self, lang: GeneratedLanguage): + while self.has_unresolved_references: + for item in self._items: + item.resolve_references(lang) + + def clean_unused_definitions(self): + for item in self._items: + item.clean_unused_definitions() + + def resolve(self, relative_path: str) -> SchemaFilesystemItem: + components = list(filter(lambda comp: comp, relative_path.split('/'))) + + if len(components) < 1: + raise errors.InvalidReferenceError + first_component = components[0] + + item: SchemaFilesystemItem + if first_component == '..': + if self._parent_dir is None: + raise errors.InvalidReferenceError + item = self._parent_dir + else: + filtered_items = list(filter(lambda it: it.name == first_component, self._items)) + if len(filtered_items) < 1: + raise errors.InvalidReferenceError + item = filtered_items[0] + + if len(components) == 1: + return item + + if not isinstance(item, SchemaDirectory): + raise errors.InvalidReferenceError + + return item.resolve(relative_path='/'.join(components[1::])) + + +class SchemaFile(SchemaFilesystemItem): + def __init__(self, name: str, parent_dir: Optional[SchemaDirectory], contents: Dict[str, any]): + self._name: str = name + self._parent_dir: Optional[SchemaDirectory] = parent_dir + self._contents: Dict[str, any] = contents + + @property + def as_json(self) -> Dict[str, any]: + return {self._name: self._contents} + + @property + def parent_dir(self) -> Optional[SchemaDirectory]: + return self._parent_dir + + @property + def name(self) -> str: + return self._name + + @property + def contents(self) -> Dict[str, any]: + return self._contents + + @property + def has_unresolved_references(self) -> bool: + return preprocessor.has_unresolved_references(self._contents) + + def resolve_references(self, lang: GeneratedLanguage): + self._contents = preprocessor.resolve_references( + dictionary=self._contents, + location=ElementLocation(file=self), + lang=lang + ) + + def merge_all_ofs(self, lang: GeneratedLanguage): + self._contents = preprocessor.merge_all_ofs( + dictionary=self._contents, + lang=lang, + location=ElementLocation(file=self) + ) + + def clean_unused_definitions(self): + self._contents = preprocessor.clean_unused_definitions(self._contents) + + +class ElementLocation: + def __init__(self, file: SchemaFile, path: List[str] = None): + self._file: SchemaFile = file + self._path: List[str] = path or [] + + def __add__(self, other): + assert isinstance(other, str) + return ElementLocation(self._file, self._path + [other]) + + def __str__(self) -> str: + joined_path = '.'.join(self._path) + return f'in {self._file.name} at "{joined_path}"' + + def __repr__(self): + return str(self) + + @property + def file(self) -> SchemaFile: + return self._file + + @property + def path(self) -> List[str]: + return self._path + + +class Reference: + def __init__(self, location: ElementLocation, ref: str): + prefix: str + self._file: SchemaFile + self._path: List[str] + self._value: Dict[str, any] + + prefix, self._file, self._path = utils.get_full_reference_location(location, ref) + + definition = preprocessor.prepend_prefix_to_references( + prefix=prefix, + dictionary=utils.enclosed_dict_for( + keys=self._path, + dictionary=self._file.contents + ) + ) + + def_type: Optional[str] = definition.get('type') + is_obj = def_type == 'object' + is_str_enum = def_type == 'string' and definition.get('enum') is not None + is_entity_enum = definition.get('anyOf') is not None + is_top_level_entity = def_type.startswith('$defined_') if isinstance(def_type, str) else False + is_referenced_from_top_level = len(location.path) == 0 + should_inline = is_referenced_from_top_level or not ( + is_obj or is_str_enum or is_entity_enum or is_top_level_entity) + + if not self._path and not should_inline: + file_first_comp = self._file.name.split('.')[0] + self._value = {'type': f'$defined_{file_first_comp}'} + return + + if def_type is None and not is_entity_enum: + self._value = definition + return + + inlining_suppressed = definition.get('suppress_inline', False) + is_unique_reference_in_same_file: bool + if self._file == location.file: + obj_name = self._path[-1] if self._path else '#' + num_of_refs = utils.number_of_references(obj_name=obj_name, dictionary=location.file.contents) + is_unique_reference_in_same_file = num_of_refs == 1 + else: + is_unique_reference_in_same_file = False + top_level_entity_name = self._file.name.replace('.json', '') + + if is_unique_reference_in_same_file and not inlining_suppressed: + typename_value = self.path[-1] if self._path else top_level_entity_name + definition |= {'$typename': typename_value} + self._value = definition + elif should_inline: + self._value = definition + else: + type_value = self.path[-1] if self._path else top_level_entity_name + self._value = {'type': f'$defined_{type_value}'} + + def __eq__(self, other) -> bool: + assert isinstance(other, Reference) + return self.file == other.file and self.path == other.path + + @property + def file(self) -> SchemaFile: + return self._file + + @property + def path(self) -> List[str]: + return self._path + + @property + def value(self) -> Dict[str, any]: + return self._value + + +class DescriptionReference: + def __init__(self, location: ElementLocation, ref: str): + file: SchemaFile + path: List[str] + _, file, path = utils.get_full_reference_location(location, ref) + self._value = utils.enclosed_dict_for(keys=path, dictionary=file.contents) + + @property + def value(self) -> Dict[str, str]: + return self._value diff --git a/api_generator/api_generator/schema/preprocessing/errors.py b/api_generator/api_generator/schema/preprocessing/errors.py new file mode 100644 index 000000000..10b0c9794 --- /dev/null +++ b/api_generator/api_generator/schema/preprocessing/errors.py @@ -0,0 +1,68 @@ +from . import entities + + +class FileAtInputPathError(Exception): + def __init__(self, path: str): + self.path = path + message = f'Found file at input path {path}' + super(FileAtInputPathError, self).__init__(message) + + +class PathNotFoundError(Exception): + def __init__(self, path: str): + self.path = path + message = f'Could not find file or directory at path {path}' + super(PathNotFoundError, self).__init__(message) + + +class NonDictionaryContentError(Exception): + def __init__(self, path: str): + self.path = path + message = f'Root JSON object in file at {path} is not an object' + super(NonDictionaryContentError, self).__init__(message) + + +class InvalidReferenceError(Exception): + def __init__(self): + message = 'Could not resolve some reference' + super(InvalidReferenceError, self).__init__(message) + + +class InvalidFileReferenceError(Exception): + def __init__(self, location: entities.ElementLocation, reference: str): + self.location: entities.ElementLocation = location + self.reference: str = reference + message = f'Could not resolve reference {reference} {location}' + super(InvalidFileReferenceError, self).__init__(message) + + +class UnsupportedCircularReferenceError(Exception): + def __init__(self, description: str): + self.description: str = description + message = f'Unsupported circular reference: {description}\nOnly circular references to classes are allowed' + super(UnsupportedCircularReferenceError, self).__init__(message) + + +class AmbiguousMergeError(Exception): + def __init__(self, location: entities.ElementLocation): + self.location: entities.ElementLocation = location + message = f'Could not merge objects {location}' + super(AmbiguousMergeError, self).__init__(message) + + +class InvalidAllOfError(Exception): + def __init__(self, location: entities.ElementLocation): + self.location: entities.ElementLocation = location + message = f'Value of allOf key is not an array of objects {location}' + super(InvalidAllOfError, self).__init__(message) + + +class UnresolvedReferenceError(Exception): + def __init__(self, location: entities.ElementLocation, object_name: str, field_name: str, unresolved_typename: str): + self.location: entities.ElementLocation = location + self.object_name: str = object_name + self.field_name: str = field_name + self.unresolved_typename: str = unresolved_typename + message = f'Property of "{object_name}" with name "{field_name}" with type "{unresolved_typename}" ' \ + f'is not defined {location}. Did you make a reference to non-root complex type?' + super(UnresolvedReferenceError, self).__init__(message) diff --git a/api_generator/api_generator/schema/preprocessing/preprocessor.py b/api_generator/api_generator/schema/preprocessing/preprocessor.py new file mode 100644 index 000000000..159eafd55 --- /dev/null +++ b/api_generator/api_generator/schema/preprocessing/preprocessor.py @@ -0,0 +1,217 @@ +from __future__ import annotations +from typing import Dict, List, Optional +from copy import deepcopy +import os +import json + +from ...config import Config, GeneratedLanguage +from ..utils import code_generation_disabled +from .utils import is_list_of_type, Key, Value, number_of_references +from . import errors +from . import entities + + +def has_unresolved_references(dictionary: Dict) -> bool: + if isinstance(dictionary.get('$ref'), str): + return True + if isinstance(dictionary.get('$description'), str): + return True + for value in dictionary.values(): + if isinstance(value, Dict) and has_unresolved_references(value): + return True + elif is_list_of_type(value, Dict): + for inner_dict in value: + if has_unresolved_references(inner_dict): + return True + return False + + +def prepend_prefix_to_references(prefix: str, dictionary: Dict[Key, any]) -> Dict[Key, Value]: + if not prefix: + return dictionary + result = deepcopy(dictionary) + reference = result.get('$ref') + if isinstance(reference, str): + new_value = prefix + reference + if type(new_value) is not Value: + raise errors.InvalidReferenceError + if not reference.startswith('#/'): + result['$ref'] = new_value + else: + for key, value in dictionary.items(): + if isinstance(value, Dict): + result[key] = prepend_prefix_to_references(prefix, value) + elif is_list_of_type(value, list_type=Dict): + result[key] = list(map(lambda element: prepend_prefix_to_references(prefix, element), value)) + return result + + +def __merge(d1: Dict[str, any], d2: Dict[str, any], location: entities.ElementLocation): + result = deepcopy(d1) + other = d2 + result.pop('allOf', None) + for key in other: + if key in result: + error = errors.AmbiguousMergeError(location=location + key) + result_val, other_val = result[key], other[key] + if isinstance(result_val, Dict) and isinstance(other_val, Dict): + result[key] = __merge(result_val, other_val, location=location + key) + + elif is_list_of_type(result_val, str) and is_list_of_type(other_val, str): + if set(result_val) & set(other_val): + raise error + + result[key] = result_val + other_val + else: + raise error + else: + result[key] = other[key] + + return result + + +def merge_all_ofs(dictionary: Dict[str, any], lang: GeneratedLanguage, location: entities.ElementLocation + ) -> Dict[str, any]: + result = deepcopy(dictionary) + for key in result: + value = dictionary.get(key) + if isinstance(value, Dict): + result[key] = merge_all_ofs(dictionary=value, lang=lang, location=location + key) + elif is_list_of_type(value, Dict): + result[key] = list(map( + lambda element: merge_all_ofs(dictionary=element, lang=lang, location=location + key), + value + )) + if 'allOf' in result: + if not isinstance(result['allOf'], List): + raise errors.InvalidAllOfError(location) + dicts_to_merge: List[Dict[str, any]] = list(filter( + lambda element: not code_generation_disabled(lang, element), + result['allOf'] + )) + for d in dicts_to_merge: + ref_str = d.get('$ref') + if isinstance(ref_str, str): + ref = entities.Reference(location, ref_str) + value = merge_all_ofs(dictionary=ref.value, lang=lang, location=location) + result = __merge(d1=result, d2=value, location=location) + else: + result = __merge(d1=result, d2=d, location=location) + result.pop('allOf', None) + return result + + +def clean_unused_definitions(dictionary: Dict[str, any]) -> Dict[str, any]: + definitions: Dict[str, any] = dictionary.get('definitions') + if definitions is None: + return dictionary + used_definitions: Dict[str, any] = dict() + for name, definition in definitions.items(): + if number_of_references(obj_name=name, dictionary=dictionary) > 0: + used_definitions[name] = definition + else: + print(f'Purging unused definition "{name}"') + dictionary['definitions'] = used_definitions + return dictionary + + +def resolve_references( + dictionary: Dict[str, any], + location: entities.ElementLocation, + lang: GeneratedLanguage, + reference_stack: List[entities.Reference] = None +) -> Dict[str, any]: + description_ref = dictionary.get('$description') + if isinstance(description_ref, str): + description = entities.DescriptionReference(location=location, ref=description_ref) + result = deepcopy(dictionary) + result['description_translations'] = description.value + result.pop('$description', None) + return result + + if reference_stack is None: + reference_stack = [] + reference = dictionary.get('$ref') + + def loop_str_transform(r: entities.Reference) -> str: + joined_path = '/'.join(r.path) + return f'{r.file.name}#{joined_path}' + + if isinstance(reference, str): + ref = entities.Reference(location=location, ref=reference) + if ref in reference_stack: + loop_str = ' -> '.join(map(loop_str_transform, reference_stack + [ref])) + print(f'Handling circular reference: {loop_str}') + + ref_type = ref.value.get('type') + if not (isinstance(ref_type, str) and ref_type.startswith('$defined_')): + raise errors.UnsupportedCircularReferenceError(description=loop_str) + return ref.value + result = deepcopy(dictionary) + result['$resolved_refs'] = result.get('$resolved_refs', []) + [reference] + result.pop('$ref', None) + return __merge(d1=result, d2=ref.value, location=location) + + resolved_refs = dictionary.get('$resolved_refs') + if not is_list_of_type(resolved_refs, list_type=str): + resolved_refs = [] + new_stack = reference_stack + list(map(lambda it: entities.Reference(location=location, ref=it), resolved_refs)) + + resolved_dict = deepcopy(dictionary) + for key in list(resolved_dict): + value = resolved_dict.get(key) + if isinstance(value, Dict): + if not code_generation_disabled(lang=lang, dictionary=value): + resolved_dict[key] = resolve_references( + dictionary=value, + location=location + key, + lang=lang, + reference_stack=new_stack + ) + else: + resolved_dict.pop(key, None) + elif is_list_of_type(value, list_type=Dict): + resolved_dict[key] = list(map( + lambda element: resolve_references(dictionary=element, lang=lang, location=location + key), + filter( + lambda element: not code_generation_disabled(lang=lang, dictionary=element), + value + ) + )) + return resolved_dict + + +def internal_resolve_structure( + path: str, + lang: GeneratedLanguage, + parent_dir: Optional[entities.SchemaDirectory] = None +) -> entities.SchemaFilesystemItem: + print(f'Processing path: {path}') + + if not os.path.exists(path): + raise errors.PathNotFoundError(path) + + if os.path.isdir(path): + return entities.SchemaDirectory(path, parent_dir, lang) + + json_data = json.loads(open(path).read()) + if not isinstance(json_data, Dict): + raise errors.NonDictionaryContentError(path) + + name = os.path.basename(os.path.normpath(path)) + return entities.SchemaFile(name=name, parent_dir=parent_dir, contents=json_data) + + +def resolve_structure(config: Config) -> entities.SchemaDirectory: + result = internal_resolve_structure(path=config.schema_path, lang=config.generation.lang) + if not isinstance(result, entities.SchemaDirectory): + raise errors.FileAtInputPathError(config.schema_path) + return result + + +def schema_preprocessing(config: Config) -> entities.SchemaDirectory: + root_directory = resolve_structure(config) + root_directory.merge_all_ofs(config.generation.lang) + root_directory.resolve_references(config.generation.lang) + root_directory.clean_unused_definitions() + return root_directory diff --git a/api_generator/api_generator/schema/preprocessing/utils.py b/api_generator/api_generator/schema/preprocessing/utils.py new file mode 100644 index 000000000..592044121 --- /dev/null +++ b/api_generator/api_generator/schema/preprocessing/utils.py @@ -0,0 +1,83 @@ +from typing import Dict, List, TypeVar, Optional +from copy import deepcopy + +from . import errors +from .entities import ElementLocation, SchemaFile +from ...config import GeneratedLanguage + +Key = TypeVar('T') +Value = TypeVar('V') + + +def enclosed_dict_for(keys: List[Key], dictionary: Dict[Key, any]) -> Dict[Key, any]: + if not keys: + return dictionary + d = deepcopy(dictionary) + for key in keys: + d = d.get(key) + return d + + +def get_value_with_optional_by_lang(key: str, lang: GeneratedLanguage, dictionary: Dict[str, any]) -> Optional[Value]: + if key in dictionary: + return dictionary[key] + return dictionary.get(f'{key}_{lang.value}') + + +def is_list_of_type(value, list_type) -> bool: + return isinstance(value, List) and all(isinstance(element, list_type) for element in value) + + +def number_of_references(obj_name: str, dictionary: Dict[str, any]) -> int: + result = 0 + for item in dictionary.values(): + if isinstance(item, Dict): + result += number_of_references(obj_name, item) + elif is_list_of_type(item, list_type=Dict): + result += sum(map(lambda element: number_of_references(obj_name=obj_name, dictionary=element), item)) + + ref = dictionary.get('$ref') + if isinstance(ref, str) and ref.endswith('/' + obj_name): + result += 1 + + def_type = dictionary.get('type') + if isinstance(def_type, str) and def_type == f'$defined_{obj_name}': + result += 1 + + return result + + +def code_generation_disabled(lang: GeneratedLanguage, dictionary: Dict[str, any]) -> bool: + value = get_value_with_optional_by_lang('code_generation_disabled', lang, dictionary) + return value or False + + +def get_full_reference_location(location: ElementLocation, ref: str) -> (str, SchemaFile, List[str]): + invalid_reference_error = errors.InvalidFileReferenceError(location, ref) + components: List[str] = ref.split('#') + + if len(components) > 2: + raise invalid_reference_error + + if len(components) == 2 and not components[0]: + path: List[str] = list(filter(None, components[1].split('/'))) + return '', location.file, path + + try: + parent_directory = location.file.parent_dir + if parent_directory is None: + raise invalid_reference_error + ref = parent_directory.resolve(components[0]) + if not isinstance(ref, SchemaFile): + raise invalid_reference_error + + if len(components) == 2: + path: List[str] = list(filter(None, components[1].split('/'))) + else: + path: List[str] = [] + + ref_dir_path = '/'.join(components[0].split('/')[:-1]) + prefix = '' if not ref_dir_path else ref_dir_path + '/' + return prefix, ref, path + except errors.InvalidReferenceError: + raise invalid_reference_error diff --git a/api_generator/api_generator/schema/utils.py b/api_generator/api_generator/schema/utils.py new file mode 100644 index 000000000..85e29652b --- /dev/null +++ b/api_generator/api_generator/schema/utils.py @@ -0,0 +1,33 @@ +from typing import Dict, List, TypeVar, Optional + +from ..config import GeneratedLanguage + +Key = TypeVar('T') +Value = TypeVar('V') + + +def enclosed_dict_for(keys: List[Key], dictionary: Dict[Key, any]) -> Dict[Key, any]: + if len(keys) == 0: + return dictionary + d = dictionary.copy() + for key in keys: + d = d.get(key) + return d + + +def get_value_with_optional_by_lang(key: str, lang: GeneratedLanguage, dictionary: Dict[str, any]) -> Optional[Value]: + if key in dictionary: + return dictionary[key] + return dictionary.get(f'{key}_{lang.value}') + + +def code_generation_disabled(lang: GeneratedLanguage, dictionary: Dict[str, any]) -> bool: + return get_value_with_optional_by_lang('code_generation_disabled', lang, dictionary) or False + + +def is_list_of_type(value, list_type) -> bool: + return isinstance(value, List) and all(isinstance(element, list_type) for element in value) + + +def is_dict_with_keys_of_type(value, key_type) -> bool: + return isinstance(value, Dict) and all(isinstance(key, key_type) for key in value) diff --git a/api_generator/api_generator/utils.py b/api_generator/api_generator/utils.py new file mode 100644 index 000000000..ca3bf6233 --- /dev/null +++ b/api_generator/api_generator/utils.py @@ -0,0 +1,69 @@ +from typing import List, Dict, Any +import re +import os +import shutil +import json + + +def __camel_case_components(string: str) -> List[str]: + result: List[str] = [] + start_component_pos = 0 + for current_pos, char in enumerate(string): + if char.isupper(): + if start_component_pos != current_pos: + result.append(string[start_component_pos:current_pos]) + start_component_pos = current_pos + result.append(string[start_component_pos::]) + return result + + +def name_components(string: str) -> List[str]: + components = [] + for component in re.split(r'[_\-:\s]+', string): + components.extend(__camel_case_components(component)) + return list(filter(lambda x: x, components)) + + +def capitalize_camel_case(string: str) -> str: + return ''.join(map(lambda component: component.capitalize(), name_components(string))) + + +def lower_camel_case(string: str) -> str: + components = name_components(string) + if not components: + return '' + return ''.join([components[0].lower()] + list(map(lambda component: component.capitalize(), components[1::]))) + + +def constant_upper_case(string: str): + return '_'.join(map(lambda s: s.upper(), name_components(string))) + + +def fixing_first_digit(string: str) -> str: + if not string: + return string + first_char = string[0] + if first_char.isdigit(): + return f'_{string}' + return string + + +def clear_content_of_directory(directory: str) -> None: + for filename in os.listdir(directory): + file_path = os.path.join(directory, filename) + try: + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + except Exception as e: + print(f'Failed to delete {file_path}. Reason: {e}') + + +def json_dict(string: str) -> Dict[str, Any]: + return json.loads(string) + + +def indented(string: str, level: int = 1, indent_width: int = 2) -> str: + indent = ' ' * level * indent_width + return indent + string.replace('\n', '\n' + indent) diff --git a/api_generator/dev_requirements.txt b/api_generator/dev_requirements.txt new file mode 100644 index 000000000..30d794e39 --- /dev/null +++ b/api_generator/dev_requirements.txt @@ -0,0 +1,3 @@ +flake8==2.5.4 +pytest==7.1.2 +deepdiff==5.8.1 diff --git a/api_generator/requirements.txt b/api_generator/requirements.txt new file mode 100644 index 000000000..b3ee7d5ca --- /dev/null +++ b/api_generator/requirements.txt @@ -0,0 +1,2 @@ +pydantic==1.9.1 +validators==0.20.0 diff --git a/api_generator/tests/configs/documentation_config.json b/api_generator/tests/configs/documentation_config.json new file mode 100644 index 000000000..d25cef5f3 --- /dev/null +++ b/api_generator/tests/configs/documentation_config.json @@ -0,0 +1,3 @@ +{ + "lang": "documentation" +} diff --git a/api_generator/tests/configs/kotlin_config.json b/api_generator/tests/configs/kotlin_config.json new file mode 100644 index 000000000..348c388a2 --- /dev/null +++ b/api_generator/tests/configs/kotlin_config.json @@ -0,0 +1,10 @@ +{ + "lang": "kotlin", + "header": "// Generated code. Do not modify.\n\npackage com.yandex.div2\n\nimport android.graphics.Color\nimport android.net.Uri\nimport androidx.annotation.ColorInt\nimport com.yandex.div.json.*\nimport com.yandex.div.json.expressions.Expression\nimport com.yandex.div.json.expressions.ExpressionsList\nimport com.yandex.div.json.schema.*\nimport com.yandex.div.core.annotations.Mockable\nimport java.io.IOException\nimport java.util.BitSet\nimport org.json.JSONObject\nimport com.yandex.div.data.*\n", + "errorsCollectors": [ + "div-data" + ], + "kotlinAnnotations": [ + "@Mockable" + ] +} diff --git a/api_generator/tests/configs/swift_config.json b/api_generator/tests/configs/swift_config.json new file mode 100644 index 000000000..770a495f0 --- /dev/null +++ b/api_generator/tests/configs/swift_config.json @@ -0,0 +1,4 @@ +{ + "lang": "swift", + "header": "// Generated code. Do not modify.\n\nimport CommonCore\nimport Foundation\nimport Serialization\n" +} diff --git a/api_generator/tests/configs/typescript_config.json b/api_generator/tests/configs/typescript_config.json new file mode 100644 index 000000000..ab91c4a15 --- /dev/null +++ b/api_generator/tests/configs/typescript_config.json @@ -0,0 +1,4 @@ +{ + "lang": "typescript", + "header": "// Generated code. Do not modify.\n\nimport { TemplateBlock } from '../blocks';\nimport { Exact, IntBoolean, NonEmptyArray } from '../helper';\nimport { Type } from '../template';\n" +} diff --git a/api_generator/tests/references/documentation/en/entity.md b/api_generator/tests/references/documentation/en/entity.md new file mode 100644 index 000000000..b8bfb97e7 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity.md @@ -0,0 +1,19 @@ +# entity +It can be one of the following types: +* [entity_with_array](entity_with_array.md#entity_with_array) +* [entity_with_array_of_nested_items](entity_with_array_of_nested_items.md#entity_with_array_of_nested_items) +* [entity_with_array_with_transform](entity_with_array_with_transform.md#entity_with_array_with_transform) +* [entity_with_complex_property](entity_with_complex_property.md#entity_with_complex_property) +* [entity_with_complex_property_with_default_value](entity_with_complex_property_with_default_value.md#entity_with_complex_property_with_default_value) +* [entity_with_entity_property](entity_with_entity_property.md#entity_with_entity_property) +* [entity_with_optional_complex_property](entity_with_optional_complex_property.md#entity_with_optional_complex_property) +* [entity_with_optional_property](entity_with_optional_property.md#entity_with_optional_property) +* [entity_with_optional_string_enum_property](entity_with_optional_string_enum_property.md#entity_with_optional_string_enum_property) +* [entity_with_property_with_default_value](entity_with_property_with_default_value.md#entity_with_property_with_default_value) +* [entity_with_required_property](entity_with_required_property.md#entity_with_required_property) +* [entity_with_simple_properties](entity_with_simple_properties.md#entity_with_simple_properties) +* [entity_with_strict_array](entity_with_strict_array.md#entity_with_strict_array) +* [entity_with_string_array_property](entity_with_string_array_property.md#entity_with_string_array_property) +* [entity_with_string_enum_property](entity_with_string_enum_property.md#entity_with_string_enum_property) +* [entity_with_string_enum_property_with_default_value](entity_with_string_enum_property_with_default_value.md#entity_with_string_enum_property_with_default_value) +* [entity_without_properties](entity_without_properties.md#entity_without_properties) diff --git a/api_generator/tests/references/documentation/en/entity_with_array.md b/api_generator/tests/references/documentation/en/entity_with_array.md new file mode 100644 index 000000000..e01d1b0bb --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_array.md @@ -0,0 +1,16 @@ +# entity_with_array +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_array", + array*: [ entity, ... ] +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `array` |

    **array**

    Required parameter.

    An array must not be empty.

    List of possible values:

  • [entity_with_array](entity_with_array.md#entity_with_array)
  • [entity_with_array_of_nested_items](entity_with_array_of_nested_items.md#entity_with_array_of_nested_items)
  • [entity_with_array_with_transform](entity_with_array_with_transform.md#entity_with_array_with_transform)
  • [entity_with_complex_property](entity_with_complex_property.md#entity_with_complex_property)
  • [entity_with_complex_property_with_default_value](entity_with_complex_property_with_default_value.md#entity_with_complex_property_with_default_value)
  • [entity_with_entity_property](entity_with_entity_property.md#entity_with_entity_property)
  • [entity_with_optional_complex_property](entity_with_optional_complex_property.md#entity_with_optional_complex_property)
  • [entity_with_optional_property](entity_with_optional_property.md#entity_with_optional_property)
  • [entity_with_optional_string_enum_property](entity_with_optional_string_enum_property.md#entity_with_optional_string_enum_property)
  • [entity_with_property_with_default_value](entity_with_property_with_default_value.md#entity_with_property_with_default_value)
  • [entity_with_required_property](entity_with_required_property.md#entity_with_required_property)
  • [entity_with_simple_properties](entity_with_simple_properties.md#entity_with_simple_properties)
  • [entity_with_strict_array](entity_with_strict_array.md#entity_with_strict_array)
  • [entity_with_string_array_property](entity_with_string_array_property.md#entity_with_string_array_property)
  • [entity_with_string_enum_property](entity_with_string_enum_property.md#entity_with_string_enum_property)
  • [entity_with_string_enum_property_with_default_value](entity_with_string_enum_property_with_default_value.md#entity_with_string_enum_property_with_default_value)
  • [entity_without_properties](entity_without_properties.md#entity_without_properties)
  • | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_array`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_array_of_nested_items.md b/api_generator/tests/references/documentation/en/entity_with_array_of_nested_items.md new file mode 100644 index 000000000..a942799c6 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_array_of_nested_items.md @@ -0,0 +1,28 @@ +# entity_with_array_of_nested_items +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_array_of_nested_items", + items*: [ + { + entity*: entity, + property*: "string" + }, + ... + ] +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `items` |

    **array**

    Required parameter.

    An array must not be empty.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_array_of_nested_items`.

    | + +### item +| Parameters | Description | +| --- | --- | +| `entity` |

    **object**

    Required parameter.

    The value has the type `entity`.

    List of possible values:

  • [entity_with_array](entity_with_array.md#entity_with_array)
  • [entity_with_array_of_nested_items](entity_with_array_of_nested_items.md#entity_with_array_of_nested_items)
  • [entity_with_array_with_transform](entity_with_array_with_transform.md#entity_with_array_with_transform)
  • [entity_with_complex_property](entity_with_complex_property.md#entity_with_complex_property)
  • [entity_with_complex_property_with_default_value](entity_with_complex_property_with_default_value.md#entity_with_complex_property_with_default_value)
  • [entity_with_entity_property](entity_with_entity_property.md#entity_with_entity_property)
  • [entity_with_optional_complex_property](entity_with_optional_complex_property.md#entity_with_optional_complex_property)
  • [entity_with_optional_property](entity_with_optional_property.md#entity_with_optional_property)
  • [entity_with_optional_string_enum_property](entity_with_optional_string_enum_property.md#entity_with_optional_string_enum_property)
  • [entity_with_property_with_default_value](entity_with_property_with_default_value.md#entity_with_property_with_default_value)
  • [entity_with_required_property](entity_with_required_property.md#entity_with_required_property)
  • [entity_with_simple_properties](entity_with_simple_properties.md#entity_with_simple_properties)
  • [entity_with_strict_array](entity_with_strict_array.md#entity_with_strict_array)
  • [entity_with_string_array_property](entity_with_string_array_property.md#entity_with_string_array_property)
  • [entity_with_string_enum_property](entity_with_string_enum_property.md#entity_with_string_enum_property)
  • [entity_with_string_enum_property_with_default_value](entity_with_string_enum_property_with_default_value.md#entity_with_string_enum_property_with_default_value)
  • [entity_without_properties](entity_without_properties.md#entity_without_properties)
  • | +| `property` |

    **string**

    Required parameter.

    A string must not be empty.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_array_with_transform.md b/api_generator/tests/references/documentation/en/entity_with_array_with_transform.md new file mode 100644 index 000000000..9684a2d91 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_array_with_transform.md @@ -0,0 +1,16 @@ +# entity_with_array_with_transform +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_array_with_transform", + array*: [ "string", ... ] +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `array` |

    **array**

    Required parameter.

    An array must not be empty.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_array_with_transform`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_complex_property.md b/api_generator/tests/references/documentation/en/entity_with_complex_property.md new file mode 100644 index 000000000..b49217131 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_complex_property.md @@ -0,0 +1,23 @@ +# entity_with_complex_property +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_complex_property", + property*: { + value*: "string" + } +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `property` |

    **object**

    Required parameter.

    The value has the type `property`.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_complex_property`.

    | + +### property +| Parameters | Description | +| --- | --- | +| `value` |

    **string**

    Required parameter.

    The value must be a valid URL.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_complex_property_with_default_value.md b/api_generator/tests/references/documentation/en/entity_with_complex_property_with_default_value.md new file mode 100644 index 000000000..095684451 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_complex_property_with_default_value.md @@ -0,0 +1,23 @@ +# entity_with_complex_property_with_default_value +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_complex_property_with_default_value", + property: { + value*: "string" + } +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `property` |

    **object**

    The value has the type `property`.

    Default value: `{ "value": "Default text" }`.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_complex_property_with_default_value`.

    | + +### property +| Parameters | Description | +| --- | --- | +| `value` |

    **string**

    Required parameter.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_entity_property.md b/api_generator/tests/references/documentation/en/entity_with_entity_property.md new file mode 100644 index 000000000..be6432b96 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_entity_property.md @@ -0,0 +1,16 @@ +# entity_with_entity_property +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_entity_property", + entity: entity +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `entity` |

    **object**

    The value has the type `entity`.

    Default value: `{ "type": "entity_with_string_enum_property", "property": "second" }`.

    List of possible values:

  • [entity_with_array](entity_with_array.md#entity_with_array)
  • [entity_with_array_of_nested_items](entity_with_array_of_nested_items.md#entity_with_array_of_nested_items)
  • [entity_with_array_with_transform](entity_with_array_with_transform.md#entity_with_array_with_transform)
  • [entity_with_complex_property](entity_with_complex_property.md#entity_with_complex_property)
  • [entity_with_complex_property_with_default_value](entity_with_complex_property_with_default_value.md#entity_with_complex_property_with_default_value)
  • [entity_with_entity_property](entity_with_entity_property.md#entity_with_entity_property)
  • [entity_with_optional_complex_property](entity_with_optional_complex_property.md#entity_with_optional_complex_property)
  • [entity_with_optional_property](entity_with_optional_property.md#entity_with_optional_property)
  • [entity_with_optional_string_enum_property](entity_with_optional_string_enum_property.md#entity_with_optional_string_enum_property)
  • [entity_with_property_with_default_value](entity_with_property_with_default_value.md#entity_with_property_with_default_value)
  • [entity_with_required_property](entity_with_required_property.md#entity_with_required_property)
  • [entity_with_simple_properties](entity_with_simple_properties.md#entity_with_simple_properties)
  • [entity_with_strict_array](entity_with_strict_array.md#entity_with_strict_array)
  • [entity_with_string_array_property](entity_with_string_array_property.md#entity_with_string_array_property)
  • [entity_with_string_enum_property](entity_with_string_enum_property.md#entity_with_string_enum_property)
  • [entity_with_string_enum_property_with_default_value](entity_with_string_enum_property_with_default_value.md#entity_with_string_enum_property_with_default_value)
  • [entity_without_properties](entity_without_properties.md#entity_without_properties)
  • | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_entity_property`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_optional_complex_property.md b/api_generator/tests/references/documentation/en/entity_with_optional_complex_property.md new file mode 100644 index 000000000..94fe7b695 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_optional_complex_property.md @@ -0,0 +1,23 @@ +# entity_with_optional_complex_property +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_optional_complex_property", + property: { + value*: "string" + } +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `property` |

    **object**

    The value has the type `property`.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_optional_complex_property`.

    | + +### property +| Parameters | Description | +| --- | --- | +| `value` |

    **string**

    Required parameter.

    The value must be a valid URL.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_optional_property.md b/api_generator/tests/references/documentation/en/entity_with_optional_property.md new file mode 100644 index 000000000..63d6c718d --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_optional_property.md @@ -0,0 +1,16 @@ +# entity_with_optional_property +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_optional_property", + property: "string" +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `property` |

    **string**

    A string must not be empty.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_optional_property`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_optional_string_enum_property.md b/api_generator/tests/references/documentation/en/entity_with_optional_string_enum_property.md new file mode 100644 index 000000000..53511b857 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_optional_string_enum_property.md @@ -0,0 +1,16 @@ +# entity_with_optional_string_enum_property +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_optional_string_enum_property", + property: "string" +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `property` |

    **string**

    Possible values: `first`, `second`.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_optional_string_enum_property`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_property_with_default_value.md b/api_generator/tests/references/documentation/en/entity_with_property_with_default_value.md new file mode 100644 index 000000000..c9f20f2c9 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_property_with_default_value.md @@ -0,0 +1,31 @@ +# entity_with_property_with_default_value +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_property_with_default_value", + int: "int", + nested: { + int: "int", + non_optional*: "string", + url: "string" + }, + url: "string" +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `int` |

    **int**

    Restriction for the value `x`: `x >= 0`.

    Default value: `0`.

    | +| `nested` |

    **object**

    non_optional is used to suppress auto-generation of default value for object with all-optional fields.

    The value has the type `nested`.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_property_with_default_value`.

    | +| `url` |

    **string**

    The value must be a valid URL.

    Allowed schemes: `https`.

    Default value: `https://yandex.ru`.

    | + +### nested +| Parameters | Description | +| --- | --- | +| `int` |

    **int**

    Restriction for the value `x`: `x >= 0`.

    Default value: `0`.

    | +| `non_optional` |

    **string**

    Required parameter.

    | +| `url` |

    **string**

    The value must be a valid URL.

    Allowed schemes: `https`.

    Default value: `https://yandex.ru`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_required_property.md b/api_generator/tests/references/documentation/en/entity_with_required_property.md new file mode 100644 index 000000000..b9d26e67b --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_required_property.md @@ -0,0 +1,16 @@ +# entity_with_required_property +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_required_property", + property*: "string" +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `property` |

    **string**

    Required parameter.

    A string must not be empty.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_required_property`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_simple_properties.md b/api_generator/tests/references/documentation/en/entity_with_simple_properties.md new file mode 100644 index 000000000..f48eaf2f9 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_simple_properties.md @@ -0,0 +1,32 @@ +# entity_with_simple_properties +Entity with simple properties. + +## JSON +```json +{ + type*: "entity_with_simple_properties", + boolean: "bool", + boolean_int: "bool_int", + color: "string", + double: "number", + id: "int", + integer: "int", + positive_integer: "int", + string: "string", + url: "string" +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `boolean` |

    **bool**

    Boolean property.

    | +| `boolean_int` |

    **bool_int**

    Parameter is deprecated

    Boolean value in numeric format.

    Available platforms: Android.

    | +| `color` |

    **string**

    Color.

    Valid formats: `#RGB`, `#ARGB`, `#RRGGBB`, `#AARRGGBB`.

    | +| `double` |

    **number**

    Floating point number.

    | +| `id` |

    **int**

    ID. Can't contain expressions.

    | +| `integer` |

    **int**

    Integer.

    | +| `positive_integer` |

    **int**

    Positive integer.

    Restriction for the value `x`: `x > 0`.

    The functionality is under development.

    | +| `string` |

    **string**

    String.

    A string must not be empty.

    Available platforms: Android, iOS, browser.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_simple_properties`.

    | +| `url` |

    **string**

    The value must be a valid URL.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_strict_array.md b/api_generator/tests/references/documentation/en/entity_with_strict_array.md new file mode 100644 index 000000000..8e93723b7 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_strict_array.md @@ -0,0 +1,16 @@ +# entity_with_strict_array +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_strict_array", + array*: [ entity, ... ] +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `array` |

    **array**

    Required parameter.

    An array must not be empty.

    Partial parsing is not allowed.

    List of possible values:

  • [entity_with_array](entity_with_array.md#entity_with_array)
  • [entity_with_array_of_nested_items](entity_with_array_of_nested_items.md#entity_with_array_of_nested_items)
  • [entity_with_array_with_transform](entity_with_array_with_transform.md#entity_with_array_with_transform)
  • [entity_with_complex_property](entity_with_complex_property.md#entity_with_complex_property)
  • [entity_with_complex_property_with_default_value](entity_with_complex_property_with_default_value.md#entity_with_complex_property_with_default_value)
  • [entity_with_entity_property](entity_with_entity_property.md#entity_with_entity_property)
  • [entity_with_optional_complex_property](entity_with_optional_complex_property.md#entity_with_optional_complex_property)
  • [entity_with_optional_property](entity_with_optional_property.md#entity_with_optional_property)
  • [entity_with_optional_string_enum_property](entity_with_optional_string_enum_property.md#entity_with_optional_string_enum_property)
  • [entity_with_property_with_default_value](entity_with_property_with_default_value.md#entity_with_property_with_default_value)
  • [entity_with_required_property](entity_with_required_property.md#entity_with_required_property)
  • [entity_with_simple_properties](entity_with_simple_properties.md#entity_with_simple_properties)
  • [entity_with_strict_array](entity_with_strict_array.md#entity_with_strict_array)
  • [entity_with_string_array_property](entity_with_string_array_property.md#entity_with_string_array_property)
  • [entity_with_string_enum_property](entity_with_string_enum_property.md#entity_with_string_enum_property)
  • [entity_with_string_enum_property_with_default_value](entity_with_string_enum_property_with_default_value.md#entity_with_string_enum_property_with_default_value)
  • [entity_without_properties](entity_without_properties.md#entity_without_properties)
  • | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_strict_array`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_string_array_property.md b/api_generator/tests/references/documentation/en/entity_with_string_array_property.md new file mode 100644 index 000000000..cbe32d3b2 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_string_array_property.md @@ -0,0 +1,16 @@ +# entity_with_string_array_property +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_string_array_property", + array*: [ "string", ... ] +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `array` |

    **array**

    Required parameter.

    An array must not be empty.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_string_array_property`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_string_enum_property.md b/api_generator/tests/references/documentation/en/entity_with_string_enum_property.md new file mode 100644 index 000000000..756e3024f --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_string_enum_property.md @@ -0,0 +1,16 @@ +# entity_with_string_enum_property +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_string_enum_property", + property*: "string" +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `property` |

    **string**

    Required parameter.

    Possible values: `first`, `second`.

    | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_string_enum_property`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_with_string_enum_property_with_default_value.md b/api_generator/tests/references/documentation/en/entity_with_string_enum_property_with_default_value.md new file mode 100644 index 000000000..675c8ff22 --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_with_string_enum_property_with_default_value.md @@ -0,0 +1,16 @@ +# entity_with_string_enum_property_with_default_value +There is no description yet. + +## JSON +```json +{ + type*: "entity_with_string_enum_property_with_default_value", + value: "string" +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_with_string_enum_property_with_default_value`.

    | +| `value` |

    **string**

    Possible values: `first`, `second`, `third`.

    Default value: `second`.

    | diff --git a/api_generator/tests/references/documentation/en/entity_without_properties.md b/api_generator/tests/references/documentation/en/entity_without_properties.md new file mode 100644 index 000000000..7e04b342c --- /dev/null +++ b/api_generator/tests/references/documentation/en/entity_without_properties.md @@ -0,0 +1,14 @@ +# entity_without_properties +There is no description yet. + +## JSON +```json +{ + type*: "entity_without_properties" +} +``` + +## Parameters +| Parameters | Description | +| --- | --- | +| `type` |

    **string**

    Required parameter.

    The value must always be `entity_without_properties`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity.md b/api_generator/tests/references/documentation/ru/entity.md new file mode 100644 index 000000000..62a11e60d --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity.md @@ -0,0 +1,19 @@ +# entity +Может быть одним из следующих типов: +* [entity_with_array](entity_with_array.md#entity_with_array) +* [entity_with_array_of_nested_items](entity_with_array_of_nested_items.md#entity_with_array_of_nested_items) +* [entity_with_array_with_transform](entity_with_array_with_transform.md#entity_with_array_with_transform) +* [entity_with_complex_property](entity_with_complex_property.md#entity_with_complex_property) +* [entity_with_complex_property_with_default_value](entity_with_complex_property_with_default_value.md#entity_with_complex_property_with_default_value) +* [entity_with_entity_property](entity_with_entity_property.md#entity_with_entity_property) +* [entity_with_optional_complex_property](entity_with_optional_complex_property.md#entity_with_optional_complex_property) +* [entity_with_optional_property](entity_with_optional_property.md#entity_with_optional_property) +* [entity_with_optional_string_enum_property](entity_with_optional_string_enum_property.md#entity_with_optional_string_enum_property) +* [entity_with_property_with_default_value](entity_with_property_with_default_value.md#entity_with_property_with_default_value) +* [entity_with_required_property](entity_with_required_property.md#entity_with_required_property) +* [entity_with_simple_properties](entity_with_simple_properties.md#entity_with_simple_properties) +* [entity_with_strict_array](entity_with_strict_array.md#entity_with_strict_array) +* [entity_with_string_array_property](entity_with_string_array_property.md#entity_with_string_array_property) +* [entity_with_string_enum_property](entity_with_string_enum_property.md#entity_with_string_enum_property) +* [entity_with_string_enum_property_with_default_value](entity_with_string_enum_property_with_default_value.md#entity_with_string_enum_property_with_default_value) +* [entity_without_properties](entity_without_properties.md#entity_without_properties) diff --git a/api_generator/tests/references/documentation/ru/entity_with_array.md b/api_generator/tests/references/documentation/ru/entity_with_array.md new file mode 100644 index 000000000..27ba3b625 --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_array.md @@ -0,0 +1,16 @@ +# entity_with_array +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_array", + array*: [ entity, ... ] +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `array` |

    **array**

    Обязательный параметр.

    Массив должен быть непустым.

    Список возможных значений:

  • [entity_with_array](entity_with_array.md#entity_with_array)
  • [entity_with_array_of_nested_items](entity_with_array_of_nested_items.md#entity_with_array_of_nested_items)
  • [entity_with_array_with_transform](entity_with_array_with_transform.md#entity_with_array_with_transform)
  • [entity_with_complex_property](entity_with_complex_property.md#entity_with_complex_property)
  • [entity_with_complex_property_with_default_value](entity_with_complex_property_with_default_value.md#entity_with_complex_property_with_default_value)
  • [entity_with_entity_property](entity_with_entity_property.md#entity_with_entity_property)
  • [entity_with_optional_complex_property](entity_with_optional_complex_property.md#entity_with_optional_complex_property)
  • [entity_with_optional_property](entity_with_optional_property.md#entity_with_optional_property)
  • [entity_with_optional_string_enum_property](entity_with_optional_string_enum_property.md#entity_with_optional_string_enum_property)
  • [entity_with_property_with_default_value](entity_with_property_with_default_value.md#entity_with_property_with_default_value)
  • [entity_with_required_property](entity_with_required_property.md#entity_with_required_property)
  • [entity_with_simple_properties](entity_with_simple_properties.md#entity_with_simple_properties)
  • [entity_with_strict_array](entity_with_strict_array.md#entity_with_strict_array)
  • [entity_with_string_array_property](entity_with_string_array_property.md#entity_with_string_array_property)
  • [entity_with_string_enum_property](entity_with_string_enum_property.md#entity_with_string_enum_property)
  • [entity_with_string_enum_property_with_default_value](entity_with_string_enum_property_with_default_value.md#entity_with_string_enum_property_with_default_value)
  • [entity_without_properties](entity_without_properties.md#entity_without_properties)
  • | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_array`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_array_of_nested_items.md b/api_generator/tests/references/documentation/ru/entity_with_array_of_nested_items.md new file mode 100644 index 000000000..4ac5fe58d --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_array_of_nested_items.md @@ -0,0 +1,28 @@ +# entity_with_array_of_nested_items +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_array_of_nested_items", + items*: [ + { + entity*: entity, + property*: "string" + }, + ... + ] +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `items` |

    **array**

    Обязательный параметр.

    Массив должен быть непустым.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_array_of_nested_items`.

    | + +### item +| Параметры | Описание | +| --- | --- | +| `entity` |

    **object**

    Обязательный параметр.

    Значение имеет тип `entity`.

    Список возможных значений:

  • [entity_with_array](entity_with_array.md#entity_with_array)
  • [entity_with_array_of_nested_items](entity_with_array_of_nested_items.md#entity_with_array_of_nested_items)
  • [entity_with_array_with_transform](entity_with_array_with_transform.md#entity_with_array_with_transform)
  • [entity_with_complex_property](entity_with_complex_property.md#entity_with_complex_property)
  • [entity_with_complex_property_with_default_value](entity_with_complex_property_with_default_value.md#entity_with_complex_property_with_default_value)
  • [entity_with_entity_property](entity_with_entity_property.md#entity_with_entity_property)
  • [entity_with_optional_complex_property](entity_with_optional_complex_property.md#entity_with_optional_complex_property)
  • [entity_with_optional_property](entity_with_optional_property.md#entity_with_optional_property)
  • [entity_with_optional_string_enum_property](entity_with_optional_string_enum_property.md#entity_with_optional_string_enum_property)
  • [entity_with_property_with_default_value](entity_with_property_with_default_value.md#entity_with_property_with_default_value)
  • [entity_with_required_property](entity_with_required_property.md#entity_with_required_property)
  • [entity_with_simple_properties](entity_with_simple_properties.md#entity_with_simple_properties)
  • [entity_with_strict_array](entity_with_strict_array.md#entity_with_strict_array)
  • [entity_with_string_array_property](entity_with_string_array_property.md#entity_with_string_array_property)
  • [entity_with_string_enum_property](entity_with_string_enum_property.md#entity_with_string_enum_property)
  • [entity_with_string_enum_property_with_default_value](entity_with_string_enum_property_with_default_value.md#entity_with_string_enum_property_with_default_value)
  • [entity_without_properties](entity_without_properties.md#entity_without_properties)
  • | +| `property` |

    **string**

    Обязательный параметр.

    Строка должна быть непустой.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_array_with_transform.md b/api_generator/tests/references/documentation/ru/entity_with_array_with_transform.md new file mode 100644 index 000000000..beefb24fd --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_array_with_transform.md @@ -0,0 +1,16 @@ +# entity_with_array_with_transform +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_array_with_transform", + array*: [ "string", ... ] +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `array` |

    **array**

    Обязательный параметр.

    Массив должен быть непустым.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_array_with_transform`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_complex_property.md b/api_generator/tests/references/documentation/ru/entity_with_complex_property.md new file mode 100644 index 000000000..75e1f2af3 --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_complex_property.md @@ -0,0 +1,23 @@ +# entity_with_complex_property +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_complex_property", + property*: { + value*: "string" + } +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `property` |

    **object**

    Обязательный параметр.

    Значение имеет тип `property`.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_complex_property`.

    | + +### property +| Параметры | Описание | +| --- | --- | +| `value` |

    **string**

    Обязательный параметр.

    Значение должно быть валидным URL.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_complex_property_with_default_value.md b/api_generator/tests/references/documentation/ru/entity_with_complex_property_with_default_value.md new file mode 100644 index 000000000..32ee1c07f --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_complex_property_with_default_value.md @@ -0,0 +1,23 @@ +# entity_with_complex_property_with_default_value +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_complex_property_with_default_value", + property: { + value*: "string" + } +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `property` |

    **object**

    Значение имеет тип `property`.

    Значение по умолчанию: `{ "value": "Default text" }`.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_complex_property_with_default_value`.

    | + +### property +| Параметры | Описание | +| --- | --- | +| `value` |

    **string**

    Обязательный параметр.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_entity_property.md b/api_generator/tests/references/documentation/ru/entity_with_entity_property.md new file mode 100644 index 000000000..cc3155bd5 --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_entity_property.md @@ -0,0 +1,16 @@ +# entity_with_entity_property +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_entity_property", + entity: entity +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `entity` |

    **object**

    Значение имеет тип `entity`.

    Значение по умолчанию: `{ "type": "entity_with_string_enum_property", "property": "second" }`.

    Список возможных значений:

  • [entity_with_array](entity_with_array.md#entity_with_array)
  • [entity_with_array_of_nested_items](entity_with_array_of_nested_items.md#entity_with_array_of_nested_items)
  • [entity_with_array_with_transform](entity_with_array_with_transform.md#entity_with_array_with_transform)
  • [entity_with_complex_property](entity_with_complex_property.md#entity_with_complex_property)
  • [entity_with_complex_property_with_default_value](entity_with_complex_property_with_default_value.md#entity_with_complex_property_with_default_value)
  • [entity_with_entity_property](entity_with_entity_property.md#entity_with_entity_property)
  • [entity_with_optional_complex_property](entity_with_optional_complex_property.md#entity_with_optional_complex_property)
  • [entity_with_optional_property](entity_with_optional_property.md#entity_with_optional_property)
  • [entity_with_optional_string_enum_property](entity_with_optional_string_enum_property.md#entity_with_optional_string_enum_property)
  • [entity_with_property_with_default_value](entity_with_property_with_default_value.md#entity_with_property_with_default_value)
  • [entity_with_required_property](entity_with_required_property.md#entity_with_required_property)
  • [entity_with_simple_properties](entity_with_simple_properties.md#entity_with_simple_properties)
  • [entity_with_strict_array](entity_with_strict_array.md#entity_with_strict_array)
  • [entity_with_string_array_property](entity_with_string_array_property.md#entity_with_string_array_property)
  • [entity_with_string_enum_property](entity_with_string_enum_property.md#entity_with_string_enum_property)
  • [entity_with_string_enum_property_with_default_value](entity_with_string_enum_property_with_default_value.md#entity_with_string_enum_property_with_default_value)
  • [entity_without_properties](entity_without_properties.md#entity_without_properties)
  • | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_entity_property`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_optional_complex_property.md b/api_generator/tests/references/documentation/ru/entity_with_optional_complex_property.md new file mode 100644 index 000000000..519c060e9 --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_optional_complex_property.md @@ -0,0 +1,23 @@ +# entity_with_optional_complex_property +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_optional_complex_property", + property: { + value*: "string" + } +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `property` |

    **object**

    Значение имеет тип `property`.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_optional_complex_property`.

    | + +### property +| Параметры | Описание | +| --- | --- | +| `value` |

    **string**

    Обязательный параметр.

    Значение должно быть валидным URL.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_optional_property.md b/api_generator/tests/references/documentation/ru/entity_with_optional_property.md new file mode 100644 index 000000000..4e53e4bd0 --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_optional_property.md @@ -0,0 +1,16 @@ +# entity_with_optional_property +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_optional_property", + property: "string" +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `property` |

    **string**

    Строка должна быть непустой.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_optional_property`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_optional_string_enum_property.md b/api_generator/tests/references/documentation/ru/entity_with_optional_string_enum_property.md new file mode 100644 index 000000000..197f8ced2 --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_optional_string_enum_property.md @@ -0,0 +1,16 @@ +# entity_with_optional_string_enum_property +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_optional_string_enum_property", + property: "string" +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `property` |

    **string**

    Возможные значения: `first`, `second`.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_optional_string_enum_property`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_property_with_default_value.md b/api_generator/tests/references/documentation/ru/entity_with_property_with_default_value.md new file mode 100644 index 000000000..588dcbdfb --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_property_with_default_value.md @@ -0,0 +1,31 @@ +# entity_with_property_with_default_value +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_property_with_default_value", + int: "int", + nested: { + int: "int", + non_optional*: "string", + url: "string" + }, + url: "string" +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `int` |

    **int**

    Ограничение для значения `x`: `x >= 0`.

    Значение по умолчанию: `0`.

    | +| `nested` |

    **object**

    non_optional используется, чтобы запретить автогенерацию дефолтного значения для объекта, все свойства которого опциональны.

    Значение имеет тип `nested`.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_property_with_default_value`.

    | +| `url` |

    **string**

    Значение должно быть валидным URL.

    Разрешенные схемы: `https`.

    Значение по умолчанию: `https://yandex.ru`.

    | + +### nested +| Параметры | Описание | +| --- | --- | +| `int` |

    **int**

    Ограничение для значения `x`: `x >= 0`.

    Значение по умолчанию: `0`.

    | +| `non_optional` |

    **string**

    Обязательный параметр.

    | +| `url` |

    **string**

    Значение должно быть валидным URL.

    Разрешенные схемы: `https`.

    Значение по умолчанию: `https://yandex.ru`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_required_property.md b/api_generator/tests/references/documentation/ru/entity_with_required_property.md new file mode 100644 index 000000000..904aebe88 --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_required_property.md @@ -0,0 +1,16 @@ +# entity_with_required_property +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_required_property", + property*: "string" +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `property` |

    **string**

    Обязательный параметр.

    Строка должна быть непустой.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_required_property`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_simple_properties.md b/api_generator/tests/references/documentation/ru/entity_with_simple_properties.md new file mode 100644 index 000000000..40a14f456 --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_simple_properties.md @@ -0,0 +1,32 @@ +# entity_with_simple_properties +Объект с простыми свойствами. + +## JSON +```json +{ + type*: "entity_with_simple_properties", + boolean: "bool", + boolean_int: "bool_int", + color: "string", + double: "number", + id: "int", + integer: "int", + positive_integer: "int", + string: "string", + url: "string" +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `boolean` |

    **bool**

    Логическое значение.

    | +| `boolean_int` |

    **bool_int**

    Параметр устарел.

    Логическое значение в числовом формате.

    Доступные платформы: Android.

    | +| `color` |

    **string**

    Цвет.

    Допустимые форматы: `#RGB`, `#ARGB`, `#RRGGBB`, `#AARRGGBB`.

    | +| `double` |

    **number**

    Число с плавающей точкой.

    | +| `id` |

    **int**

    Идентификатор. Не может содержать выражение.

    | +| `integer` |

    **int**

    Целое число.

    | +| `positive_integer` |

    **int**

    Положительное целое число.

    Ограничение для значения `x`: `x > 0`.

    Функциональность находится в разработке.

    | +| `string` |

    **string**

    Строка.

    Строка должна быть непустой.

    Доступные платформы: Android, iOS, браузер.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_simple_properties`.

    | +| `url` |

    **string**

    Значение должно быть валидным URL.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_strict_array.md b/api_generator/tests/references/documentation/ru/entity_with_strict_array.md new file mode 100644 index 000000000..669af44ab --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_strict_array.md @@ -0,0 +1,16 @@ +# entity_with_strict_array +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_strict_array", + array*: [ entity, ... ] +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `array` |

    **array**

    Обязательный параметр.

    Массив должен быть непустым.

    Не допускается частичный парсинг.

    Список возможных значений:

  • [entity_with_array](entity_with_array.md#entity_with_array)
  • [entity_with_array_of_nested_items](entity_with_array_of_nested_items.md#entity_with_array_of_nested_items)
  • [entity_with_array_with_transform](entity_with_array_with_transform.md#entity_with_array_with_transform)
  • [entity_with_complex_property](entity_with_complex_property.md#entity_with_complex_property)
  • [entity_with_complex_property_with_default_value](entity_with_complex_property_with_default_value.md#entity_with_complex_property_with_default_value)
  • [entity_with_entity_property](entity_with_entity_property.md#entity_with_entity_property)
  • [entity_with_optional_complex_property](entity_with_optional_complex_property.md#entity_with_optional_complex_property)
  • [entity_with_optional_property](entity_with_optional_property.md#entity_with_optional_property)
  • [entity_with_optional_string_enum_property](entity_with_optional_string_enum_property.md#entity_with_optional_string_enum_property)
  • [entity_with_property_with_default_value](entity_with_property_with_default_value.md#entity_with_property_with_default_value)
  • [entity_with_required_property](entity_with_required_property.md#entity_with_required_property)
  • [entity_with_simple_properties](entity_with_simple_properties.md#entity_with_simple_properties)
  • [entity_with_strict_array](entity_with_strict_array.md#entity_with_strict_array)
  • [entity_with_string_array_property](entity_with_string_array_property.md#entity_with_string_array_property)
  • [entity_with_string_enum_property](entity_with_string_enum_property.md#entity_with_string_enum_property)
  • [entity_with_string_enum_property_with_default_value](entity_with_string_enum_property_with_default_value.md#entity_with_string_enum_property_with_default_value)
  • [entity_without_properties](entity_without_properties.md#entity_without_properties)
  • | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_strict_array`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_string_array_property.md b/api_generator/tests/references/documentation/ru/entity_with_string_array_property.md new file mode 100644 index 000000000..0a1a6134d --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_string_array_property.md @@ -0,0 +1,16 @@ +# entity_with_string_array_property +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_string_array_property", + array*: [ "string", ... ] +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `array` |

    **array**

    Обязательный параметр.

    Массив должен быть непустым.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_string_array_property`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_string_enum_property.md b/api_generator/tests/references/documentation/ru/entity_with_string_enum_property.md new file mode 100644 index 000000000..ebcbbcf45 --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_string_enum_property.md @@ -0,0 +1,16 @@ +# entity_with_string_enum_property +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_string_enum_property", + property*: "string" +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `property` |

    **string**

    Обязательный параметр.

    Возможные значения: `first`, `second`.

    | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_string_enum_property`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_with_string_enum_property_with_default_value.md b/api_generator/tests/references/documentation/ru/entity_with_string_enum_property_with_default_value.md new file mode 100644 index 000000000..18ef1a60b --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_with_string_enum_property_with_default_value.md @@ -0,0 +1,16 @@ +# entity_with_string_enum_property_with_default_value +Описания пока нет + +## JSON +```json +{ + type*: "entity_with_string_enum_property_with_default_value", + value: "string" +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_with_string_enum_property_with_default_value`.

    | +| `value` |

    **string**

    Возможные значения: `first`, `second`, `third`.

    Значение по умолчанию: `second`.

    | diff --git a/api_generator/tests/references/documentation/ru/entity_without_properties.md b/api_generator/tests/references/documentation/ru/entity_without_properties.md new file mode 100644 index 000000000..0abd92f2d --- /dev/null +++ b/api_generator/tests/references/documentation/ru/entity_without_properties.md @@ -0,0 +1,14 @@ +# entity_without_properties +Описания пока нет + +## JSON +```json +{ + type*: "entity_without_properties" +} +``` + +## Параметры +| Параметры | Описание | +| --- | --- | +| `type` |

    **string**

    Обязательный параметр.

    Значение всегда должно равняться `entity_without_properties`.

    | diff --git a/api_generator/tests/references/documentation/toc.yaml b/api_generator/tests/references/documentation/toc.yaml new file mode 100644 index 000000000..f16fdd1d6 --- /dev/null +++ b/api_generator/tests/references/documentation/toc.yaml @@ -0,0 +1,57 @@ +title: +href: +items: + -name: entity + href: concepts/divs/entity.md + hidden: true + -name: entity_with_array + href: concepts/divs/entity_with_array.md + hidden: true + -name: entity_with_array_of_nested_items + href: concepts/divs/entity_with_array_of_nested_items.md + hidden: true + -name: entity_with_array_with_transform + href: concepts/divs/entity_with_array_with_transform.md + hidden: true + -name: entity_with_complex_property + href: concepts/divs/entity_with_complex_property.md + hidden: true + -name: entity_with_complex_property_with_default_value + href: concepts/divs/entity_with_complex_property_with_default_value.md + hidden: true + -name: entity_with_entity_property + href: concepts/divs/entity_with_entity_property.md + hidden: true + -name: entity_with_optional_complex_property + href: concepts/divs/entity_with_optional_complex_property.md + hidden: true + -name: entity_with_optional_property + href: concepts/divs/entity_with_optional_property.md + hidden: true + -name: entity_with_optional_string_enum_property + href: concepts/divs/entity_with_optional_string_enum_property.md + hidden: true + -name: entity_with_required_property + href: concepts/divs/entity_with_required_property.md + hidden: true + -name: entity_with_strict_array + href: concepts/divs/entity_with_strict_array.md + hidden: true + -name: entity_with_string_array_property + href: concepts/divs/entity_with_string_array_property.md + hidden: true + -name: entity_with_string_enum_property + href: concepts/divs/entity_with_string_enum_property.md + hidden: true + -name: entity_with_string_enum_property_with_default_value + href: concepts/divs/entity_with_string_enum_property_with_default_value.md + hidden: true + -name: entity_without_properties + href: concepts/divs/entity_without_properties.md + hidden: true + -name: entity_with_property_with_default_value + href: concepts/divs/entity_with_property_with_default_value.md + hidden: true + -name: entity_with_simple_properties + href: concepts/divs/entity_with_simple_properties.md + hidden: true \ No newline at end of file diff --git a/api_generator/tests/references/kotlin/Entity.kt b/api_generator/tests/references/kotlin/Entity.kt new file mode 100644 index 000000000..73e97c51c --- /dev/null +++ b/api_generator/tests/references/kotlin/Entity.kt @@ -0,0 +1,117 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +sealed class Entity : JSONSerializable { + class WithArray(val value: EntityWithArray) : Entity() + class WithArrayOfNestedItems(val value: EntityWithArrayOfNestedItems) : Entity() + class WithArrayWithTransform(val value: EntityWithArrayWithTransform) : Entity() + class WithComplexProperty(val value: EntityWithComplexProperty) : Entity() + class WithComplexPropertyWithDefaultValue(val value: EntityWithComplexPropertyWithDefaultValue) : Entity() + class WithEntityProperty(val value: EntityWithEntityProperty) : Entity() + class WithOptionalComplexProperty(val value: EntityWithOptionalComplexProperty) : Entity() + class WithOptionalProperty(val value: EntityWithOptionalProperty) : Entity() + class WithOptionalStringEnumProperty(val value: EntityWithOptionalStringEnumProperty) : Entity() + class WithPropertyWithDefaultValue(val value: EntityWithPropertyWithDefaultValue) : Entity() + class WithRequiredProperty(val value: EntityWithRequiredProperty) : Entity() + class WithSimpleProperties(val value: EntityWithSimpleProperties) : Entity() + class WithStrictArray(val value: EntityWithStrictArray) : Entity() + class WithStringArrayProperty(val value: EntityWithStringArrayProperty) : Entity() + class WithStringEnumProperty(val value: EntityWithStringEnumProperty) : Entity() + class WithStringEnumPropertyWithDefaultValue(val value: EntityWithStringEnumPropertyWithDefaultValue) : Entity() + class WithoutProperties(val value: EntityWithoutProperties) : Entity() + + fun value(): Any { + return when (this) { + is WithArray -> value + is WithArrayOfNestedItems -> value + is WithArrayWithTransform -> value + is WithComplexProperty -> value + is WithComplexPropertyWithDefaultValue -> value + is WithEntityProperty -> value + is WithOptionalComplexProperty -> value + is WithOptionalProperty -> value + is WithOptionalStringEnumProperty -> value + is WithPropertyWithDefaultValue -> value + is WithRequiredProperty -> value + is WithSimpleProperties -> value + is WithStrictArray -> value + is WithStringArrayProperty -> value + is WithStringEnumProperty -> value + is WithStringEnumPropertyWithDefaultValue -> value + is WithoutProperties -> value + } + } + + override fun writeToJSON(): JSONObject { + return when (this) { + is WithArray -> value.writeToJSON() + is WithArrayOfNestedItems -> value.writeToJSON() + is WithArrayWithTransform -> value.writeToJSON() + is WithComplexProperty -> value.writeToJSON() + is WithComplexPropertyWithDefaultValue -> value.writeToJSON() + is WithEntityProperty -> value.writeToJSON() + is WithOptionalComplexProperty -> value.writeToJSON() + is WithOptionalProperty -> value.writeToJSON() + is WithOptionalStringEnumProperty -> value.writeToJSON() + is WithPropertyWithDefaultValue -> value.writeToJSON() + is WithRequiredProperty -> value.writeToJSON() + is WithSimpleProperties -> value.writeToJSON() + is WithStrictArray -> value.writeToJSON() + is WithStringArrayProperty -> value.writeToJSON() + is WithStringEnumProperty -> value.writeToJSON() + is WithStringEnumPropertyWithDefaultValue -> value.writeToJSON() + is WithoutProperties -> value.writeToJSON() + } + } + + companion object { + @Throws(ParsingException::class) + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): Entity { + val logger = env.logger + val type: String = json.read("type", logger = logger, env = env) + when (type) { + EntityWithArray.TYPE -> return WithArray(EntityWithArray(env, json)) + EntityWithArrayOfNestedItems.TYPE -> return WithArrayOfNestedItems(EntityWithArrayOfNestedItems(env, json)) + EntityWithArrayWithTransform.TYPE -> return WithArrayWithTransform(EntityWithArrayWithTransform(env, json)) + EntityWithComplexProperty.TYPE -> return WithComplexProperty(EntityWithComplexProperty(env, json)) + EntityWithComplexPropertyWithDefaultValue.TYPE -> return WithComplexPropertyWithDefaultValue(EntityWithComplexPropertyWithDefaultValue(env, json)) + EntityWithEntityProperty.TYPE -> return WithEntityProperty(EntityWithEntityProperty(env, json)) + EntityWithOptionalComplexProperty.TYPE -> return WithOptionalComplexProperty(EntityWithOptionalComplexProperty(env, json)) + EntityWithOptionalProperty.TYPE -> return WithOptionalProperty(EntityWithOptionalProperty(env, json)) + EntityWithOptionalStringEnumProperty.TYPE -> return WithOptionalStringEnumProperty(EntityWithOptionalStringEnumProperty(env, json)) + EntityWithPropertyWithDefaultValue.TYPE -> return WithPropertyWithDefaultValue(EntityWithPropertyWithDefaultValue(env, json)) + EntityWithRequiredProperty.TYPE -> return WithRequiredProperty(EntityWithRequiredProperty(env, json)) + EntityWithSimpleProperties.TYPE -> return WithSimpleProperties(EntityWithSimpleProperties(env, json)) + EntityWithStrictArray.TYPE -> return WithStrictArray(EntityWithStrictArray(env, json)) + EntityWithStringArrayProperty.TYPE -> return WithStringArrayProperty(EntityWithStringArrayProperty(env, json)) + EntityWithStringEnumProperty.TYPE -> return WithStringEnumProperty(EntityWithStringEnumProperty(env, json)) + EntityWithStringEnumPropertyWithDefaultValue.TYPE -> return WithStringEnumPropertyWithDefaultValue(EntityWithStringEnumPropertyWithDefaultValue(env, json)) + EntityWithoutProperties.TYPE -> return WithoutProperties(EntityWithoutProperties(env, json)) + } + val template = env.templates.getOrThrow(type, json) as? EntityTemplate + if (template != null) { + return template.resolve(env, json) + } else { + throw typeMismatch(json = json, key = "type", value = type) + } + } + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> Entity(env, json = it) } + } +} diff --git a/api_generator/tests/references/kotlin/EntityTemplate.kt b/api_generator/tests/references/kotlin/EntityTemplate.kt new file mode 100644 index 000000000..f97cb07b7 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityTemplate.kt @@ -0,0 +1,161 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +sealed class EntityTemplate : JSONSerializable, JsonTemplate { + class WithArray(val value: EntityWithArrayTemplate) : EntityTemplate() + class WithArrayOfNestedItems(val value: EntityWithArrayOfNestedItemsTemplate) : EntityTemplate() + class WithArrayWithTransform(val value: EntityWithArrayWithTransformTemplate) : EntityTemplate() + class WithComplexProperty(val value: EntityWithComplexPropertyTemplate) : EntityTemplate() + class WithComplexPropertyWithDefaultValue(val value: EntityWithComplexPropertyWithDefaultValueTemplate) : EntityTemplate() + class WithEntityProperty(val value: EntityWithEntityPropertyTemplate) : EntityTemplate() + class WithOptionalComplexProperty(val value: EntityWithOptionalComplexPropertyTemplate) : EntityTemplate() + class WithOptionalProperty(val value: EntityWithOptionalPropertyTemplate) : EntityTemplate() + class WithOptionalStringEnumProperty(val value: EntityWithOptionalStringEnumPropertyTemplate) : EntityTemplate() + class WithPropertyWithDefaultValue(val value: EntityWithPropertyWithDefaultValueTemplate) : EntityTemplate() + class WithRequiredProperty(val value: EntityWithRequiredPropertyTemplate) : EntityTemplate() + class WithSimpleProperties(val value: EntityWithSimplePropertiesTemplate) : EntityTemplate() + class WithStrictArray(val value: EntityWithStrictArrayTemplate) : EntityTemplate() + class WithStringArrayProperty(val value: EntityWithStringArrayPropertyTemplate) : EntityTemplate() + class WithStringEnumProperty(val value: EntityWithStringEnumPropertyTemplate) : EntityTemplate() + class WithStringEnumPropertyWithDefaultValue(val value: EntityWithStringEnumPropertyWithDefaultValueTemplate) : EntityTemplate() + class WithoutProperties(val value: EntityWithoutPropertiesTemplate) : EntityTemplate() + + fun value(): Any { + return when (this) { + is WithArray -> value + is WithArrayOfNestedItems -> value + is WithArrayWithTransform -> value + is WithComplexProperty -> value + is WithComplexPropertyWithDefaultValue -> value + is WithEntityProperty -> value + is WithOptionalComplexProperty -> value + is WithOptionalProperty -> value + is WithOptionalStringEnumProperty -> value + is WithPropertyWithDefaultValue -> value + is WithRequiredProperty -> value + is WithSimpleProperties -> value + is WithStrictArray -> value + is WithStringArrayProperty -> value + is WithStringEnumProperty -> value + is WithStringEnumPropertyWithDefaultValue -> value + is WithoutProperties -> value + } + } + + override fun writeToJSON(): JSONObject { + return when (this) { + is WithArray -> value.writeToJSON() + is WithArrayOfNestedItems -> value.writeToJSON() + is WithArrayWithTransform -> value.writeToJSON() + is WithComplexProperty -> value.writeToJSON() + is WithComplexPropertyWithDefaultValue -> value.writeToJSON() + is WithEntityProperty -> value.writeToJSON() + is WithOptionalComplexProperty -> value.writeToJSON() + is WithOptionalProperty -> value.writeToJSON() + is WithOptionalStringEnumProperty -> value.writeToJSON() + is WithPropertyWithDefaultValue -> value.writeToJSON() + is WithRequiredProperty -> value.writeToJSON() + is WithSimpleProperties -> value.writeToJSON() + is WithStrictArray -> value.writeToJSON() + is WithStringArrayProperty -> value.writeToJSON() + is WithStringEnumProperty -> value.writeToJSON() + is WithStringEnumPropertyWithDefaultValue -> value.writeToJSON() + is WithoutProperties -> value.writeToJSON() + } + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): Entity { + return when (this) { + is WithArray -> Entity.WithArray(value.resolve(env, data)) + is WithArrayOfNestedItems -> Entity.WithArrayOfNestedItems(value.resolve(env, data)) + is WithArrayWithTransform -> Entity.WithArrayWithTransform(value.resolve(env, data)) + is WithComplexProperty -> Entity.WithComplexProperty(value.resolve(env, data)) + is WithComplexPropertyWithDefaultValue -> Entity.WithComplexPropertyWithDefaultValue(value.resolve(env, data)) + is WithEntityProperty -> Entity.WithEntityProperty(value.resolve(env, data)) + is WithOptionalComplexProperty -> Entity.WithOptionalComplexProperty(value.resolve(env, data)) + is WithOptionalProperty -> Entity.WithOptionalProperty(value.resolve(env, data)) + is WithOptionalStringEnumProperty -> Entity.WithOptionalStringEnumProperty(value.resolve(env, data)) + is WithPropertyWithDefaultValue -> Entity.WithPropertyWithDefaultValue(value.resolve(env, data)) + is WithRequiredProperty -> Entity.WithRequiredProperty(value.resolve(env, data)) + is WithSimpleProperties -> Entity.WithSimpleProperties(value.resolve(env, data)) + is WithStrictArray -> Entity.WithStrictArray(value.resolve(env, data)) + is WithStringArrayProperty -> Entity.WithStringArrayProperty(value.resolve(env, data)) + is WithStringEnumProperty -> Entity.WithStringEnumProperty(value.resolve(env, data)) + is WithStringEnumPropertyWithDefaultValue -> Entity.WithStringEnumPropertyWithDefaultValue(value.resolve(env, data)) + is WithoutProperties -> Entity.WithoutProperties(value.resolve(env, data)) + } + } + + val type: String + get() { + return when (this) { + is WithArray -> EntityWithArrayTemplate.TYPE + is WithArrayOfNestedItems -> EntityWithArrayOfNestedItemsTemplate.TYPE + is WithArrayWithTransform -> EntityWithArrayWithTransformTemplate.TYPE + is WithComplexProperty -> EntityWithComplexPropertyTemplate.TYPE + is WithComplexPropertyWithDefaultValue -> EntityWithComplexPropertyWithDefaultValueTemplate.TYPE + is WithEntityProperty -> EntityWithEntityPropertyTemplate.TYPE + is WithOptionalComplexProperty -> EntityWithOptionalComplexPropertyTemplate.TYPE + is WithOptionalProperty -> EntityWithOptionalPropertyTemplate.TYPE + is WithOptionalStringEnumProperty -> EntityWithOptionalStringEnumPropertyTemplate.TYPE + is WithPropertyWithDefaultValue -> EntityWithPropertyWithDefaultValueTemplate.TYPE + is WithRequiredProperty -> EntityWithRequiredPropertyTemplate.TYPE + is WithSimpleProperties -> EntityWithSimplePropertiesTemplate.TYPE + is WithStrictArray -> EntityWithStrictArrayTemplate.TYPE + is WithStringArrayProperty -> EntityWithStringArrayPropertyTemplate.TYPE + is WithStringEnumProperty -> EntityWithStringEnumPropertyTemplate.TYPE + is WithStringEnumPropertyWithDefaultValue -> EntityWithStringEnumPropertyWithDefaultValueTemplate.TYPE + is WithoutProperties -> EntityWithoutPropertiesTemplate.TYPE + } + } + + companion object { + @Throws(ParsingException::class) + operator fun invoke( + env: ParsingEnvironment, + topLevel: Boolean = false, + json: JSONObject + ): EntityTemplate { + val logger = env.logger + val receivedType: String = json.read("type", logger = logger, env = env) + val parent = env.templates[receivedType] as? EntityTemplate + val type = parent?.type ?: receivedType + when (type) { + EntityWithArrayTemplate.TYPE -> return WithArray(EntityWithArrayTemplate(env, parent?.value() as EntityWithArrayTemplate?, topLevel, json)) + EntityWithArrayOfNestedItemsTemplate.TYPE -> return WithArrayOfNestedItems(EntityWithArrayOfNestedItemsTemplate(env, parent?.value() as EntityWithArrayOfNestedItemsTemplate?, topLevel, json)) + EntityWithArrayWithTransformTemplate.TYPE -> return WithArrayWithTransform(EntityWithArrayWithTransformTemplate(env, parent?.value() as EntityWithArrayWithTransformTemplate?, topLevel, json)) + EntityWithComplexPropertyTemplate.TYPE -> return WithComplexProperty(EntityWithComplexPropertyTemplate(env, parent?.value() as EntityWithComplexPropertyTemplate?, topLevel, json)) + EntityWithComplexPropertyWithDefaultValueTemplate.TYPE -> return WithComplexPropertyWithDefaultValue(EntityWithComplexPropertyWithDefaultValueTemplate(env, parent?.value() as EntityWithComplexPropertyWithDefaultValueTemplate?, topLevel, json)) + EntityWithEntityPropertyTemplate.TYPE -> return WithEntityProperty(EntityWithEntityPropertyTemplate(env, parent?.value() as EntityWithEntityPropertyTemplate?, topLevel, json)) + EntityWithOptionalComplexPropertyTemplate.TYPE -> return WithOptionalComplexProperty(EntityWithOptionalComplexPropertyTemplate(env, parent?.value() as EntityWithOptionalComplexPropertyTemplate?, topLevel, json)) + EntityWithOptionalPropertyTemplate.TYPE -> return WithOptionalProperty(EntityWithOptionalPropertyTemplate(env, parent?.value() as EntityWithOptionalPropertyTemplate?, topLevel, json)) + EntityWithOptionalStringEnumPropertyTemplate.TYPE -> return WithOptionalStringEnumProperty(EntityWithOptionalStringEnumPropertyTemplate(env, parent?.value() as EntityWithOptionalStringEnumPropertyTemplate?, topLevel, json)) + EntityWithPropertyWithDefaultValueTemplate.TYPE -> return WithPropertyWithDefaultValue(EntityWithPropertyWithDefaultValueTemplate(env, parent?.value() as EntityWithPropertyWithDefaultValueTemplate?, topLevel, json)) + EntityWithRequiredPropertyTemplate.TYPE -> return WithRequiredProperty(EntityWithRequiredPropertyTemplate(env, parent?.value() as EntityWithRequiredPropertyTemplate?, topLevel, json)) + EntityWithSimplePropertiesTemplate.TYPE -> return WithSimpleProperties(EntityWithSimplePropertiesTemplate(env, parent?.value() as EntityWithSimplePropertiesTemplate?, topLevel, json)) + EntityWithStrictArrayTemplate.TYPE -> return WithStrictArray(EntityWithStrictArrayTemplate(env, parent?.value() as EntityWithStrictArrayTemplate?, topLevel, json)) + EntityWithStringArrayPropertyTemplate.TYPE -> return WithStringArrayProperty(EntityWithStringArrayPropertyTemplate(env, parent?.value() as EntityWithStringArrayPropertyTemplate?, topLevel, json)) + EntityWithStringEnumPropertyTemplate.TYPE -> return WithStringEnumProperty(EntityWithStringEnumPropertyTemplate(env, parent?.value() as EntityWithStringEnumPropertyTemplate?, topLevel, json)) + EntityWithStringEnumPropertyWithDefaultValueTemplate.TYPE -> return WithStringEnumPropertyWithDefaultValue(EntityWithStringEnumPropertyWithDefaultValueTemplate(env, parent?.value() as EntityWithStringEnumPropertyWithDefaultValueTemplate?, topLevel, json)) + EntityWithoutPropertiesTemplate.TYPE -> return WithoutProperties(EntityWithoutPropertiesTemplate(env, parent?.value() as EntityWithoutPropertiesTemplate?, topLevel, json)) + else -> throw typeMismatch(json = json, key = "type", value = type) + } + } + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityTemplate(env, json = it) } + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithArray.kt b/api_generator/tests/references/kotlin/EntityWithArray.kt new file mode 100644 index 000000000..296348138 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithArray.kt @@ -0,0 +1,47 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithArray( + @JvmField final val array: List, // at least 1 elements +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "array", value = array) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_array" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithArray { + val logger = env.logger + return EntityWithArray( + array = JsonParser.readList(json, "array", Entity.CREATOR, ARRAY_VALIDATOR, logger, env) + ) + } + + private val ARRAY_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithArray(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithArrayOfNestedItems.kt b/api_generator/tests/references/kotlin/EntityWithArrayOfNestedItems.kt new file mode 100644 index 000000000..eba5672bd --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithArrayOfNestedItems.kt @@ -0,0 +1,79 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithArrayOfNestedItems( + @JvmField final val items: List, // at least 1 elements +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "items", value = items) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_array_of_nested_items" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithArrayOfNestedItems { + val logger = env.logger + return EntityWithArrayOfNestedItems( + items = JsonParser.readList(json, "items", Item.CREATOR, ITEMS_VALIDATOR, logger, env) + ) + } + + private val ITEMS_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithArrayOfNestedItems(env, json = it) } + } + + + @Mockable + class Item( + @JvmField final val entity: Entity, + @JvmField final val property: Expression, // at least 1 char + ) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "entity", value = entity) + json.writeExpression(key = "property", value = property) + return json + } + + companion object { + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): Item { + val logger = env.logger + return Item( + entity = JsonParser.read(json, "entity", Entity.CREATOR, logger, env), + property = JsonParser.readExpression(json, "property", PROPERTY_VALIDATOR, logger, env, TYPE_HELPER_STRING) + ) + } + + private val PROPERTY_TEMPLATE_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + private val PROPERTY_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> Item(env, json = it) } + } + + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithArrayOfNestedItemsTemplate.kt b/api_generator/tests/references/kotlin/EntityWithArrayOfNestedItemsTemplate.kt new file mode 100644 index 000000000..db47ee7e9 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithArrayOfNestedItemsTemplate.kt @@ -0,0 +1,99 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithArrayOfNestedItemsTemplate : JSONSerializable, JsonTemplate { + @JvmField final val items: Field> // at least 1 elements + + constructor ( + env: ParsingEnvironment, + parent: EntityWithArrayOfNestedItemsTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + items = JsonTemplateParser.readListField(json, "items", topLevel, parent?.items, ItemTemplate.CREATOR, ITEMS_TEMPLATE_VALIDATOR, logger, env) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithArrayOfNestedItems { + return EntityWithArrayOfNestedItems( + items = items.resolveTemplateList(env = env, key = "items", data = data, ITEMS_VALIDATOR, reader = ITEMS_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeField(key = "items", field = items) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_array_of_nested_items" + + private val ITEMS_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + private val ITEMS_TEMPLATE_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + + val ITEMS_READER: Reader> = { key, json, env -> JsonParser.readList(json, key, EntityWithArrayOfNestedItems.Item.CREATOR, ITEMS_VALIDATOR, env.logger, env) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithArrayOfNestedItemsTemplate(env, json = it) } + } + + + @Mockable + class ItemTemplate : JSONSerializable, JsonTemplate { + @JvmField final val entity: Field + @JvmField final val property: Field> // at least 1 char + + constructor ( + env: ParsingEnvironment, + parent: ItemTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + entity = JsonTemplateParser.readField(json, "entity", topLevel, parent?.entity, EntityTemplate.CREATOR, logger, env) + property = JsonTemplateParser.readFieldWithExpression(json, "property", topLevel, parent?.property, PROPERTY_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_STRING) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithArrayOfNestedItems.Item { + return EntityWithArrayOfNestedItems.Item( + entity = entity.resolveTemplate(env = env, key = "entity", data = data, reader = ENTITY_READER), + property = property.resolve(env = env, key = "property", data = data, reader = PROPERTY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeField(key = "entity", field = entity) + json.writeFieldWithExpression(key = "property", field = property) + return json + } + + companion object { + private val PROPERTY_TEMPLATE_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + private val PROPERTY_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + + val ENTITY_READER: Reader = { key, json, env -> JsonParser.read(json, key, Entity.CREATOR, env.logger, env) } + val PROPERTY_READER: Reader> = { key, json, env -> JsonParser.readExpression(json, key, PROPERTY_VALIDATOR, env.logger, env, TYPE_HELPER_STRING) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> ItemTemplate(env, json = it) } + } + + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithArrayTemplate.kt b/api_generator/tests/references/kotlin/EntityWithArrayTemplate.kt new file mode 100644 index 000000000..35c71516c --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithArrayTemplate.kt @@ -0,0 +1,57 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithArrayTemplate : JSONSerializable, JsonTemplate { + @JvmField final val array: Field> // at least 1 elements + + constructor ( + env: ParsingEnvironment, + parent: EntityWithArrayTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + array = JsonTemplateParser.readListField(json, "array", topLevel, parent?.array, EntityTemplate.CREATOR, ARRAY_TEMPLATE_VALIDATOR, logger, env) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithArray { + return EntityWithArray( + array = array.resolveTemplateList(env = env, key = "array", data = data, ARRAY_VALIDATOR, reader = ARRAY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeField(key = "array", field = array) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_array" + + private val ARRAY_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + private val ARRAY_TEMPLATE_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + + val ARRAY_READER: Reader> = { key, json, env -> JsonParser.readList(json, key, Entity.CREATOR, ARRAY_VALIDATOR, env.logger, env) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithArrayTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithArrayWithTransform.kt b/api_generator/tests/references/kotlin/EntityWithArrayWithTransform.kt new file mode 100644 index 000000000..b2c55690e --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithArrayWithTransform.kt @@ -0,0 +1,47 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithArrayWithTransform( + @JvmField final val array: ExpressionsList, // at least 1 elements +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpressionsList(key = "array", value = array, converter = COLOR_INT_TO_STRING) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_array_with_transform" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithArrayWithTransform { + val logger = env.logger + return EntityWithArrayWithTransform( + array = JsonParser.readExpressionsList(json, "array", STRING_TO_COLOR_INT, ARRAY_VALIDATOR, logger, env, TYPE_HELPER_COLOR) + ) + } + + private val ARRAY_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithArrayWithTransform(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithArrayWithTransformTemplate.kt b/api_generator/tests/references/kotlin/EntityWithArrayWithTransformTemplate.kt new file mode 100644 index 000000000..730632c81 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithArrayWithTransformTemplate.kt @@ -0,0 +1,57 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithArrayWithTransformTemplate : JSONSerializable, JsonTemplate { + @JvmField final val array: Field> // at least 1 elements + + constructor ( + env: ParsingEnvironment, + parent: EntityWithArrayWithTransformTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + array = JsonTemplateParser.readExpressionsListField(json, "array", topLevel, parent?.array, STRING_TO_COLOR_INT, ARRAY_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_COLOR) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithArrayWithTransform { + return EntityWithArrayWithTransform( + array = array.resolveExpressionList(env = env, key = "array", data = data, reader = ARRAY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpressionsListField(key = "array", field = array, converter = COLOR_INT_TO_STRING) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_array_with_transform" + + private val ARRAY_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + private val ARRAY_TEMPLATE_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + + val ARRAY_READER: Reader> = { key, json, env -> JsonParser.readExpressionsList(json, key, STRING_TO_COLOR_INT, ARRAY_VALIDATOR, env.logger, env, TYPE_HELPER_COLOR) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithArrayWithTransformTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithComplexProperty.kt b/api_generator/tests/references/kotlin/EntityWithComplexProperty.kt new file mode 100644 index 000000000..eaf6a7944 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithComplexProperty.kt @@ -0,0 +1,71 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithComplexProperty( + @JvmField final val property: Property, +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "property", value = property) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_complex_property" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithComplexProperty { + val logger = env.logger + return EntityWithComplexProperty( + property = JsonParser.read(json, "property", Property.CREATOR, logger, env) + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithComplexProperty(env, json = it) } + } + + + @Mockable + class Property( + @JvmField final val value: Expression, + ) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpression(key = "value", value = value, converter = URI_TO_STRING) + return json + } + + companion object { + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): Property { + val logger = env.logger + return Property( + value = JsonParser.readExpression(json, "value", STRING_TO_URI, logger, env, TYPE_HELPER_URI) + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> Property(env, json = it) } + } + + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithComplexPropertyTemplate.kt b/api_generator/tests/references/kotlin/EntityWithComplexPropertyTemplate.kt new file mode 100644 index 000000000..ba71dc796 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithComplexPropertyTemplate.kt @@ -0,0 +1,88 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithComplexPropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val property: Field + + constructor ( + env: ParsingEnvironment, + parent: EntityWithComplexPropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + property = JsonTemplateParser.readField(json, "property", topLevel, parent?.property, PropertyTemplate.CREATOR, logger, env) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithComplexProperty { + return EntityWithComplexProperty( + property = property.resolveTemplate(env = env, key = "property", data = data, reader = PROPERTY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeField(key = "property", field = property) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_complex_property" + + val PROPERTY_READER: Reader = { key, json, env -> JsonParser.read(json, key, EntityWithComplexProperty.Property.CREATOR, env.logger, env) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithComplexPropertyTemplate(env, json = it) } + } + + + @Mockable + class PropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val value: Field> + + constructor ( + env: ParsingEnvironment, + parent: PropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + value = JsonTemplateParser.readFieldWithExpression(json, "value", topLevel, parent?.value, STRING_TO_URI, logger, env, TYPE_HELPER_URI) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithComplexProperty.Property { + return EntityWithComplexProperty.Property( + value = value.resolve(env = env, key = "value", data = data, reader = VALUE_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeFieldWithExpression(key = "value", field = value, converter = URI_TO_STRING) + return json + } + + companion object { + val VALUE_READER: Reader> = { key, json, env -> JsonParser.readExpression(json, key, STRING_TO_URI, env.logger, env, TYPE_HELPER_URI) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> PropertyTemplate(env, json = it) } + } + + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithComplexPropertyWithDefaultValue.kt b/api_generator/tests/references/kotlin/EntityWithComplexPropertyWithDefaultValue.kt new file mode 100644 index 000000000..e80113a32 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithComplexPropertyWithDefaultValue.kt @@ -0,0 +1,73 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithComplexPropertyWithDefaultValue( + @JvmField final val property: Property = PROPERTY_DEFAULT_VALUE, // default value: EntityWithComplexPropertyWithDefaultValue.Property(value = Expression.constant("Default text")) +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "property", value = property) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_complex_property_with_default_value" + + private val PROPERTY_DEFAULT_VALUE = EntityWithComplexPropertyWithDefaultValue.Property(value = Expression.constant("Default text")) + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithComplexPropertyWithDefaultValue { + val logger = env.logger + return EntityWithComplexPropertyWithDefaultValue( + property = JsonParser.readOptional(json, "property", Property.CREATOR, logger, env) ?: PROPERTY_DEFAULT_VALUE + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithComplexPropertyWithDefaultValue(env, json = it) } + } + + + @Mockable + class Property( + @JvmField final val value: Expression, + ) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpression(key = "value", value = value) + return json + } + + companion object { + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): Property { + val logger = env.logger + return Property( + value = JsonParser.readExpression(json, "value", logger, env, TYPE_HELPER_STRING) + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> Property(env, json = it) } + } + + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithComplexPropertyWithDefaultValueTemplate.kt b/api_generator/tests/references/kotlin/EntityWithComplexPropertyWithDefaultValueTemplate.kt new file mode 100644 index 000000000..b5a669d86 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithComplexPropertyWithDefaultValueTemplate.kt @@ -0,0 +1,90 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithComplexPropertyWithDefaultValueTemplate : JSONSerializable, JsonTemplate { + @JvmField final val property: Field // default value: EntityWithComplexPropertyWithDefaultValue.Property(value = Expression.constant("Default text")) + + constructor ( + env: ParsingEnvironment, + parent: EntityWithComplexPropertyWithDefaultValueTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + property = JsonTemplateParser.readOptionalField(json, "property", topLevel, parent?.property, PropertyTemplate.CREATOR, logger, env) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithComplexPropertyWithDefaultValue { + return EntityWithComplexPropertyWithDefaultValue( + property = property.resolveOptionalTemplate(env = env, key = "property", data = data, reader = PROPERTY_READER) ?: PROPERTY_DEFAULT_VALUE + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeField(key = "property", field = property) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_complex_property_with_default_value" + + private val PROPERTY_DEFAULT_VALUE = EntityWithComplexPropertyWithDefaultValue.Property(value = Expression.constant("Default text")) + + val PROPERTY_READER: Reader = { key, json, env -> JsonParser.readOptional(json, key, EntityWithComplexPropertyWithDefaultValue.Property.CREATOR, env.logger, env) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithComplexPropertyWithDefaultValueTemplate(env, json = it) } + } + + + @Mockable + class PropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val value: Field> + + constructor ( + env: ParsingEnvironment, + parent: PropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + value = JsonTemplateParser.readFieldWithExpression(json, "value", topLevel, parent?.value, logger, env, TYPE_HELPER_STRING) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithComplexPropertyWithDefaultValue.Property { + return EntityWithComplexPropertyWithDefaultValue.Property( + value = value.resolve(env = env, key = "value", data = data, reader = VALUE_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeFieldWithExpression(key = "value", field = value) + return json + } + + companion object { + val VALUE_READER: Reader> = { key, json, env -> JsonParser.readExpression(json, key, env.logger, env, TYPE_HELPER_STRING) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> PropertyTemplate(env, json = it) } + } + + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithEntityProperty.kt b/api_generator/tests/references/kotlin/EntityWithEntityProperty.kt new file mode 100644 index 000000000..f0922be88 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithEntityProperty.kt @@ -0,0 +1,47 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithEntityProperty( + @JvmField final val entity: Entity = ENTITY_DEFAULT_VALUE, // default value: Entity.WithStringEnumProperty(EntityWithStringEnumProperty(property = Expression.constant(EntityWithStringEnumProperty.Property.SECOND))) +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "entity", value = entity) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_entity_property" + + private val ENTITY_DEFAULT_VALUE = Entity.WithStringEnumProperty(EntityWithStringEnumProperty(property = Expression.constant(EntityWithStringEnumProperty.Property.SECOND))) + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithEntityProperty { + val logger = env.logger + return EntityWithEntityProperty( + entity = JsonParser.readOptional(json, "entity", Entity.CREATOR, logger, env) ?: ENTITY_DEFAULT_VALUE + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithEntityProperty(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithEntityPropertyTemplate.kt b/api_generator/tests/references/kotlin/EntityWithEntityPropertyTemplate.kt new file mode 100644 index 000000000..fe414c7d0 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithEntityPropertyTemplate.kt @@ -0,0 +1,56 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithEntityPropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val entity: Field // default value: Entity.WithStringEnumProperty(EntityWithStringEnumProperty(property = Expression.constant(EntityWithStringEnumProperty.Property.SECOND))) + + constructor ( + env: ParsingEnvironment, + parent: EntityWithEntityPropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + entity = JsonTemplateParser.readOptionalField(json, "entity", topLevel, parent?.entity, EntityTemplate.CREATOR, logger, env) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithEntityProperty { + return EntityWithEntityProperty( + entity = entity.resolveOptionalTemplate(env = env, key = "entity", data = data, reader = ENTITY_READER) ?: ENTITY_DEFAULT_VALUE + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeField(key = "entity", field = entity) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_entity_property" + + private val ENTITY_DEFAULT_VALUE = Entity.WithStringEnumProperty(EntityWithStringEnumProperty(property = Expression.constant(EntityWithStringEnumProperty.Property.SECOND))) + + val ENTITY_READER: Reader = { key, json, env -> JsonParser.readOptional(json, key, Entity.CREATOR, env.logger, env) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithEntityPropertyTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithOptionalComplexProperty.kt b/api_generator/tests/references/kotlin/EntityWithOptionalComplexProperty.kt new file mode 100644 index 000000000..715a6da3a --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithOptionalComplexProperty.kt @@ -0,0 +1,71 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithOptionalComplexProperty( + @JvmField final val property: Property? = null, +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "property", value = property) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_optional_complex_property" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithOptionalComplexProperty { + val logger = env.logger + return EntityWithOptionalComplexProperty( + property = JsonParser.readOptional(json, "property", Property.CREATOR, logger, env) + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithOptionalComplexProperty(env, json = it) } + } + + + @Mockable + class Property( + @JvmField final val value: Expression, + ) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpression(key = "value", value = value, converter = URI_TO_STRING) + return json + } + + companion object { + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): Property { + val logger = env.logger + return Property( + value = JsonParser.readExpression(json, "value", STRING_TO_URI, logger, env, TYPE_HELPER_URI) + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> Property(env, json = it) } + } + + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithOptionalComplexPropertyTemplate.kt b/api_generator/tests/references/kotlin/EntityWithOptionalComplexPropertyTemplate.kt new file mode 100644 index 000000000..53283fc02 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithOptionalComplexPropertyTemplate.kt @@ -0,0 +1,88 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithOptionalComplexPropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val property: Field + + constructor ( + env: ParsingEnvironment, + parent: EntityWithOptionalComplexPropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + property = JsonTemplateParser.readOptionalField(json, "property", topLevel, parent?.property, PropertyTemplate.CREATOR, logger, env) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithOptionalComplexProperty { + return EntityWithOptionalComplexProperty( + property = property.resolveOptionalTemplate(env = env, key = "property", data = data, reader = PROPERTY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeField(key = "property", field = property) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_optional_complex_property" + + val PROPERTY_READER: Reader = { key, json, env -> JsonParser.readOptional(json, key, EntityWithOptionalComplexProperty.Property.CREATOR, env.logger, env) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithOptionalComplexPropertyTemplate(env, json = it) } + } + + + @Mockable + class PropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val value: Field> + + constructor ( + env: ParsingEnvironment, + parent: PropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + value = JsonTemplateParser.readFieldWithExpression(json, "value", topLevel, parent?.value, STRING_TO_URI, logger, env, TYPE_HELPER_URI) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithOptionalComplexProperty.Property { + return EntityWithOptionalComplexProperty.Property( + value = value.resolve(env = env, key = "value", data = data, reader = VALUE_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeFieldWithExpression(key = "value", field = value, converter = URI_TO_STRING) + return json + } + + companion object { + val VALUE_READER: Reader> = { key, json, env -> JsonParser.readExpression(json, key, STRING_TO_URI, env.logger, env, TYPE_HELPER_URI) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> PropertyTemplate(env, json = it) } + } + + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithOptionalProperty.kt b/api_generator/tests/references/kotlin/EntityWithOptionalProperty.kt new file mode 100644 index 000000000..53f20db73 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithOptionalProperty.kt @@ -0,0 +1,48 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithOptionalProperty( + @JvmField final val property: Expression? = null, // at least 1 char +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpression(key = "property", value = property) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_optional_property" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithOptionalProperty { + val logger = env.logger + return EntityWithOptionalProperty( + property = JsonParser.readOptionalExpression(json, "property", PROPERTY_VALIDATOR, logger, env, TYPE_HELPER_STRING) + ) + } + + private val PROPERTY_TEMPLATE_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + private val PROPERTY_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithOptionalProperty(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithOptionalPropertyTemplate.kt b/api_generator/tests/references/kotlin/EntityWithOptionalPropertyTemplate.kt new file mode 100644 index 000000000..9be8824e0 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithOptionalPropertyTemplate.kt @@ -0,0 +1,57 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithOptionalPropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val property: Field> // at least 1 char + + constructor ( + env: ParsingEnvironment, + parent: EntityWithOptionalPropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + property = JsonTemplateParser.readOptionalFieldWithExpression(json, "property", topLevel, parent?.property, PROPERTY_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_STRING) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithOptionalProperty { + return EntityWithOptionalProperty( + property = property.resolveOptional(env = env, key = "property", data = data, reader = PROPERTY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeFieldWithExpression(key = "property", field = property) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_optional_property" + + private val PROPERTY_TEMPLATE_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + private val PROPERTY_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + + val PROPERTY_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, PROPERTY_VALIDATOR, env.logger, env, TYPE_HELPER_STRING) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithOptionalPropertyTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithOptionalStringEnumProperty.kt b/api_generator/tests/references/kotlin/EntityWithOptionalStringEnumProperty.kt new file mode 100644 index 000000000..4f259d2a5 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithOptionalStringEnumProperty.kt @@ -0,0 +1,74 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithOptionalStringEnumProperty( + @JvmField final val property: Expression? = null, +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpression(key = "property", value = property, converter = { v: Property -> Property.toString(v) }) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_optional_string_enum_property" + + private val TYPE_HELPER_PROPERTY = TypeHelper.from(default = EntityWithOptionalStringEnumProperty.Property.values().first()) { it is EntityWithOptionalStringEnumProperty.Property } + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithOptionalStringEnumProperty { + val logger = env.logger + return EntityWithOptionalStringEnumProperty( + property = JsonParser.readOptionalExpression(json, "property", Property.Converter.FROM_STRING, logger, env, TYPE_HELPER_PROPERTY) + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithOptionalStringEnumProperty(env, json = it) } + } + + + enum class Property(private val value: String) { + FIRST("first"), + SECOND("second"); + + companion object Converter { + fun toString(obj: Property): String { + return obj.value + } + + fun fromString(string: String): Property? { + return when (string) { + FIRST.value -> FIRST + SECOND.value -> SECOND + else -> null + } + } + + val FROM_STRING = { string: String -> + when (string) { + FIRST.value -> FIRST + SECOND.value -> SECOND + else -> null + } + } + } + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithOptionalStringEnumPropertyTemplate.kt b/api_generator/tests/references/kotlin/EntityWithOptionalStringEnumPropertyTemplate.kt new file mode 100644 index 000000000..aacc235bb --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithOptionalStringEnumPropertyTemplate.kt @@ -0,0 +1,56 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithOptionalStringEnumPropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val property: Field> + + constructor ( + env: ParsingEnvironment, + parent: EntityWithOptionalStringEnumPropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + property = JsonTemplateParser.readOptionalFieldWithExpression(json, "property", topLevel, parent?.property, EntityWithOptionalStringEnumProperty.Property.Converter.FROM_STRING, logger, env, TYPE_HELPER_PROPERTY) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithOptionalStringEnumProperty { + return EntityWithOptionalStringEnumProperty( + property = property.resolveOptional(env = env, key = "property", data = data, reader = PROPERTY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeFieldWithExpression(key = "property", field = property, converter = { v: EntityWithOptionalStringEnumProperty.Property -> EntityWithOptionalStringEnumProperty.Property.toString(v) }) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_optional_string_enum_property" + + private val TYPE_HELPER_PROPERTY = TypeHelper.from(default = EntityWithOptionalStringEnumProperty.Property.values().first()) { it is EntityWithOptionalStringEnumProperty.Property } + + val PROPERTY_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, EntityWithOptionalStringEnumProperty.Property.Converter.FROM_STRING, env.logger, env, TYPE_HELPER_PROPERTY) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithOptionalStringEnumPropertyTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithPropertyWithDefaultValue.kt b/api_generator/tests/references/kotlin/EntityWithPropertyWithDefaultValue.kt new file mode 100644 index 000000000..50f17b384 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithPropertyWithDefaultValue.kt @@ -0,0 +1,99 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithPropertyWithDefaultValue( + @JvmField final val int: Expression = INT_DEFAULT_VALUE, // constraint: number >= 0; default value: 0 + @JvmField final val nested: Nested? = null, + @JvmField final val url: Expression = URL_DEFAULT_VALUE, // valid schemes: [https]; default value: https://yandex.ru +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpression(key = "int", value = int) + json.write(key = "nested", value = nested) + json.write(key = "type", value = TYPE) + json.writeExpression(key = "url", value = url, converter = URI_TO_STRING) + return json + } + + companion object { + const val TYPE = "entity_with_property_with_default_value" + + private val INT_DEFAULT_VALUE = Expression.constant(0) + private val URL_DEFAULT_VALUE = Expression.constant(Uri.parse("https://yandex.ru")) + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithPropertyWithDefaultValue { + val logger = env.logger + return EntityWithPropertyWithDefaultValue( + int = JsonParser.readOptionalExpression(json, "int", NUMBER_TO_INT, INT_VALIDATOR, logger, env, INT_DEFAULT_VALUE, TYPE_HELPER_INT) ?: INT_DEFAULT_VALUE, + nested = JsonParser.readOptional(json, "nested", Nested.CREATOR, logger, env), + url = JsonParser.readOptionalExpression(json, "url", STRING_TO_URI, URL_VALIDATOR, logger, env, URL_DEFAULT_VALUE, TYPE_HELPER_URI) ?: URL_DEFAULT_VALUE + ) + } + + private val INT_TEMPLATE_VALIDATOR = ValueValidator { it: Int -> it >= 0 } + private val INT_VALIDATOR = ValueValidator { it: Int -> it >= 0 } + private val URL_TEMPLATE_VALIDATOR = ValueValidator { it.hasScheme(listOf("https")) } + private val URL_VALIDATOR = ValueValidator { it.hasScheme(listOf("https")) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithPropertyWithDefaultValue(env, json = it) } + } + + + @Mockable + class Nested( + @JvmField final val int: Expression = INT_DEFAULT_VALUE, // constraint: number >= 0; default value: 0 + @JvmField final val nonOptional: Expression, + @JvmField final val url: Expression = URL_DEFAULT_VALUE, // valid schemes: [https]; default value: https://yandex.ru + ) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpression(key = "int", value = int) + json.writeExpression(key = "non_optional", value = nonOptional) + json.writeExpression(key = "url", value = url, converter = URI_TO_STRING) + return json + } + + companion object { + private val INT_DEFAULT_VALUE = Expression.constant(0) + private val URL_DEFAULT_VALUE = Expression.constant(Uri.parse("https://yandex.ru")) + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): Nested { + val logger = env.logger + return Nested( + int = JsonParser.readOptionalExpression(json, "int", NUMBER_TO_INT, INT_VALIDATOR, logger, env, INT_DEFAULT_VALUE, TYPE_HELPER_INT) ?: INT_DEFAULT_VALUE, + nonOptional = JsonParser.readExpression(json, "non_optional", logger, env, TYPE_HELPER_STRING), + url = JsonParser.readOptionalExpression(json, "url", STRING_TO_URI, URL_VALIDATOR, logger, env, URL_DEFAULT_VALUE, TYPE_HELPER_URI) ?: URL_DEFAULT_VALUE + ) + } + + private val INT_TEMPLATE_VALIDATOR = ValueValidator { it: Int -> it >= 0 } + private val INT_VALIDATOR = ValueValidator { it: Int -> it >= 0 } + private val URL_TEMPLATE_VALIDATOR = ValueValidator { it.hasScheme(listOf("https")) } + private val URL_VALIDATOR = ValueValidator { it.hasScheme(listOf("https")) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> Nested(env, json = it) } + } + + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithPropertyWithDefaultValueTemplate.kt b/api_generator/tests/references/kotlin/EntityWithPropertyWithDefaultValueTemplate.kt new file mode 100644 index 000000000..40ea3ba9c --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithPropertyWithDefaultValueTemplate.kt @@ -0,0 +1,124 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithPropertyWithDefaultValueTemplate : JSONSerializable, JsonTemplate { + @JvmField final val int: Field> // constraint: number >= 0; default value: 0 + @JvmField final val nested: Field + @JvmField final val url: Field> // valid schemes: [https]; default value: https://yandex.ru + + constructor ( + env: ParsingEnvironment, + parent: EntityWithPropertyWithDefaultValueTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + int = JsonTemplateParser.readOptionalFieldWithExpression(json, "int", topLevel, parent?.int, NUMBER_TO_INT, INT_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_INT) + nested = JsonTemplateParser.readOptionalField(json, "nested", topLevel, parent?.nested, NestedTemplate.CREATOR, logger, env) + url = JsonTemplateParser.readOptionalFieldWithExpression(json, "url", topLevel, parent?.url, STRING_TO_URI, URL_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_URI) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithPropertyWithDefaultValue { + return EntityWithPropertyWithDefaultValue( + int = int.resolveOptional(env = env, key = "int", data = data, reader = INT_READER) ?: INT_DEFAULT_VALUE, + nested = nested.resolveOptionalTemplate(env = env, key = "nested", data = data, reader = NESTED_READER), + url = url.resolveOptional(env = env, key = "url", data = data, reader = URL_READER) ?: URL_DEFAULT_VALUE + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeFieldWithExpression(key = "int", field = int) + json.writeField(key = "nested", field = nested) + json.write(key = "type", value = TYPE) + json.writeFieldWithExpression(key = "url", field = url, converter = URI_TO_STRING) + return json + } + + companion object { + const val TYPE = "entity_with_property_with_default_value" + + private val INT_DEFAULT_VALUE = Expression.constant(0) + private val URL_DEFAULT_VALUE = Expression.constant(Uri.parse("https://yandex.ru")) + + private val INT_TEMPLATE_VALIDATOR = ValueValidator { it: Int -> it >= 0 } + private val INT_VALIDATOR = ValueValidator { it: Int -> it >= 0 } + private val URL_TEMPLATE_VALIDATOR = ValueValidator { it.hasScheme(listOf("https")) } + private val URL_VALIDATOR = ValueValidator { it.hasScheme(listOf("https")) } + + val INT_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, NUMBER_TO_INT, INT_VALIDATOR, env.logger, env, INT_DEFAULT_VALUE, TYPE_HELPER_INT) } + val NESTED_READER: Reader = { key, json, env -> JsonParser.readOptional(json, key, EntityWithPropertyWithDefaultValue.Nested.CREATOR, env.logger, env) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + val URL_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, STRING_TO_URI, URL_VALIDATOR, env.logger, env, URL_DEFAULT_VALUE, TYPE_HELPER_URI) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithPropertyWithDefaultValueTemplate(env, json = it) } + } + + + @Mockable + class NestedTemplate : JSONSerializable, JsonTemplate { + @JvmField final val int: Field> // constraint: number >= 0; default value: 0 + @JvmField final val nonOptional: Field> + @JvmField final val url: Field> // valid schemes: [https]; default value: https://yandex.ru + + constructor ( + env: ParsingEnvironment, + parent: NestedTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + int = JsonTemplateParser.readOptionalFieldWithExpression(json, "int", topLevel, parent?.int, NUMBER_TO_INT, INT_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_INT) + nonOptional = JsonTemplateParser.readFieldWithExpression(json, "non_optional", topLevel, parent?.nonOptional, logger, env, TYPE_HELPER_STRING) + url = JsonTemplateParser.readOptionalFieldWithExpression(json, "url", topLevel, parent?.url, STRING_TO_URI, URL_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_URI) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithPropertyWithDefaultValue.Nested { + return EntityWithPropertyWithDefaultValue.Nested( + int = int.resolveOptional(env = env, key = "int", data = data, reader = INT_READER) ?: INT_DEFAULT_VALUE, + nonOptional = nonOptional.resolve(env = env, key = "non_optional", data = data, reader = NON_OPTIONAL_READER), + url = url.resolveOptional(env = env, key = "url", data = data, reader = URL_READER) ?: URL_DEFAULT_VALUE + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeFieldWithExpression(key = "int", field = int) + json.writeFieldWithExpression(key = "non_optional", field = nonOptional) + json.writeFieldWithExpression(key = "url", field = url, converter = URI_TO_STRING) + return json + } + + companion object { + private val INT_DEFAULT_VALUE = Expression.constant(0) + private val URL_DEFAULT_VALUE = Expression.constant(Uri.parse("https://yandex.ru")) + + private val INT_TEMPLATE_VALIDATOR = ValueValidator { it: Int -> it >= 0 } + private val INT_VALIDATOR = ValueValidator { it: Int -> it >= 0 } + private val URL_TEMPLATE_VALIDATOR = ValueValidator { it.hasScheme(listOf("https")) } + private val URL_VALIDATOR = ValueValidator { it.hasScheme(listOf("https")) } + + val INT_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, NUMBER_TO_INT, INT_VALIDATOR, env.logger, env, INT_DEFAULT_VALUE, TYPE_HELPER_INT) } + val NON_OPTIONAL_READER: Reader> = { key, json, env -> JsonParser.readExpression(json, key, env.logger, env, TYPE_HELPER_STRING) } + val URL_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, STRING_TO_URI, URL_VALIDATOR, env.logger, env, URL_DEFAULT_VALUE, TYPE_HELPER_URI) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> NestedTemplate(env, json = it) } + } + + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithRequiredProperty.kt b/api_generator/tests/references/kotlin/EntityWithRequiredProperty.kt new file mode 100644 index 000000000..4b3eeeecc --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithRequiredProperty.kt @@ -0,0 +1,48 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithRequiredProperty( + @JvmField final val property: Expression, // at least 1 char +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpression(key = "property", value = property) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_required_property" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithRequiredProperty { + val logger = env.logger + return EntityWithRequiredProperty( + property = JsonParser.readExpression(json, "property", PROPERTY_VALIDATOR, logger, env, TYPE_HELPER_STRING) + ) + } + + private val PROPERTY_TEMPLATE_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + private val PROPERTY_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithRequiredProperty(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithRequiredPropertyTemplate.kt b/api_generator/tests/references/kotlin/EntityWithRequiredPropertyTemplate.kt new file mode 100644 index 000000000..c4a1f9894 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithRequiredPropertyTemplate.kt @@ -0,0 +1,57 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithRequiredPropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val property: Field> // at least 1 char + + constructor ( + env: ParsingEnvironment, + parent: EntityWithRequiredPropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + property = JsonTemplateParser.readFieldWithExpression(json, "property", topLevel, parent?.property, PROPERTY_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_STRING) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithRequiredProperty { + return EntityWithRequiredProperty( + property = property.resolve(env = env, key = "property", data = data, reader = PROPERTY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeFieldWithExpression(key = "property", field = property) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_required_property" + + private val PROPERTY_TEMPLATE_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + private val PROPERTY_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + + val PROPERTY_READER: Reader> = { key, json, env -> JsonParser.readExpression(json, key, PROPERTY_VALIDATOR, env.logger, env, TYPE_HELPER_STRING) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithRequiredPropertyTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithSimpleProperties.kt b/api_generator/tests/references/kotlin/EntityWithSimpleProperties.kt new file mode 100644 index 000000000..c30e7fb79 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithSimpleProperties.kt @@ -0,0 +1,74 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithSimpleProperties( + @JvmField final val boolean: Expression? = null, + @JvmField final val booleanInt: Expression? = null, + @JvmField final val color: Expression? = null, + @JvmField final val double: Expression? = null, + @JvmField final val id: Int? = null, + @JvmField final val integer: Expression? = null, + @JvmField final val positiveInteger: Expression? = null, // constraint: number > 0 + @JvmField final val string: Expression? = null, // at least 1 char + @JvmField final val url: Expression? = null, +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpression(key = "boolean", value = boolean, converter = BOOLEAN_TO_INT) + json.writeExpression(key = "boolean_int", value = booleanInt, converter = BOOLEAN_TO_INT) + json.writeExpression(key = "color", value = color, converter = COLOR_INT_TO_STRING) + json.writeExpression(key = "double", value = double) + json.write(key = "id", value = id) + json.writeExpression(key = "integer", value = integer) + json.writeExpression(key = "positive_integer", value = positiveInteger) + json.writeExpression(key = "string", value = string) + json.write(key = "type", value = TYPE) + json.writeExpression(key = "url", value = url, converter = URI_TO_STRING) + return json + } + + companion object { + const val TYPE = "entity_with_simple_properties" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithSimpleProperties { + val logger = env.logger + return EntityWithSimpleProperties( + boolean = JsonParser.readOptionalExpression(json, "boolean", NUMBER_TO_BOOLEAN, logger, env, TYPE_HELPER_BOOLEAN), + booleanInt = JsonParser.readOptionalExpression(json, "boolean_int", NUMBER_TO_BOOLEAN, logger, env, TYPE_HELPER_BOOLEAN), + color = JsonParser.readOptionalExpression(json, "color", STRING_TO_COLOR_INT, logger, env, TYPE_HELPER_COLOR), + double = JsonParser.readOptionalExpression(json, "double", NUMBER_TO_DOUBLE, logger, env, TYPE_HELPER_DOUBLE), + id = JsonParser.readOptional(json, "id", NUMBER_TO_INT, logger, env), + integer = JsonParser.readOptionalExpression(json, "integer", NUMBER_TO_INT, logger, env, TYPE_HELPER_INT), + positiveInteger = JsonParser.readOptionalExpression(json, "positive_integer", NUMBER_TO_INT, POSITIVE_INTEGER_VALIDATOR, logger, env, TYPE_HELPER_INT), + string = JsonParser.readOptionalExpression(json, "string", STRING_VALIDATOR, logger, env, TYPE_HELPER_STRING), + url = JsonParser.readOptionalExpression(json, "url", STRING_TO_URI, logger, env, TYPE_HELPER_URI) + ) + } + + private val POSITIVE_INTEGER_TEMPLATE_VALIDATOR = ValueValidator { it: Int -> it > 0 } + private val POSITIVE_INTEGER_VALIDATOR = ValueValidator { it: Int -> it > 0 } + private val STRING_TEMPLATE_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + private val STRING_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithSimpleProperties(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithSimplePropertiesTemplate.kt b/api_generator/tests/references/kotlin/EntityWithSimplePropertiesTemplate.kt new file mode 100644 index 000000000..a4ad8495d --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithSimplePropertiesTemplate.kt @@ -0,0 +1,99 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithSimplePropertiesTemplate : JSONSerializable, JsonTemplate { + @JvmField final val boolean: Field> + @JvmField final val booleanInt: Field> + @JvmField final val color: Field> + @JvmField final val double: Field> + @JvmField final val id: Field + @JvmField final val integer: Field> + @JvmField final val positiveInteger: Field> // constraint: number > 0 + @JvmField final val string: Field> // at least 1 char + @JvmField final val url: Field> + + constructor ( + env: ParsingEnvironment, + parent: EntityWithSimplePropertiesTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + boolean = JsonTemplateParser.readOptionalFieldWithExpression(json, "boolean", topLevel, parent?.boolean, NUMBER_TO_BOOLEAN, logger, env, TYPE_HELPER_BOOLEAN) + booleanInt = JsonTemplateParser.readOptionalFieldWithExpression(json, "boolean_int", topLevel, parent?.booleanInt, NUMBER_TO_BOOLEAN, logger, env, TYPE_HELPER_BOOLEAN) + color = JsonTemplateParser.readOptionalFieldWithExpression(json, "color", topLevel, parent?.color, STRING_TO_COLOR_INT, logger, env, TYPE_HELPER_COLOR) + double = JsonTemplateParser.readOptionalFieldWithExpression(json, "double", topLevel, parent?.double, NUMBER_TO_DOUBLE, logger, env, TYPE_HELPER_DOUBLE) + id = JsonTemplateParser.readOptionalField(json, "id", topLevel, parent?.id, NUMBER_TO_INT, logger, env) + integer = JsonTemplateParser.readOptionalFieldWithExpression(json, "integer", topLevel, parent?.integer, NUMBER_TO_INT, logger, env, TYPE_HELPER_INT) + positiveInteger = JsonTemplateParser.readOptionalFieldWithExpression(json, "positive_integer", topLevel, parent?.positiveInteger, NUMBER_TO_INT, POSITIVE_INTEGER_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_INT) + string = JsonTemplateParser.readOptionalFieldWithExpression(json, "string", topLevel, parent?.string, STRING_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_STRING) + url = JsonTemplateParser.readOptionalFieldWithExpression(json, "url", topLevel, parent?.url, STRING_TO_URI, logger, env, TYPE_HELPER_URI) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithSimpleProperties { + return EntityWithSimpleProperties( + boolean = boolean.resolveOptional(env = env, key = "boolean", data = data, reader = BOOLEAN_READER), + booleanInt = booleanInt.resolveOptional(env = env, key = "boolean_int", data = data, reader = BOOLEAN_INT_READER), + color = color.resolveOptional(env = env, key = "color", data = data, reader = COLOR_READER), + double = double.resolveOptional(env = env, key = "double", data = data, reader = DOUBLE_READER), + id = id.resolveOptional(env = env, key = "id", data = data, reader = ID_READER), + integer = integer.resolveOptional(env = env, key = "integer", data = data, reader = INTEGER_READER), + positiveInteger = positiveInteger.resolveOptional(env = env, key = "positive_integer", data = data, reader = POSITIVE_INTEGER_READER), + string = string.resolveOptional(env = env, key = "string", data = data, reader = STRING_READER), + url = url.resolveOptional(env = env, key = "url", data = data, reader = URL_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeFieldWithExpression(key = "boolean", field = boolean, converter = BOOLEAN_TO_INT) + json.writeFieldWithExpression(key = "boolean_int", field = booleanInt, converter = BOOLEAN_TO_INT) + json.writeFieldWithExpression(key = "color", field = color, converter = COLOR_INT_TO_STRING) + json.writeFieldWithExpression(key = "double", field = double) + json.writeField(key = "id", field = id) + json.writeFieldWithExpression(key = "integer", field = integer) + json.writeFieldWithExpression(key = "positive_integer", field = positiveInteger) + json.writeFieldWithExpression(key = "string", field = string) + json.write(key = "type", value = TYPE) + json.writeFieldWithExpression(key = "url", field = url, converter = URI_TO_STRING) + return json + } + + companion object { + const val TYPE = "entity_with_simple_properties" + + private val POSITIVE_INTEGER_TEMPLATE_VALIDATOR = ValueValidator { it: Int -> it > 0 } + private val POSITIVE_INTEGER_VALIDATOR = ValueValidator { it: Int -> it > 0 } + private val STRING_TEMPLATE_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + private val STRING_VALIDATOR = ValueValidator { it: String -> it.length >= 1 } + + val BOOLEAN_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, NUMBER_TO_BOOLEAN, env.logger, env, TYPE_HELPER_BOOLEAN) } + val BOOLEAN_INT_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, NUMBER_TO_BOOLEAN, env.logger, env, TYPE_HELPER_BOOLEAN) } + val COLOR_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, STRING_TO_COLOR_INT, env.logger, env, TYPE_HELPER_COLOR) } + val DOUBLE_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, NUMBER_TO_DOUBLE, env.logger, env, TYPE_HELPER_DOUBLE) } + val ID_READER: Reader = { key, json, env -> JsonParser.readOptional(json, key, NUMBER_TO_INT, env.logger, env) } + val INTEGER_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, NUMBER_TO_INT, env.logger, env, TYPE_HELPER_INT) } + val POSITIVE_INTEGER_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, NUMBER_TO_INT, POSITIVE_INTEGER_VALIDATOR, env.logger, env, TYPE_HELPER_INT) } + val STRING_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, STRING_VALIDATOR, env.logger, env, TYPE_HELPER_STRING) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + val URL_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, STRING_TO_URI, env.logger, env, TYPE_HELPER_URI) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithSimplePropertiesTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithStrictArray.kt b/api_generator/tests/references/kotlin/EntityWithStrictArray.kt new file mode 100644 index 000000000..8e42a8ac5 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithStrictArray.kt @@ -0,0 +1,47 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithStrictArray( + @JvmField final val array: List, // at least 1 elements; all received elements must be valid +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "array", value = array) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_strict_array" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithStrictArray { + val logger = env.logger + return EntityWithStrictArray( + array = JsonParser.readStrictList(json, "array", Entity.CREATOR, ARRAY_VALIDATOR, logger, env) + ) + } + + private val ARRAY_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithStrictArray(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithStrictArrayTemplate.kt b/api_generator/tests/references/kotlin/EntityWithStrictArrayTemplate.kt new file mode 100644 index 000000000..981e98321 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithStrictArrayTemplate.kt @@ -0,0 +1,57 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithStrictArrayTemplate : JSONSerializable, JsonTemplate { + @JvmField final val array: Field> // at least 1 elements; all received elements must be valid + + constructor ( + env: ParsingEnvironment, + parent: EntityWithStrictArrayTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + array = JsonTemplateParser.readStrictListField(json, "array", topLevel, parent?.array, EntityTemplate.CREATOR, ARRAY_TEMPLATE_VALIDATOR, logger, env) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithStrictArray { + return EntityWithStrictArray( + array = array.resolveTemplateList(env = env, key = "array", data = data, ARRAY_VALIDATOR, reader = ARRAY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeField(key = "array", field = array) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_strict_array" + + private val ARRAY_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + private val ARRAY_TEMPLATE_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + + val ARRAY_READER: Reader> = { key, json, env -> JsonParser.readStrictList(json, key, Entity.CREATOR, ARRAY_VALIDATOR, env.logger, env) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithStrictArrayTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithStringArrayProperty.kt b/api_generator/tests/references/kotlin/EntityWithStringArrayProperty.kt new file mode 100644 index 000000000..5d2ac7e5f --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithStringArrayProperty.kt @@ -0,0 +1,47 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithStringArrayProperty( + @JvmField final val array: ExpressionsList, // at least 1 elements +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpressionsList(key = "array", value = array) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_string_array_property" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithStringArrayProperty { + val logger = env.logger + return EntityWithStringArrayProperty( + array = JsonParser.readExpressionsList(json, "array", ARRAY_VALIDATOR, logger, env, TYPE_HELPER_STRING) + ) + } + + private val ARRAY_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithStringArrayProperty(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithStringArrayPropertyTemplate.kt b/api_generator/tests/references/kotlin/EntityWithStringArrayPropertyTemplate.kt new file mode 100644 index 000000000..de96a8401 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithStringArrayPropertyTemplate.kt @@ -0,0 +1,57 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithStringArrayPropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val array: Field> // at least 1 elements + + constructor ( + env: ParsingEnvironment, + parent: EntityWithStringArrayPropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + array = JsonTemplateParser.readExpressionsListField(json, "array", topLevel, parent?.array, ARRAY_TEMPLATE_VALIDATOR, logger, env, TYPE_HELPER_STRING) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithStringArrayProperty { + return EntityWithStringArrayProperty( + array = array.resolveExpressionList(env = env, key = "array", data = data, reader = ARRAY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpressionsListField(key = "array", field = array) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_string_array_property" + + private val ARRAY_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + private val ARRAY_TEMPLATE_VALIDATOR = ListValidator { it: List<*> -> it.size >= 1 } + + val ARRAY_READER: Reader> = { key, json, env -> JsonParser.readExpressionsList(json, key, ARRAY_VALIDATOR, env.logger, env, TYPE_HELPER_STRING) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithStringArrayPropertyTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithStringEnumProperty.kt b/api_generator/tests/references/kotlin/EntityWithStringEnumProperty.kt new file mode 100644 index 000000000..0b011546d --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithStringEnumProperty.kt @@ -0,0 +1,74 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithStringEnumProperty( + @JvmField final val property: Expression, +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeExpression(key = "property", value = property, converter = { v: Property -> Property.toString(v) }) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_string_enum_property" + + private val TYPE_HELPER_PROPERTY = TypeHelper.from(default = EntityWithStringEnumProperty.Property.values().first()) { it is EntityWithStringEnumProperty.Property } + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithStringEnumProperty { + val logger = env.logger + return EntityWithStringEnumProperty( + property = JsonParser.readExpression(json, "property", Property.Converter.FROM_STRING, logger, env, TYPE_HELPER_PROPERTY) + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithStringEnumProperty(env, json = it) } + } + + + enum class Property(private val value: String) { + FIRST("first"), + SECOND("second"); + + companion object Converter { + fun toString(obj: Property): String { + return obj.value + } + + fun fromString(string: String): Property? { + return when (string) { + FIRST.value -> FIRST + SECOND.value -> SECOND + else -> null + } + } + + val FROM_STRING = { string: String -> + when (string) { + FIRST.value -> FIRST + SECOND.value -> SECOND + else -> null + } + } + } + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithStringEnumPropertyTemplate.kt b/api_generator/tests/references/kotlin/EntityWithStringEnumPropertyTemplate.kt new file mode 100644 index 000000000..5ca008709 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithStringEnumPropertyTemplate.kt @@ -0,0 +1,56 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithStringEnumPropertyTemplate : JSONSerializable, JsonTemplate { + @JvmField final val property: Field> + + constructor ( + env: ParsingEnvironment, + parent: EntityWithStringEnumPropertyTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + property = JsonTemplateParser.readFieldWithExpression(json, "property", topLevel, parent?.property, EntityWithStringEnumProperty.Property.Converter.FROM_STRING, logger, env, TYPE_HELPER_PROPERTY) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithStringEnumProperty { + return EntityWithStringEnumProperty( + property = property.resolve(env = env, key = "property", data = data, reader = PROPERTY_READER) + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.writeFieldWithExpression(key = "property", field = property, converter = { v: EntityWithStringEnumProperty.Property -> EntityWithStringEnumProperty.Property.toString(v) }) + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_with_string_enum_property" + + private val TYPE_HELPER_PROPERTY = TypeHelper.from(default = EntityWithStringEnumProperty.Property.values().first()) { it is EntityWithStringEnumProperty.Property } + + val PROPERTY_READER: Reader> = { key, json, env -> JsonParser.readExpression(json, key, EntityWithStringEnumProperty.Property.Converter.FROM_STRING, env.logger, env, TYPE_HELPER_PROPERTY) } + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithStringEnumPropertyTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithStringEnumPropertyWithDefaultValue.kt b/api_generator/tests/references/kotlin/EntityWithStringEnumPropertyWithDefaultValue.kt new file mode 100644 index 000000000..b7623dbc1 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithStringEnumPropertyWithDefaultValue.kt @@ -0,0 +1,79 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithStringEnumPropertyWithDefaultValue( + @JvmField final val value: Expression = VALUE_DEFAULT_VALUE, // default value: second +) : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "type", value = TYPE) + json.writeExpression(key = "value", value = value, converter = { v: Value -> Value.toString(v) }) + return json + } + + companion object { + const val TYPE = "entity_with_string_enum_property_with_default_value" + + private val VALUE_DEFAULT_VALUE = Expression.constant(Value.SECOND) + + private val TYPE_HELPER_VALUE = TypeHelper.from(default = EntityWithStringEnumPropertyWithDefaultValue.Value.values().first()) { it is EntityWithStringEnumPropertyWithDefaultValue.Value } + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithStringEnumPropertyWithDefaultValue { + val logger = env.logger + return EntityWithStringEnumPropertyWithDefaultValue( + value = JsonParser.readOptionalExpression(json, "value", Value.Converter.FROM_STRING, logger, env, VALUE_DEFAULT_VALUE, TYPE_HELPER_VALUE) ?: VALUE_DEFAULT_VALUE + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithStringEnumPropertyWithDefaultValue(env, json = it) } + } + + + enum class Value(private val value: String) { + FIRST("first"), + SECOND("second"), + THIRD("third"); + + companion object Converter { + fun toString(obj: Value): String { + return obj.value + } + + fun fromString(string: String): Value? { + return when (string) { + FIRST.value -> FIRST + SECOND.value -> SECOND + THIRD.value -> THIRD + else -> null + } + } + + val FROM_STRING = { string: String -> + when (string) { + FIRST.value -> FIRST + SECOND.value -> SECOND + THIRD.value -> THIRD + else -> null + } + } + } + } +} diff --git a/api_generator/tests/references/kotlin/EntityWithStringEnumPropertyWithDefaultValueTemplate.kt b/api_generator/tests/references/kotlin/EntityWithStringEnumPropertyWithDefaultValueTemplate.kt new file mode 100644 index 000000000..e270704db --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithStringEnumPropertyWithDefaultValueTemplate.kt @@ -0,0 +1,58 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithStringEnumPropertyWithDefaultValueTemplate : JSONSerializable, JsonTemplate { + @JvmField final val value: Field> // default value: second + + constructor ( + env: ParsingEnvironment, + parent: EntityWithStringEnumPropertyWithDefaultValueTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + value = JsonTemplateParser.readOptionalFieldWithExpression(json, "value", topLevel, parent?.value, EntityWithStringEnumPropertyWithDefaultValue.Value.Converter.FROM_STRING, logger, env, TYPE_HELPER_VALUE) + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithStringEnumPropertyWithDefaultValue { + return EntityWithStringEnumPropertyWithDefaultValue( + value = value.resolveOptional(env = env, key = "value", data = data, reader = VALUE_READER) ?: VALUE_DEFAULT_VALUE + ) + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "type", value = TYPE) + json.writeFieldWithExpression(key = "value", field = value, converter = { v: EntityWithStringEnumPropertyWithDefaultValue.Value -> EntityWithStringEnumPropertyWithDefaultValue.Value.toString(v) }) + return json + } + + companion object { + const val TYPE = "entity_with_string_enum_property_with_default_value" + + private val VALUE_DEFAULT_VALUE = Expression.constant(EntityWithStringEnumPropertyWithDefaultValue.Value.SECOND) + + private val TYPE_HELPER_VALUE = TypeHelper.from(default = EntityWithStringEnumPropertyWithDefaultValue.Value.values().first()) { it is EntityWithStringEnumPropertyWithDefaultValue.Value } + + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + val VALUE_READER: Reader?> = { key, json, env -> JsonParser.readOptionalExpression(json, key, EntityWithStringEnumPropertyWithDefaultValue.Value.Converter.FROM_STRING, env.logger, env, VALUE_DEFAULT_VALUE, TYPE_HELPER_VALUE) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithStringEnumPropertyWithDefaultValueTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithoutProperties.kt b/api_generator/tests/references/kotlin/EntityWithoutProperties.kt new file mode 100644 index 000000000..ed346e810 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithoutProperties.kt @@ -0,0 +1,41 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithoutProperties() : JSONSerializable { + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_without_properties" + + @JvmStatic + @JvmName("fromJson") + operator fun invoke(env: ParsingEnvironment, json: JSONObject): EntityWithoutProperties { + val logger = env.logger + return EntityWithoutProperties( + ) + } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithoutProperties(env, json = it) } + } + +} diff --git a/api_generator/tests/references/kotlin/EntityWithoutPropertiesTemplate.kt b/api_generator/tests/references/kotlin/EntityWithoutPropertiesTemplate.kt new file mode 100644 index 000000000..c98cf9f41 --- /dev/null +++ b/api_generator/tests/references/kotlin/EntityWithoutPropertiesTemplate.kt @@ -0,0 +1,48 @@ +// Generated code. Do not modify. + +package com.yandex.div2 + +import android.graphics.Color +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.json.* +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.* +import com.yandex.div.core.annotations.Mockable +import java.io.IOException +import java.util.BitSet +import org.json.JSONObject +import com.yandex.div.data.* + +@Mockable +class EntityWithoutPropertiesTemplate : JSONSerializable, JsonTemplate { + + constructor ( + env: ParsingEnvironment, + parent: EntityWithoutPropertiesTemplate? = null, + topLevel: Boolean = false, + json: JSONObject + ) { + val logger = env.logger + } + + override fun resolve(env: ParsingEnvironment, data: JSONObject): EntityWithoutProperties { + return EntityWithoutProperties() + } + + override fun writeToJSON(): JSONObject { + val json = JSONObject() + json.write(key = "type", value = TYPE) + return json + } + + companion object { + const val TYPE = "entity_without_properties" + + val TYPE_READER: Reader = { key, json, env -> JsonParser.read(json, key, env.logger, env) } + + val CREATOR = { env: ParsingEnvironment, it: JSONObject -> EntityWithoutPropertiesTemplate(env, json = it) } + } + +} diff --git a/api_generator/tests/references/reference_clean_unused_definitions.json b/api_generator/tests/references/reference_clean_unused_definitions.json new file mode 100644 index 000000000..39ee26994 --- /dev/null +++ b/api_generator/tests/references/reference_clean_unused_definitions.json @@ -0,0 +1,822 @@ +{ + "test_schema": [ + { + "common.json": { + "non_empty_string": { + "type": "string", + "minLength": 1, + "clientMinLength": 1 + }, + "color": { + "type": "string", + "format": "color" + }, + "url": { + "type": "string", + "format": "uri" + }, + "positive_integer": { + "type": "integer", + "constraint": "number > 0" + }, + "non_negative_integer": { + "type": "integer", + "constraint": "number >= 0" + }, + "boolean_int": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "format": "boolean" + } + } + }, + { + "entity.json": { + "anyOf": [ + { + "$resolved_refs": [ + "entity_with_array.json" + ], + "type": "$defined_entity_with_array" + }, + { + "$resolved_refs": [ + "entity_with_array_of_nested_items.json" + ], + "type": "$defined_entity_with_array_of_nested_items" + }, + { + "$resolved_refs": [ + "entity_with_array_with_transform.json" + ], + "type": "$defined_entity_with_array_with_transform" + }, + { + "$resolved_refs": [ + "entity_with_complex_property.json" + ], + "type": "$defined_entity_with_complex_property" + }, + { + "$resolved_refs": [ + "entity_with_complex_property_with_default_value.json" + ], + "type": "$defined_entity_with_complex_property_with_default_value" + }, + { + "$resolved_refs": [ + "entity_with_entity_property.json" + ], + "type": "$defined_entity_with_entity_property" + }, + { + "$resolved_refs": [ + "entity_with_optional_complex_property.json" + ], + "type": "$defined_entity_with_optional_complex_property" + }, + { + "$resolved_refs": [ + "entity_with_optional_property.json" + ], + "type": "$defined_entity_with_optional_property" + }, + { + "$resolved_refs": [ + "entity_with_optional_string_enum_property.json" + ], + "type": "$defined_entity_with_optional_string_enum_property" + }, + { + "$resolved_refs": [ + "entity_with_property_with_default_value.json" + ], + "type": "$defined_entity_with_property_with_default_value" + }, + { + "$resolved_refs": [ + "entity_with_required_property.json" + ], + "type": "$defined_entity_with_required_property" + }, + { + "$resolved_refs": [ + "entity_with_simple_properties.json" + ], + "type": "$defined_entity_with_simple_properties" + }, + { + "$resolved_refs": [ + "entity_with_strict_array.json" + ], + "type": "$defined_entity_with_strict_array" + }, + { + "$resolved_refs": [ + "entity_with_string_array_property.json" + ], + "type": "$defined_entity_with_string_array_property" + }, + { + "$resolved_refs": [ + "entity_with_string_enum_property.json" + ], + "type": "$defined_entity_with_string_enum_property" + }, + { + "$resolved_refs": [ + "entity_with_string_enum_property_with_default_value.json" + ], + "type": "$defined_entity_with_string_enum_property_with_default_value" + }, + { + "$resolved_refs": [ + "entity_without_properties.json" + ], + "type": "$defined_entity_without_properties" + } + ] + } + }, + { + "entity_with_array.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array" + ] + }, + "array": { + "type": "array", + "items": { + "$resolved_refs": [ + "entity.json" + ], + "type": "$defined_entity" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_array_of_nested_items.json": { + "type": "object", + "definitions": {}, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array_of_nested_items" + ] + }, + "items": { + "type": "array", + "items": { + "$resolved_refs": [ + "#/definitions/item" + ], + "type": "object", + "properties": { + "property": { + "$resolved_refs": [ + "common.json#/non_empty_string" + ], + "type": "string", + "minLength": 1, + "clientMinLength": 1 + }, + "entity": { + "$resolved_refs": [ + "entity.json" + ], + "type": "$defined_entity" + } + }, + "required": [ + "property", + "entity" + ], + "$typename": "item" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "items" + ] + } + }, + { + "entity_with_array_with_transform.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array_with_transform" + ] + }, + "array": { + "type": "array", + "items": { + "$resolved_refs": [ + "common.json#/color" + ], + "type": "string", + "format": "color" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_complex_property.json": { + "type": "object", + "definitions": {}, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_complex_property" + ] + }, + "property": { + "$resolved_refs": [ + "#/definitions/complex_property" + ], + "type": "object", + "properties": { + "value": { + "$resolved_refs": [ + "common.json#/url" + ], + "type": "string", + "format": "uri" + } + }, + "required": [ + "value" + ], + "$typename": "complex_property" + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_complex_property_with_default_value.json": { + "type": "object", + "definitions": {}, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_complex_property_with_default_value" + ] + }, + "property": { + "default_value": "{ \"value\": \"Default text\" }", + "$resolved_refs": [ + "#/definitions/complex_property" + ], + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "$typename": "complex_property" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_entity_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_entity_property" + ] + }, + "entity": { + "default_value": "{ \"type\": \"entity_with_string_enum_property\", \"property\": \"second\" }", + "$resolved_refs": [ + "entity.json" + ], + "type": "$defined_entity" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_complex_property.json": { + "type": "object", + "definitions": {}, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_complex_property" + ] + }, + "property": { + "$resolved_refs": [ + "#/definitions/complex_property" + ], + "type": "object", + "properties": { + "value": { + "$resolved_refs": [ + "common.json#/url" + ], + "type": "string", + "format": "uri" + } + }, + "required": [ + "value" + ], + "$typename": "complex_property" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_property" + ] + }, + "property": { + "$resolved_refs": [ + "common.json#/non_empty_string" + ], + "type": "string", + "minLength": 1, + "clientMinLength": 1 + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_string_enum_property.json": { + "type": "object", + "definitions": {}, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_string_enum_property" + ] + }, + "property": { + "$resolved_refs": [ + "#/definitions/string_enum_property" + ], + "type": "string", + "enum": [ + "first", + "second" + ], + "$typename": "string_enum_property" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_required_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_required_property" + ] + }, + "property": { + "$resolved_refs": [ + "common.json#/non_empty_string" + ], + "type": "string", + "minLength": 1, + "clientMinLength": 1 + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_strict_array.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_strict_array" + ] + }, + "array": { + "type": "array", + "items": { + "$resolved_refs": [ + "entity.json" + ], + "type": "$defined_entity" + }, + "minItems": 1, + "clientMinItems": 1, + "strictParsing": true + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_string_array_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_array_property" + ] + }, + "array": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_string_enum_property.json": { + "type": "object", + "definitions": {}, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_enum_property" + ] + }, + "property": { + "$resolved_refs": [ + "#/definitions/string_enum_property" + ], + "type": "string", + "enum": [ + "first", + "second" + ], + "$typename": "string_enum_property" + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_string_enum_property_with_default_value.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_enum_property_with_default_value" + ] + }, + "value": { + "type": "string", + "enum": [ + "first", + "second", + "third" + ], + "default_value": "second" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_without_properties.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_without_properties" + ] + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_property_with_default_value.json": { + "type": "object", + "definitions": {}, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_property_with_default_value" + ] + }, + "int": { + "$resolved_refs": [ + "#/definitions/int_prop", + "common.json#/non_negative_integer" + ], + "default_value": "0", + "type": "integer", + "constraint": "number >= 0" + }, + "url": { + "$resolved_refs": [ + "#/definitions/url_prop", + "common.json#/url" + ], + "schemes": [ + "https" + ], + "default_value": "https://yandex.ru", + "type": "string", + "format": "uri" + }, + "nested": { + "$resolved_refs": [ + "#/definitions/nested" + ], + "type": "object", + "properties": { + "int": { + "$resolved_refs": [ + "#/definitions/int_prop", + "common.json#/non_negative_integer" + ], + "default_value": "0", + "type": "integer", + "constraint": "number >= 0", + "$typename": "int_prop" + }, + "url": { + "$resolved_refs": [ + "#/definitions/url_prop", + "common.json#/url" + ], + "schemes": [ + "https" + ], + "default_value": "https://yandex.ru", + "type": "string", + "format": "uri", + "$typename": "url_prop" + }, + "non_optional": { + "type": "string" + } + }, + "required": [ + "non_optional" + ], + "$typename": "nested", + "description_translations": { + "en": "non_optional is used to suppress auto-generation of default value for object with all-optional fields.", + "ru": "non_optional используется, чтобы запретить автогенерацию дефолтного значения для объекта, все свойства которого опциональны." + } + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_simple_properties.json": { + "type": "object", + "swift_protocols": "EntityProtocol", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_simple_properties" + ] + }, + "id": { + "type": "integer", + "supports_expressions": false, + "description_translations": { + "en": "ID. Can't contain expressions.", + "ru": "Идентификатор. Не может содержать выражение." + } + }, + "string": { + "platforms": [ + "android", + "ios", + "web" + ], + "description_translations": { + "en": "String.", + "ru": "Строка." + }, + "$resolved_refs": [ + "common.json#/non_empty_string" + ], + "type": "string", + "minLength": 1, + "clientMinLength": 1 + }, + "integer": { + "type": "integer", + "description_translations": { + "en": "Integer.", + "ru": "Целое число." + } + }, + "double": { + "type": "number", + "description_translations": { + "en": "Floating point number.", + "ru": "Число с плавающей точкой." + } + }, + "boolean": { + "type": "boolean", + "description_translations": { + "en": "Boolean property.", + "ru": "Логическое значение." + } + }, + "boolean_int": { + "deprecated": true, + "platforms": [ + "android" + ], + "description_translations": { + "en": "Boolean value in numeric format.", + "ru": "Логическое значение в числовом формате." + }, + "$resolved_refs": [ + "common.json#/boolean_int" + ], + "type": "integer", + "enum": [ + 0, + 1 + ], + "format": "boolean" + }, + "positive_integer": { + "platforms": [], + "description_translations": { + "en": "Positive integer.", + "ru": "Положительное целое число." + }, + "$resolved_refs": [ + "common.json#/positive_integer" + ], + "type": "integer", + "constraint": "number > 0" + }, + "color": { + "description_translations": { + "en": "Color.", + "ru": "Цвет." + }, + "$resolved_refs": [ + "common.json#/color" + ], + "type": "string", + "format": "color" + }, + "url": { + "$resolved_refs": [ + "common.json#/url" + ], + "type": "string", + "format": "uri" + } + }, + "required": [ + "type" + ], + "description_translations": { + "en": "Entity with simple properties.", + "ru": "Объект с простыми свойствами." + } + } + }, + { + "translations.json": { + "entity_with_property_with_default_value_nested": { + "en": "non_optional is used to suppress auto-generation of default value for object with all-optional fields.", + "ru": "non_optional используется, чтобы запретить автогенерацию дефолтного значения для объекта, все свойства которого опциональны." + }, + "entity_with_simple_properties": { + "en": "Entity with simple properties.", + "ru": "Объект с простыми свойствами." + }, + "entity_with_simple_properties_id": { + "en": "ID. Can't contain expressions.", + "ru": "Идентификатор. Не может содержать выражение." + }, + "entity_with_simple_properties_string": { + "en": "String.", + "ru": "Строка." + }, + "entity_with_simple_properties_integer": { + "en": "Integer.", + "ru": "Целое число." + }, + "entity_with_simple_properties_double": { + "en": "Floating point number.", + "ru": "Число с плавающей точкой." + }, + "entity_with_simple_properties_boolean": { + "en": "Boolean property.", + "ru": "Логическое значение." + }, + "entity_with_simple_properties_boolean_int": { + "en": "Boolean value in numeric format.", + "ru": "Логическое значение в числовом формате." + }, + "entity_with_simple_properties_positive_integer": { + "en": "Positive integer.", + "ru": "Положительное целое число." + }, + "entity_with_simple_properties_color": { + "en": "Color.", + "ru": "Цвет." + } + } + } + ] +} \ No newline at end of file diff --git a/api_generator/tests/references/reference_merge_allOfs.json b/api_generator/tests/references/reference_merge_allOfs.json new file mode 100644 index 000000000..e6b5a737f --- /dev/null +++ b/api_generator/tests/references/reference_merge_allOfs.json @@ -0,0 +1,656 @@ +{ + "test_schema": [ + { + "common.json": { + "non_empty_string": { + "type": "string", + "minLength": 1, + "clientMinLength": 1 + }, + "color": { + "type": "string", + "format": "color" + }, + "url": { + "type": "string", + "format": "uri" + }, + "positive_integer": { + "type": "integer", + "constraint": "number > 0" + }, + "non_negative_integer": { + "type": "integer", + "constraint": "number >= 0" + }, + "boolean_int": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "format": "boolean" + } + } + }, + { + "entity.json": { + "anyOf": [ + { + "$ref": "entity_with_array.json" + }, + { + "$ref": "entity_with_array_of_nested_items.json" + }, + { + "$ref": "entity_with_array_with_transform.json" + }, + { + "$ref": "entity_with_complex_property.json" + }, + { + "$ref": "entity_with_complex_property_with_default_value.json" + }, + { + "$ref": "entity_with_entity_property.json" + }, + { + "$ref": "entity_with_optional_complex_property.json" + }, + { + "$ref": "entity_with_optional_property.json" + }, + { + "$ref": "entity_with_optional_string_enum_property.json" + }, + { + "$ref": "entity_with_property_with_default_value.json" + }, + { + "$ref": "entity_with_required_property.json" + }, + { + "$ref": "entity_with_simple_properties.json" + }, + { + "$ref": "entity_with_strict_array.json" + }, + { + "$ref": "entity_with_string_array_property.json" + }, + { + "$ref": "entity_with_string_enum_property.json" + }, + { + "$ref": "entity_with_string_enum_property_with_default_value.json" + }, + { + "$ref": "entity_without_properties.json" + } + ] + } + }, + { + "entity_with_array.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array" + ] + }, + "array": { + "type": "array", + "items": { + "$ref": "entity.json" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_array_of_nested_items.json": { + "type": "object", + "definitions": { + "item": { + "type": "object", + "properties": { + "property": { + "$ref": "common.json#/non_empty_string" + }, + "entity": { + "$ref": "entity.json" + } + }, + "required": [ + "property", + "entity" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array_of_nested_items" + ] + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/item" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "items" + ] + } + }, + { + "entity_with_array_with_transform.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array_with_transform" + ] + }, + "array": { + "type": "array", + "items": { + "$ref": "common.json#/color" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_complex_property.json": { + "type": "object", + "definitions": { + "complex_property": { + "type": "object", + "properties": { + "value": { + "$ref": "common.json#/url" + } + }, + "required": [ + "value" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_complex_property" + ] + }, + "property": { + "$ref": "#/definitions/complex_property" + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_complex_property_with_default_value.json": { + "type": "object", + "definitions": { + "complex_property": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_complex_property_with_default_value" + ] + }, + "property": { + "$ref": "#/definitions/complex_property", + "default_value": "{ \"value\": \"Default text\" }" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_entity_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_entity_property" + ] + }, + "entity": { + "$ref": "entity.json", + "default_value": "{ \"type\": \"entity_with_string_enum_property\", \"property\": \"second\" }" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_complex_property.json": { + "type": "object", + "definitions": { + "complex_property": { + "type": "object", + "properties": { + "value": { + "$ref": "common.json#/url" + } + }, + "required": [ + "value" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_complex_property" + ] + }, + "property": { + "$ref": "#/definitions/complex_property" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_property" + ] + }, + "property": { + "$ref": "common.json#/non_empty_string" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_string_enum_property.json": { + "type": "object", + "definitions": { + "string_enum_property": { + "type": "string", + "enum": [ + "first", + "second" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_string_enum_property" + ] + }, + "property": { + "$ref": "#/definitions/string_enum_property" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_required_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_required_property" + ] + }, + "property": { + "$ref": "common.json#/non_empty_string" + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_strict_array.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_strict_array" + ] + }, + "array": { + "type": "array", + "items": { + "$ref": "entity.json" + }, + "minItems": 1, + "clientMinItems": 1, + "strictParsing": true + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_string_array_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_array_property" + ] + }, + "array": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_string_enum_property.json": { + "type": "object", + "definitions": { + "string_enum_property": { + "type": "string", + "enum": [ + "first", + "second" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_enum_property" + ] + }, + "property": { + "$ref": "#/definitions/string_enum_property" + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_string_enum_property_with_default_value.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_enum_property_with_default_value" + ] + }, + "value": { + "type": "string", + "enum": [ + "first", + "second", + "third" + ], + "default_value": "second" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_without_properties.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_without_properties" + ] + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_property_with_default_value.json": { + "type": "object", + "definitions": { + "int_prop": { + "$ref": "common.json#/non_negative_integer", + "default_value": "0" + }, + "url_prop": { + "$ref": "common.json#/url", + "schemes": [ + "https" + ], + "default_value": "https://yandex.ru" + }, + "nested": { + "type": "object", + "properties": { + "int": { + "$ref": "#/definitions/int_prop" + }, + "url": { + "$ref": "#/definitions/url_prop" + }, + "non_optional": { + "type": "string" + } + }, + "$description": "translations.json#/entity_with_property_with_default_value_nested", + "required": [ + "non_optional" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_property_with_default_value" + ] + }, + "int": { + "$ref": "#/definitions/int_prop" + }, + "url": { + "$ref": "#/definitions/url_prop" + }, + "nested": { + "$ref": "#/definitions/nested" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_simple_properties.json": { + "type": "object", + "$description": "translations.json#/entity_with_simple_properties", + "swift_protocols": "EntityProtocol", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_simple_properties" + ] + }, + "id": { + "type": "integer", + "supports_expressions": false, + "$description": "translations.json#/entity_with_simple_properties_id" + }, + "string": { + "$ref": "common.json#/non_empty_string", + "$description": "translations.json#/entity_with_simple_properties_string", + "platforms": [ + "android", + "ios", + "web" + ] + }, + "integer": { + "type": "integer", + "$description": "translations.json#/entity_with_simple_properties_integer" + }, + "double": { + "type": "number", + "$description": "translations.json#/entity_with_simple_properties_double" + }, + "boolean": { + "type": "boolean", + "$description": "translations.json#/entity_with_simple_properties_boolean" + }, + "boolean_int": { + "$ref": "common.json#/boolean_int", + "$description": "translations.json#/entity_with_simple_properties_boolean_int", + "deprecated": true, + "platforms": [ + "android" + ] + }, + "positive_integer": { + "$ref": "common.json#/positive_integer", + "$description": "translations.json#/entity_with_simple_properties_positive_integer", + "platforms": [] + }, + "color": { + "$ref": "common.json#/color", + "$description": "translations.json#/entity_with_simple_properties_color" + }, + "url": { + "$ref": "common.json#/url" + } + }, + "required": [ + "type" + ] + } + }, + { + "translations.json": { + "entity_with_property_with_default_value_nested": { + "en": "non_optional is used to suppress auto-generation of default value for object with all-optional fields.", + "ru": "non_optional используется, чтобы запретить автогенерацию дефолтного значения для объекта, все свойства которого опциональны." + }, + "entity_with_simple_properties": { + "en": "Entity with simple properties.", + "ru": "Объект с простыми свойствами." + }, + "entity_with_simple_properties_id": { + "en": "ID. Can't contain expressions.", + "ru": "Идентификатор. Не может содержать выражение." + }, + "entity_with_simple_properties_string": { + "en": "String.", + "ru": "Строка." + }, + "entity_with_simple_properties_integer": { + "en": "Integer.", + "ru": "Целое число." + }, + "entity_with_simple_properties_double": { + "en": "Floating point number.", + "ru": "Число с плавающей точкой." + }, + "entity_with_simple_properties_boolean": { + "en": "Boolean property.", + "ru": "Логическое значение." + }, + "entity_with_simple_properties_boolean_int": { + "en": "Boolean value in numeric format.", + "ru": "Логическое значение в числовом формате." + }, + "entity_with_simple_properties_positive_integer": { + "en": "Positive integer.", + "ru": "Положительное целое число." + }, + "entity_with_simple_properties_color": { + "en": "Color.", + "ru": "Цвет." + } + } + } + ] +} \ No newline at end of file diff --git a/api_generator/tests/references/reference_resolve_references.json b/api_generator/tests/references/reference_resolve_references.json new file mode 100644 index 000000000..4ca4b7503 --- /dev/null +++ b/api_generator/tests/references/reference_resolve_references.json @@ -0,0 +1,962 @@ +{ + "test_schema": [ + { + "common.json": { + "non_empty_string": { + "type": "string", + "minLength": 1, + "clientMinLength": 1 + }, + "color": { + "type": "string", + "format": "color" + }, + "url": { + "type": "string", + "format": "uri" + }, + "positive_integer": { + "type": "integer", + "constraint": "number > 0" + }, + "non_negative_integer": { + "type": "integer", + "constraint": "number >= 0" + }, + "boolean_int": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "format": "boolean" + } + } + }, + { + "entity.json": { + "anyOf": [ + { + "$resolved_refs": [ + "entity_with_array.json" + ], + "type": "$defined_entity_with_array" + }, + { + "$resolved_refs": [ + "entity_with_array_of_nested_items.json" + ], + "type": "$defined_entity_with_array_of_nested_items" + }, + { + "$resolved_refs": [ + "entity_with_array_with_transform.json" + ], + "type": "$defined_entity_with_array_with_transform" + }, + { + "$resolved_refs": [ + "entity_with_complex_property.json" + ], + "type": "$defined_entity_with_complex_property" + }, + { + "$resolved_refs": [ + "entity_with_complex_property_with_default_value.json" + ], + "type": "$defined_entity_with_complex_property_with_default_value" + }, + { + "$resolved_refs": [ + "entity_with_entity_property.json" + ], + "type": "$defined_entity_with_entity_property" + }, + { + "$resolved_refs": [ + "entity_with_optional_complex_property.json" + ], + "type": "$defined_entity_with_optional_complex_property" + }, + { + "$resolved_refs": [ + "entity_with_optional_property.json" + ], + "type": "$defined_entity_with_optional_property" + }, + { + "$resolved_refs": [ + "entity_with_optional_string_enum_property.json" + ], + "type": "$defined_entity_with_optional_string_enum_property" + }, + { + "$resolved_refs": [ + "entity_with_property_with_default_value.json" + ], + "type": "$defined_entity_with_property_with_default_value" + }, + { + "$resolved_refs": [ + "entity_with_required_property.json" + ], + "type": "$defined_entity_with_required_property" + }, + { + "$resolved_refs": [ + "entity_with_simple_properties.json" + ], + "type": "$defined_entity_with_simple_properties" + }, + { + "$resolved_refs": [ + "entity_with_strict_array.json" + ], + "type": "$defined_entity_with_strict_array" + }, + { + "$resolved_refs": [ + "entity_with_string_array_property.json" + ], + "type": "$defined_entity_with_string_array_property" + }, + { + "$resolved_refs": [ + "entity_with_string_enum_property.json" + ], + "type": "$defined_entity_with_string_enum_property" + }, + { + "$resolved_refs": [ + "entity_with_string_enum_property_with_default_value.json" + ], + "type": "$defined_entity_with_string_enum_property_with_default_value" + }, + { + "$resolved_refs": [ + "entity_without_properties.json" + ], + "type": "$defined_entity_without_properties" + } + ] + } + }, + { + "entity_with_array.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array" + ] + }, + "array": { + "type": "array", + "items": { + "$resolved_refs": [ + "entity.json" + ], + "type": "$defined_entity" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_array_of_nested_items.json": { + "type": "object", + "definitions": { + "item": { + "type": "object", + "properties": { + "property": { + "$resolved_refs": [ + "common.json#/non_empty_string" + ], + "type": "string", + "minLength": 1, + "clientMinLength": 1 + }, + "entity": { + "$resolved_refs": [ + "entity.json" + ], + "type": "$defined_entity" + } + }, + "required": [ + "property", + "entity" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array_of_nested_items" + ] + }, + "items": { + "type": "array", + "items": { + "$resolved_refs": [ + "#/definitions/item" + ], + "type": "object", + "properties": { + "property": { + "$resolved_refs": [ + "common.json#/non_empty_string" + ], + "type": "string", + "minLength": 1, + "clientMinLength": 1 + }, + "entity": { + "$resolved_refs": [ + "entity.json" + ], + "type": "$defined_entity" + } + }, + "required": [ + "property", + "entity" + ], + "$typename": "item" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "items" + ] + } + }, + { + "entity_with_array_with_transform.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array_with_transform" + ] + }, + "array": { + "type": "array", + "items": { + "$resolved_refs": [ + "common.json#/color" + ], + "type": "string", + "format": "color" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_complex_property.json": { + "type": "object", + "definitions": { + "complex_property": { + "type": "object", + "properties": { + "value": { + "$resolved_refs": [ + "common.json#/url" + ], + "type": "string", + "format": "uri" + } + }, + "required": [ + "value" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_complex_property" + ] + }, + "property": { + "$resolved_refs": [ + "#/definitions/complex_property" + ], + "type": "object", + "properties": { + "value": { + "$resolved_refs": [ + "common.json#/url" + ], + "type": "string", + "format": "uri" + } + }, + "required": [ + "value" + ], + "$typename": "complex_property" + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_complex_property_with_default_value.json": { + "type": "object", + "definitions": { + "complex_property": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_complex_property_with_default_value" + ] + }, + "property": { + "default_value": "{ \"value\": \"Default text\" }", + "$resolved_refs": [ + "#/definitions/complex_property" + ], + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "$typename": "complex_property" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_entity_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_entity_property" + ] + }, + "entity": { + "default_value": "{ \"type\": \"entity_with_string_enum_property\", \"property\": \"second\" }", + "$resolved_refs": [ + "entity.json" + ], + "type": "$defined_entity" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_complex_property.json": { + "type": "object", + "definitions": { + "complex_property": { + "type": "object", + "properties": { + "value": { + "$resolved_refs": [ + "common.json#/url" + ], + "type": "string", + "format": "uri" + } + }, + "required": [ + "value" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_complex_property" + ] + }, + "property": { + "$resolved_refs": [ + "#/definitions/complex_property" + ], + "type": "object", + "properties": { + "value": { + "$resolved_refs": [ + "common.json#/url" + ], + "type": "string", + "format": "uri" + } + }, + "required": [ + "value" + ], + "$typename": "complex_property" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_property" + ] + }, + "property": { + "$resolved_refs": [ + "common.json#/non_empty_string" + ], + "type": "string", + "minLength": 1, + "clientMinLength": 1 + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_string_enum_property.json": { + "type": "object", + "definitions": { + "string_enum_property": { + "type": "string", + "enum": [ + "first", + "second" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_string_enum_property" + ] + }, + "property": { + "$resolved_refs": [ + "#/definitions/string_enum_property" + ], + "type": "string", + "enum": [ + "first", + "second" + ], + "$typename": "string_enum_property" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_required_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_required_property" + ] + }, + "property": { + "$resolved_refs": [ + "common.json#/non_empty_string" + ], + "type": "string", + "minLength": 1, + "clientMinLength": 1 + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_strict_array.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_strict_array" + ] + }, + "array": { + "type": "array", + "items": { + "$resolved_refs": [ + "entity.json" + ], + "type": "$defined_entity" + }, + "minItems": 1, + "clientMinItems": 1, + "strictParsing": true + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_string_array_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_array_property" + ] + }, + "array": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_string_enum_property.json": { + "type": "object", + "definitions": { + "string_enum_property": { + "type": "string", + "enum": [ + "first", + "second" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_enum_property" + ] + }, + "property": { + "$resolved_refs": [ + "#/definitions/string_enum_property" + ], + "type": "string", + "enum": [ + "first", + "second" + ], + "$typename": "string_enum_property" + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_string_enum_property_with_default_value.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_enum_property_with_default_value" + ] + }, + "value": { + "type": "string", + "enum": [ + "first", + "second", + "third" + ], + "default_value": "second" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_without_properties.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_without_properties" + ] + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_property_with_default_value.json": { + "type": "object", + "definitions": { + "int_prop": { + "default_value": "0", + "$resolved_refs": [ + "common.json#/non_negative_integer" + ], + "type": "integer", + "constraint": "number >= 0" + }, + "url_prop": { + "schemes": [ + "https" + ], + "default_value": "https://yandex.ru", + "$resolved_refs": [ + "common.json#/url" + ], + "type": "string", + "format": "uri" + }, + "nested": { + "type": "object", + "properties": { + "int": { + "$resolved_refs": [ + "#/definitions/int_prop", + "common.json#/non_negative_integer" + ], + "default_value": "0", + "type": "integer", + "constraint": "number >= 0" + }, + "url": { + "$resolved_refs": [ + "#/definitions/url_prop", + "common.json#/url" + ], + "schemes": [ + "https" + ], + "default_value": "https://yandex.ru", + "type": "string", + "format": "uri" + }, + "non_optional": { + "type": "string" + } + }, + "required": [ + "non_optional" + ], + "description_translations": { + "en": "non_optional is used to suppress auto-generation of default value for object with all-optional fields.", + "ru": "non_optional используется, чтобы запретить автогенерацию дефолтного значения для объекта, все свойства которого опциональны." + } + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_property_with_default_value" + ] + }, + "int": { + "$resolved_refs": [ + "#/definitions/int_prop", + "common.json#/non_negative_integer" + ], + "default_value": "0", + "type": "integer", + "constraint": "number >= 0" + }, + "url": { + "$resolved_refs": [ + "#/definitions/url_prop", + "common.json#/url" + ], + "schemes": [ + "https" + ], + "default_value": "https://yandex.ru", + "type": "string", + "format": "uri" + }, + "nested": { + "$resolved_refs": [ + "#/definitions/nested" + ], + "type": "object", + "properties": { + "int": { + "$resolved_refs": [ + "#/definitions/int_prop", + "common.json#/non_negative_integer" + ], + "default_value": "0", + "type": "integer", + "constraint": "number >= 0", + "$typename": "int_prop" + }, + "url": { + "$resolved_refs": [ + "#/definitions/url_prop", + "common.json#/url" + ], + "schemes": [ + "https" + ], + "default_value": "https://yandex.ru", + "type": "string", + "format": "uri", + "$typename": "url_prop" + }, + "non_optional": { + "type": "string" + } + }, + "required": [ + "non_optional" + ], + "$typename": "nested", + "description_translations": { + "en": "non_optional is used to suppress auto-generation of default value for object with all-optional fields.", + "ru": "non_optional используется, чтобы запретить автогенерацию дефолтного значения для объекта, все свойства которого опциональны." + } + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_simple_properties.json": { + "type": "object", + "swift_protocols": "EntityProtocol", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_simple_properties" + ] + }, + "id": { + "type": "integer", + "supports_expressions": false, + "description_translations": { + "en": "ID. Can't contain expressions.", + "ru": "Идентификатор. Не может содержать выражение." + } + }, + "string": { + "platforms": [ + "android", + "ios", + "web" + ], + "description_translations": { + "en": "String.", + "ru": "Строка." + }, + "$resolved_refs": [ + "common.json#/non_empty_string" + ], + "type": "string", + "minLength": 1, + "clientMinLength": 1 + }, + "integer": { + "type": "integer", + "description_translations": { + "en": "Integer.", + "ru": "Целое число." + } + }, + "double": { + "type": "number", + "description_translations": { + "en": "Floating point number.", + "ru": "Число с плавающей точкой." + } + }, + "boolean": { + "type": "boolean", + "description_translations": { + "en": "Boolean property.", + "ru": "Логическое значение." + } + }, + "boolean_int": { + "deprecated": true, + "platforms": [ + "android" + ], + "description_translations": { + "en": "Boolean value in numeric format.", + "ru": "Логическое значение в числовом формате." + }, + "$resolved_refs": [ + "common.json#/boolean_int" + ], + "type": "integer", + "enum": [ + 0, + 1 + ], + "format": "boolean" + }, + "positive_integer": { + "platforms": [], + "description_translations": { + "en": "Positive integer.", + "ru": "Положительное целое число." + }, + "$resolved_refs": [ + "common.json#/positive_integer" + ], + "type": "integer", + "constraint": "number > 0" + }, + "color": { + "description_translations": { + "en": "Color.", + "ru": "Цвет." + }, + "$resolved_refs": [ + "common.json#/color" + ], + "type": "string", + "format": "color" + }, + "url": { + "$resolved_refs": [ + "common.json#/url" + ], + "type": "string", + "format": "uri" + } + }, + "required": [ + "type" + ], + "description_translations": { + "en": "Entity with simple properties.", + "ru": "Объект с простыми свойствами." + } + } + }, + { + "translations.json": { + "entity_with_property_with_default_value_nested": { + "en": "non_optional is used to suppress auto-generation of default value for object with all-optional fields.", + "ru": "non_optional используется, чтобы запретить автогенерацию дефолтного значения для объекта, все свойства которого опциональны." + }, + "entity_with_simple_properties": { + "en": "Entity with simple properties.", + "ru": "Объект с простыми свойствами." + }, + "entity_with_simple_properties_id": { + "en": "ID. Can't contain expressions.", + "ru": "Идентификатор. Не может содержать выражение." + }, + "entity_with_simple_properties_string": { + "en": "String.", + "ru": "Строка." + }, + "entity_with_simple_properties_integer": { + "en": "Integer.", + "ru": "Целое число." + }, + "entity_with_simple_properties_double": { + "en": "Floating point number.", + "ru": "Число с плавающей точкой." + }, + "entity_with_simple_properties_boolean": { + "en": "Boolean property.", + "ru": "Логическое значение." + }, + "entity_with_simple_properties_boolean_int": { + "en": "Boolean value in numeric format.", + "ru": "Логическое значение в числовом формате." + }, + "entity_with_simple_properties_positive_integer": { + "en": "Positive integer.", + "ru": "Положительное целое число." + }, + "entity_with_simple_properties_color": { + "en": "Color.", + "ru": "Цвет." + } + } + } + ] +} \ No newline at end of file diff --git a/api_generator/tests/references/reference_resolve_structure.json b/api_generator/tests/references/reference_resolve_structure.json new file mode 100644 index 000000000..e6b5a737f --- /dev/null +++ b/api_generator/tests/references/reference_resolve_structure.json @@ -0,0 +1,656 @@ +{ + "test_schema": [ + { + "common.json": { + "non_empty_string": { + "type": "string", + "minLength": 1, + "clientMinLength": 1 + }, + "color": { + "type": "string", + "format": "color" + }, + "url": { + "type": "string", + "format": "uri" + }, + "positive_integer": { + "type": "integer", + "constraint": "number > 0" + }, + "non_negative_integer": { + "type": "integer", + "constraint": "number >= 0" + }, + "boolean_int": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "format": "boolean" + } + } + }, + { + "entity.json": { + "anyOf": [ + { + "$ref": "entity_with_array.json" + }, + { + "$ref": "entity_with_array_of_nested_items.json" + }, + { + "$ref": "entity_with_array_with_transform.json" + }, + { + "$ref": "entity_with_complex_property.json" + }, + { + "$ref": "entity_with_complex_property_with_default_value.json" + }, + { + "$ref": "entity_with_entity_property.json" + }, + { + "$ref": "entity_with_optional_complex_property.json" + }, + { + "$ref": "entity_with_optional_property.json" + }, + { + "$ref": "entity_with_optional_string_enum_property.json" + }, + { + "$ref": "entity_with_property_with_default_value.json" + }, + { + "$ref": "entity_with_required_property.json" + }, + { + "$ref": "entity_with_simple_properties.json" + }, + { + "$ref": "entity_with_strict_array.json" + }, + { + "$ref": "entity_with_string_array_property.json" + }, + { + "$ref": "entity_with_string_enum_property.json" + }, + { + "$ref": "entity_with_string_enum_property_with_default_value.json" + }, + { + "$ref": "entity_without_properties.json" + } + ] + } + }, + { + "entity_with_array.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array" + ] + }, + "array": { + "type": "array", + "items": { + "$ref": "entity.json" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_array_of_nested_items.json": { + "type": "object", + "definitions": { + "item": { + "type": "object", + "properties": { + "property": { + "$ref": "common.json#/non_empty_string" + }, + "entity": { + "$ref": "entity.json" + } + }, + "required": [ + "property", + "entity" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array_of_nested_items" + ] + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/item" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "items" + ] + } + }, + { + "entity_with_array_with_transform.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_array_with_transform" + ] + }, + "array": { + "type": "array", + "items": { + "$ref": "common.json#/color" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_complex_property.json": { + "type": "object", + "definitions": { + "complex_property": { + "type": "object", + "properties": { + "value": { + "$ref": "common.json#/url" + } + }, + "required": [ + "value" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_complex_property" + ] + }, + "property": { + "$ref": "#/definitions/complex_property" + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_complex_property_with_default_value.json": { + "type": "object", + "definitions": { + "complex_property": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_complex_property_with_default_value" + ] + }, + "property": { + "$ref": "#/definitions/complex_property", + "default_value": "{ \"value\": \"Default text\" }" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_entity_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_entity_property" + ] + }, + "entity": { + "$ref": "entity.json", + "default_value": "{ \"type\": \"entity_with_string_enum_property\", \"property\": \"second\" }" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_complex_property.json": { + "type": "object", + "definitions": { + "complex_property": { + "type": "object", + "properties": { + "value": { + "$ref": "common.json#/url" + } + }, + "required": [ + "value" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_complex_property" + ] + }, + "property": { + "$ref": "#/definitions/complex_property" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_property" + ] + }, + "property": { + "$ref": "common.json#/non_empty_string" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_optional_string_enum_property.json": { + "type": "object", + "definitions": { + "string_enum_property": { + "type": "string", + "enum": [ + "first", + "second" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_optional_string_enum_property" + ] + }, + "property": { + "$ref": "#/definitions/string_enum_property" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_required_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_required_property" + ] + }, + "property": { + "$ref": "common.json#/non_empty_string" + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_strict_array.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_strict_array" + ] + }, + "array": { + "type": "array", + "items": { + "$ref": "entity.json" + }, + "minItems": 1, + "clientMinItems": 1, + "strictParsing": true + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_string_array_property.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_array_property" + ] + }, + "array": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "clientMinItems": 1 + } + }, + "required": [ + "type", + "array" + ] + } + }, + { + "entity_with_string_enum_property.json": { + "type": "object", + "definitions": { + "string_enum_property": { + "type": "string", + "enum": [ + "first", + "second" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_enum_property" + ] + }, + "property": { + "$ref": "#/definitions/string_enum_property" + } + }, + "required": [ + "type", + "property" + ] + } + }, + { + "entity_with_string_enum_property_with_default_value.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_string_enum_property_with_default_value" + ] + }, + "value": { + "type": "string", + "enum": [ + "first", + "second", + "third" + ], + "default_value": "second" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_without_properties.json": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_without_properties" + ] + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_property_with_default_value.json": { + "type": "object", + "definitions": { + "int_prop": { + "$ref": "common.json#/non_negative_integer", + "default_value": "0" + }, + "url_prop": { + "$ref": "common.json#/url", + "schemes": [ + "https" + ], + "default_value": "https://yandex.ru" + }, + "nested": { + "type": "object", + "properties": { + "int": { + "$ref": "#/definitions/int_prop" + }, + "url": { + "$ref": "#/definitions/url_prop" + }, + "non_optional": { + "type": "string" + } + }, + "$description": "translations.json#/entity_with_property_with_default_value_nested", + "required": [ + "non_optional" + ] + } + }, + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_property_with_default_value" + ] + }, + "int": { + "$ref": "#/definitions/int_prop" + }, + "url": { + "$ref": "#/definitions/url_prop" + }, + "nested": { + "$ref": "#/definitions/nested" + } + }, + "required": [ + "type" + ] + } + }, + { + "entity_with_simple_properties.json": { + "type": "object", + "$description": "translations.json#/entity_with_simple_properties", + "swift_protocols": "EntityProtocol", + "properties": { + "type": { + "type": "string", + "enum": [ + "entity_with_simple_properties" + ] + }, + "id": { + "type": "integer", + "supports_expressions": false, + "$description": "translations.json#/entity_with_simple_properties_id" + }, + "string": { + "$ref": "common.json#/non_empty_string", + "$description": "translations.json#/entity_with_simple_properties_string", + "platforms": [ + "android", + "ios", + "web" + ] + }, + "integer": { + "type": "integer", + "$description": "translations.json#/entity_with_simple_properties_integer" + }, + "double": { + "type": "number", + "$description": "translations.json#/entity_with_simple_properties_double" + }, + "boolean": { + "type": "boolean", + "$description": "translations.json#/entity_with_simple_properties_boolean" + }, + "boolean_int": { + "$ref": "common.json#/boolean_int", + "$description": "translations.json#/entity_with_simple_properties_boolean_int", + "deprecated": true, + "platforms": [ + "android" + ] + }, + "positive_integer": { + "$ref": "common.json#/positive_integer", + "$description": "translations.json#/entity_with_simple_properties_positive_integer", + "platforms": [] + }, + "color": { + "$ref": "common.json#/color", + "$description": "translations.json#/entity_with_simple_properties_color" + }, + "url": { + "$ref": "common.json#/url" + } + }, + "required": [ + "type" + ] + } + }, + { + "translations.json": { + "entity_with_property_with_default_value_nested": { + "en": "non_optional is used to suppress auto-generation of default value for object with all-optional fields.", + "ru": "non_optional используется, чтобы запретить автогенерацию дефолтного значения для объекта, все свойства которого опциональны." + }, + "entity_with_simple_properties": { + "en": "Entity with simple properties.", + "ru": "Объект с простыми свойствами." + }, + "entity_with_simple_properties_id": { + "en": "ID. Can't contain expressions.", + "ru": "Идентификатор. Не может содержать выражение." + }, + "entity_with_simple_properties_string": { + "en": "String.", + "ru": "Строка." + }, + "entity_with_simple_properties_integer": { + "en": "Integer.", + "ru": "Целое число." + }, + "entity_with_simple_properties_double": { + "en": "Floating point number.", + "ru": "Число с плавающей точкой." + }, + "entity_with_simple_properties_boolean": { + "en": "Boolean property.", + "ru": "Логическое значение." + }, + "entity_with_simple_properties_boolean_int": { + "en": "Boolean value in numeric format.", + "ru": "Логическое значение в числовом формате." + }, + "entity_with_simple_properties_positive_integer": { + "en": "Positive integer.", + "ru": "Положительное целое число." + }, + "entity_with_simple_properties_color": { + "en": "Color.", + "ru": "Цвет." + } + } + } + ] +} \ No newline at end of file diff --git a/api_generator/tests/references/swift/Entity.swift b/api_generator/tests/references/swift/Entity.swift new file mode 100644 index 000000000..489d32df0 --- /dev/null +++ b/api_generator/tests/references/swift/Entity.swift @@ -0,0 +1,115 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public enum Entity { + case entityWithArray(EntityWithArray) + case entityWithArrayOfNestedItems(EntityWithArrayOfNestedItems) + case entityWithArrayWithTransform(EntityWithArrayWithTransform) + case entityWithComplexProperty(EntityWithComplexProperty) + case entityWithComplexPropertyWithDefaultValue(EntityWithComplexPropertyWithDefaultValue) + case entityWithEntityProperty(EntityWithEntityProperty) + case entityWithOptionalComplexProperty(EntityWithOptionalComplexProperty) + case entityWithOptionalProperty(EntityWithOptionalProperty) + case entityWithOptionalStringEnumProperty(EntityWithOptionalStringEnumProperty) + case entityWithPropertyWithDefaultValue(EntityWithPropertyWithDefaultValue) + case entityWithRequiredProperty(EntityWithRequiredProperty) + case entityWithSimpleProperties(EntityWithSimpleProperties) + case entityWithStrictArray(EntityWithStrictArray) + case entityWithStringArrayProperty(EntityWithStringArrayProperty) + case entityWithStringEnumProperty(EntityWithStringEnumProperty) + case entityWithStringEnumPropertyWithDefaultValue(EntityWithStringEnumPropertyWithDefaultValue) + case entityWithoutProperties(EntityWithoutProperties) + + public var value: Serializable { + switch self { + case let .entityWithArray(value): + return value + case let .entityWithArrayOfNestedItems(value): + return value + case let .entityWithArrayWithTransform(value): + return value + case let .entityWithComplexProperty(value): + return value + case let .entityWithComplexPropertyWithDefaultValue(value): + return value + case let .entityWithEntityProperty(value): + return value + case let .entityWithOptionalComplexProperty(value): + return value + case let .entityWithOptionalProperty(value): + return value + case let .entityWithOptionalStringEnumProperty(value): + return value + case let .entityWithPropertyWithDefaultValue(value): + return value + case let .entityWithRequiredProperty(value): + return value + case let .entityWithSimpleProperties(value): + return value + case let .entityWithStrictArray(value): + return value + case let .entityWithStringArrayProperty(value): + return value + case let .entityWithStringEnumProperty(value): + return value + case let .entityWithStringEnumPropertyWithDefaultValue(value): + return value + case let .entityWithoutProperties(value): + return value + } + } +} + +#if DEBUG +extension Entity: Equatable { + public static func ==(lhs: Entity, rhs: Entity) -> Bool { + switch (lhs, rhs) { + case let (.entityWithArray(l), .entityWithArray(r)): + return l == r + case let (.entityWithArrayOfNestedItems(l), .entityWithArrayOfNestedItems(r)): + return l == r + case let (.entityWithArrayWithTransform(l), .entityWithArrayWithTransform(r)): + return l == r + case let (.entityWithComplexProperty(l), .entityWithComplexProperty(r)): + return l == r + case let (.entityWithComplexPropertyWithDefaultValue(l), .entityWithComplexPropertyWithDefaultValue(r)): + return l == r + case let (.entityWithEntityProperty(l), .entityWithEntityProperty(r)): + return l == r + case let (.entityWithOptionalComplexProperty(l), .entityWithOptionalComplexProperty(r)): + return l == r + case let (.entityWithOptionalProperty(l), .entityWithOptionalProperty(r)): + return l == r + case let (.entityWithOptionalStringEnumProperty(l), .entityWithOptionalStringEnumProperty(r)): + return l == r + case let (.entityWithPropertyWithDefaultValue(l), .entityWithPropertyWithDefaultValue(r)): + return l == r + case let (.entityWithRequiredProperty(l), .entityWithRequiredProperty(r)): + return l == r + case let (.entityWithSimpleProperties(l), .entityWithSimpleProperties(r)): + return l == r + case let (.entityWithStrictArray(l), .entityWithStrictArray(r)): + return l == r + case let (.entityWithStringArrayProperty(l), .entityWithStringArrayProperty(r)): + return l == r + case let (.entityWithStringEnumProperty(l), .entityWithStringEnumProperty(r)): + return l == r + case let (.entityWithStringEnumPropertyWithDefaultValue(l), .entityWithStringEnumPropertyWithDefaultValue(r)): + return l == r + case let (.entityWithoutProperties(l), .entityWithoutProperties(r)): + return l == r + default: + return false + } + } +} +#endif + +extension Entity: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + return value.toDictionary() + } +} diff --git a/api_generator/tests/references/swift/EntityTemplate.swift b/api_generator/tests/references/swift/EntityTemplate.swift new file mode 100644 index 000000000..95d667e1d --- /dev/null +++ b/api_generator/tests/references/swift/EntityTemplate.swift @@ -0,0 +1,444 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public enum EntityTemplate: TemplateValue { + case entityWithArrayTemplate(EntityWithArrayTemplate) + case entityWithArrayOfNestedItemsTemplate(EntityWithArrayOfNestedItemsTemplate) + case entityWithArrayWithTransformTemplate(EntityWithArrayWithTransformTemplate) + case entityWithComplexPropertyTemplate(EntityWithComplexPropertyTemplate) + case entityWithComplexPropertyWithDefaultValueTemplate(EntityWithComplexPropertyWithDefaultValueTemplate) + case entityWithEntityPropertyTemplate(EntityWithEntityPropertyTemplate) + case entityWithOptionalComplexPropertyTemplate(EntityWithOptionalComplexPropertyTemplate) + case entityWithOptionalPropertyTemplate(EntityWithOptionalPropertyTemplate) + case entityWithOptionalStringEnumPropertyTemplate(EntityWithOptionalStringEnumPropertyTemplate) + case entityWithPropertyWithDefaultValueTemplate(EntityWithPropertyWithDefaultValueTemplate) + case entityWithRequiredPropertyTemplate(EntityWithRequiredPropertyTemplate) + case entityWithSimplePropertiesTemplate(EntityWithSimplePropertiesTemplate) + case entityWithStrictArrayTemplate(EntityWithStrictArrayTemplate) + case entityWithStringArrayPropertyTemplate(EntityWithStringArrayPropertyTemplate) + case entityWithStringEnumPropertyTemplate(EntityWithStringEnumPropertyTemplate) + case entityWithStringEnumPropertyWithDefaultValueTemplate(EntityWithStringEnumPropertyWithDefaultValueTemplate) + case entityWithoutPropertiesTemplate(EntityWithoutPropertiesTemplate) + + public var value: Any { + switch self { + case let .entityWithArrayTemplate(value): + return value + case let .entityWithArrayOfNestedItemsTemplate(value): + return value + case let .entityWithArrayWithTransformTemplate(value): + return value + case let .entityWithComplexPropertyTemplate(value): + return value + case let .entityWithComplexPropertyWithDefaultValueTemplate(value): + return value + case let .entityWithEntityPropertyTemplate(value): + return value + case let .entityWithOptionalComplexPropertyTemplate(value): + return value + case let .entityWithOptionalPropertyTemplate(value): + return value + case let .entityWithOptionalStringEnumPropertyTemplate(value): + return value + case let .entityWithPropertyWithDefaultValueTemplate(value): + return value + case let .entityWithRequiredPropertyTemplate(value): + return value + case let .entityWithSimplePropertiesTemplate(value): + return value + case let .entityWithStrictArrayTemplate(value): + return value + case let .entityWithStringArrayPropertyTemplate(value): + return value + case let .entityWithStringEnumPropertyTemplate(value): + return value + case let .entityWithStringEnumPropertyWithDefaultValueTemplate(value): + return value + case let .entityWithoutPropertiesTemplate(value): + return value + } + } + + public func resolveParent(templates: Templates) throws -> EntityTemplate { + switch self { + case let .entityWithArrayTemplate(value): + return .entityWithArrayTemplate(try value.resolveParent(templates: templates)) + case let .entityWithArrayOfNestedItemsTemplate(value): + return .entityWithArrayOfNestedItemsTemplate(try value.resolveParent(templates: templates)) + case let .entityWithArrayWithTransformTemplate(value): + return .entityWithArrayWithTransformTemplate(try value.resolveParent(templates: templates)) + case let .entityWithComplexPropertyTemplate(value): + return .entityWithComplexPropertyTemplate(try value.resolveParent(templates: templates)) + case let .entityWithComplexPropertyWithDefaultValueTemplate(value): + return .entityWithComplexPropertyWithDefaultValueTemplate(try value.resolveParent(templates: templates)) + case let .entityWithEntityPropertyTemplate(value): + return .entityWithEntityPropertyTemplate(try value.resolveParent(templates: templates)) + case let .entityWithOptionalComplexPropertyTemplate(value): + return .entityWithOptionalComplexPropertyTemplate(try value.resolveParent(templates: templates)) + case let .entityWithOptionalPropertyTemplate(value): + return .entityWithOptionalPropertyTemplate(try value.resolveParent(templates: templates)) + case let .entityWithOptionalStringEnumPropertyTemplate(value): + return .entityWithOptionalStringEnumPropertyTemplate(try value.resolveParent(templates: templates)) + case let .entityWithPropertyWithDefaultValueTemplate(value): + return .entityWithPropertyWithDefaultValueTemplate(try value.resolveParent(templates: templates)) + case let .entityWithRequiredPropertyTemplate(value): + return .entityWithRequiredPropertyTemplate(try value.resolveParent(templates: templates)) + case let .entityWithSimplePropertiesTemplate(value): + return .entityWithSimplePropertiesTemplate(try value.resolveParent(templates: templates)) + case let .entityWithStrictArrayTemplate(value): + return .entityWithStrictArrayTemplate(try value.resolveParent(templates: templates)) + case let .entityWithStringArrayPropertyTemplate(value): + return .entityWithStringArrayPropertyTemplate(try value.resolveParent(templates: templates)) + case let .entityWithStringEnumPropertyTemplate(value): + return .entityWithStringEnumPropertyTemplate(try value.resolveParent(templates: templates)) + case let .entityWithStringEnumPropertyWithDefaultValueTemplate(value): + return .entityWithStringEnumPropertyWithDefaultValueTemplate(try value.resolveParent(templates: templates)) + case let .entityWithoutPropertiesTemplate(value): + return .entityWithoutPropertiesTemplate(try value.resolveParent(templates: templates)) + } + } + + public static func resolveValue(context: Context, parent: EntityTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + guard let parent = parent else { + if useOnlyLinks { + return .failure(NonEmptyArray(.missingType(representation: context.templateData))) + } else { + return resolveUnknownValue(context: context, useOnlyLinks: useOnlyLinks) + } + } + + switch parent { + case let .entityWithArrayTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithArray(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithArray(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithArrayOfNestedItemsTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithArrayOfNestedItems(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithArrayOfNestedItems(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithArrayWithTransformTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithArrayWithTransform(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithArrayWithTransform(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithComplexPropertyTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithComplexProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithComplexProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithComplexPropertyWithDefaultValueTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithComplexPropertyWithDefaultValue(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithComplexPropertyWithDefaultValue(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithEntityPropertyTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithEntityProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithEntityProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithOptionalComplexPropertyTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithOptionalComplexProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithOptionalComplexProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithOptionalPropertyTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithOptionalProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithOptionalProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithOptionalStringEnumPropertyTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithOptionalStringEnumProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithOptionalStringEnumProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithPropertyWithDefaultValueTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithPropertyWithDefaultValue(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithPropertyWithDefaultValue(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithRequiredPropertyTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithRequiredProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithRequiredProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithSimplePropertiesTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithSimpleProperties(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithSimpleProperties(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithStrictArrayTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithStrictArray(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithStrictArray(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithStringArrayPropertyTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithStringArrayProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithStringArrayProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithStringEnumPropertyTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithStringEnumProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithStringEnumProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithStringEnumPropertyWithDefaultValueTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithStringEnumPropertyWithDefaultValue(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithStringEnumPropertyWithDefaultValue(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case let .entityWithoutPropertiesTemplate(value): + let result = value.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithoutProperties(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithoutProperties(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + } + } + + private static func resolveUnknownValue(context: Context, useOnlyLinks: Bool) -> DeserializationResult { + guard let type = (context.templateData["type"] as? String).flatMap({ context.templateToType[$0] ?? $0 }) else { + return .failure(NonEmptyArray(FieldError(fieldName: "type", level: .error, error: .requiredFieldIsMissing))) + } + + switch type { + case EntityWithArray.type: + let result = EntityWithArrayTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithArray(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithArray(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithArrayOfNestedItems.type: + let result = EntityWithArrayOfNestedItemsTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithArrayOfNestedItems(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithArrayOfNestedItems(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithArrayWithTransform.type: + let result = EntityWithArrayWithTransformTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithArrayWithTransform(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithArrayWithTransform(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithComplexProperty.type: + let result = EntityWithComplexPropertyTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithComplexProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithComplexProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithComplexPropertyWithDefaultValue.type: + let result = EntityWithComplexPropertyWithDefaultValueTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithComplexPropertyWithDefaultValue(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithComplexPropertyWithDefaultValue(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithEntityProperty.type: + let result = EntityWithEntityPropertyTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithEntityProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithEntityProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithOptionalComplexProperty.type: + let result = EntityWithOptionalComplexPropertyTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithOptionalComplexProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithOptionalComplexProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithOptionalProperty.type: + let result = EntityWithOptionalPropertyTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithOptionalProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithOptionalProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithOptionalStringEnumProperty.type: + let result = EntityWithOptionalStringEnumPropertyTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithOptionalStringEnumProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithOptionalStringEnumProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithPropertyWithDefaultValue.type: + let result = EntityWithPropertyWithDefaultValueTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithPropertyWithDefaultValue(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithPropertyWithDefaultValue(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithRequiredProperty.type: + let result = EntityWithRequiredPropertyTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithRequiredProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithRequiredProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithSimpleProperties.type: + let result = EntityWithSimplePropertiesTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithSimpleProperties(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithSimpleProperties(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithStrictArray.type: + let result = EntityWithStrictArrayTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithStrictArray(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithStrictArray(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithStringArrayProperty.type: + let result = EntityWithStringArrayPropertyTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithStringArrayProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithStringArrayProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithStringEnumProperty.type: + let result = EntityWithStringEnumPropertyTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithStringEnumProperty(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithStringEnumProperty(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithStringEnumPropertyWithDefaultValue.type: + let result = EntityWithStringEnumPropertyWithDefaultValueTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithStringEnumPropertyWithDefaultValue(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithStringEnumPropertyWithDefaultValue(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + case EntityWithoutProperties.type: + let result = EntityWithoutPropertiesTemplate.resolveValue(context: context, useOnlyLinks: useOnlyLinks) + switch result { + case let .success(value): return .success(.entityWithoutProperties(value)) + case let .partialSuccess(value, warnings): return .partialSuccess(.entityWithoutProperties(value), warnings: warnings) + case let .failure(errors): return .failure(errors) + case .noValue: return .noValue + } + default: + return .failure(NonEmptyArray(FieldError(fieldName: "type", level: .error, error: .requiredFieldIsMissing))) + } + } +} + +extension EntityTemplate: TemplateDeserializable { + public init(dictionary: [String: Any], templateToType: TemplateToType) throws { + let receivedType = try dictionary.getField("type") as String + let blockType = templateToType[receivedType] ?? receivedType + switch blockType { + case EntityWithArrayTemplate.type: + self = .entityWithArrayTemplate(try EntityWithArrayTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithArrayOfNestedItemsTemplate.type: + self = .entityWithArrayOfNestedItemsTemplate(try EntityWithArrayOfNestedItemsTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithArrayWithTransformTemplate.type: + self = .entityWithArrayWithTransformTemplate(try EntityWithArrayWithTransformTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithComplexPropertyTemplate.type: + self = .entityWithComplexPropertyTemplate(try EntityWithComplexPropertyTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithComplexPropertyWithDefaultValueTemplate.type: + self = .entityWithComplexPropertyWithDefaultValueTemplate(try EntityWithComplexPropertyWithDefaultValueTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithEntityPropertyTemplate.type: + self = .entityWithEntityPropertyTemplate(try EntityWithEntityPropertyTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithOptionalComplexPropertyTemplate.type: + self = .entityWithOptionalComplexPropertyTemplate(try EntityWithOptionalComplexPropertyTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithOptionalPropertyTemplate.type: + self = .entityWithOptionalPropertyTemplate(try EntityWithOptionalPropertyTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithOptionalStringEnumPropertyTemplate.type: + self = .entityWithOptionalStringEnumPropertyTemplate(try EntityWithOptionalStringEnumPropertyTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithPropertyWithDefaultValueTemplate.type: + self = .entityWithPropertyWithDefaultValueTemplate(try EntityWithPropertyWithDefaultValueTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithRequiredPropertyTemplate.type: + self = .entityWithRequiredPropertyTemplate(try EntityWithRequiredPropertyTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithSimplePropertiesTemplate.type: + self = .entityWithSimplePropertiesTemplate(try EntityWithSimplePropertiesTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithStrictArrayTemplate.type: + self = .entityWithStrictArrayTemplate(try EntityWithStrictArrayTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithStringArrayPropertyTemplate.type: + self = .entityWithStringArrayPropertyTemplate(try EntityWithStringArrayPropertyTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithStringEnumPropertyTemplate.type: + self = .entityWithStringEnumPropertyTemplate(try EntityWithStringEnumPropertyTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithStringEnumPropertyWithDefaultValueTemplate.type: + self = .entityWithStringEnumPropertyWithDefaultValueTemplate(try EntityWithStringEnumPropertyWithDefaultValueTemplate(dictionary: dictionary, templateToType: templateToType)) + case EntityWithoutPropertiesTemplate.type: + self = .entityWithoutPropertiesTemplate(try EntityWithoutPropertiesTemplate(dictionary: dictionary, templateToType: templateToType)) + default: + throw DeserializationError.invalidFieldRepresentation(field: "entity_template", representation: dictionary) + } + } +} diff --git a/api_generator/tests/references/swift/EntityWithArray.swift b/api_generator/tests/references/swift/EntityWithArray.swift new file mode 100644 index 000000000..6a67f2802 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithArray.swift @@ -0,0 +1,41 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithArray { + public static let type: String = "entity_with_array" + public let array: [Entity] // at least 1 elements + + static let arrayValidator: AnyArrayValueValidator = + makeArrayValidator(minItems: 1) + + init( + array: [Entity] + ) { + self.array = array + } +} + +#if DEBUG +extension EntityWithArray: Equatable { + public static func ==(lhs: EntityWithArray, rhs: EntityWithArray) -> Bool { + guard + lhs.array == rhs.array + else { + return false + } + return true + } +} +#endif + +extension EntityWithArray: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["array"] = array.map { $0.toDictionary() } + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithArrayOfNestedItems.swift b/api_generator/tests/references/swift/EntityWithArrayOfNestedItems.swift new file mode 100644 index 000000000..edcf6ac3c --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithArrayOfNestedItems.swift @@ -0,0 +1,84 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithArrayOfNestedItems { + public final class Item { + public let entity: Entity + public let property: Expression // at least 1 char + + public func resolveProperty(_ resolver: ExpressionResolver) -> String? { + resolver.resolveStringBasedValue(expression: property, initializer: { $0 }) + } + + static let propertyValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + init( + entity: Entity, + property: Expression + ) { + self.entity = entity + self.property = property + } + } + + public static let type: String = "entity_with_array_of_nested_items" + public let items: [Item] // at least 1 elements + + static let itemsValidator: AnyArrayValueValidator = + makeArrayValidator(minItems: 1) + + init( + items: [Item] + ) { + self.items = items + } +} + +#if DEBUG +extension EntityWithArrayOfNestedItems: Equatable { + public static func ==(lhs: EntityWithArrayOfNestedItems, rhs: EntityWithArrayOfNestedItems) -> Bool { + guard + lhs.items == rhs.items + else { + return false + } + return true + } +} +#endif + +extension EntityWithArrayOfNestedItems: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["items"] = items.map { $0.toDictionary() } + return result + } +} + +#if DEBUG +extension EntityWithArrayOfNestedItems.Item: Equatable { + public static func ==(lhs: EntityWithArrayOfNestedItems.Item, rhs: EntityWithArrayOfNestedItems.Item) -> Bool { + guard + lhs.entity == rhs.entity, + lhs.property == rhs.property + else { + return false + } + return true + } +} +#endif + +extension EntityWithArrayOfNestedItems.Item: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["entity"] = entity.toDictionary() + result["property"] = property.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithArrayOfNestedItemsTemplate.swift b/api_generator/tests/references/swift/EntityWithArrayOfNestedItemsTemplate.swift new file mode 100644 index 000000000..1d691ac8b --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithArrayOfNestedItemsTemplate.swift @@ -0,0 +1,216 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithArrayOfNestedItemsTemplate: TemplateValue, TemplateDeserializable { + public final class ItemTemplate: TemplateValue, TemplateDeserializable { + public let entity: Field? + public let property: Field>? // at least 1 char + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + entity: try dictionary.getOptionalField("entity", templateToType: templateToType), + property: try dictionary.getOptionalExpressionField("property") + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "item_template." + field, representation: representation) + } + } + + init( + entity: Field? = nil, + property: Field>? = nil + ) { + self.entity = entity + self.property = property + } + + private static func resolveOnlyLinks(context: Context, parent: ItemTemplate?) -> DeserializationResult { + let entityValue = parent?.entity?.resolveValue(context: context, useOnlyLinks: true) ?? .noValue + let propertyValue = parent?.property?.resolveValue(context: context, validator: ResolvedValue.propertyValidator) ?? .noValue + var errors = mergeErrors( + entityValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "entity", level: .error)) }, + propertyValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "property", level: .error)) } + ) + if case .noValue = entityValue { + errors.append(.right(FieldError(fieldName: "entity", level: .error, error: .requiredFieldIsMissing))) + } + if case .noValue = propertyValue { + errors.append(.right(FieldError(fieldName: "property", level: .error, error: .requiredFieldIsMissing))) + } + guard + let entityNonNil = entityValue.value, + let propertyNonNil = propertyValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithArrayOfNestedItems.Item( + entity: entityNonNil, + property: propertyNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: ItemTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var entityValue: DeserializationResult = .noValue + var propertyValue: DeserializationResult> = parent?.property?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "entity": + entityValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: EntityTemplate.self).merged(with: entityValue) + case "property": + propertyValue = deserialize(__dictValue, validator: ResolvedValue.propertyValidator).merged(with: propertyValue) + case parent?.entity?.link: + entityValue = entityValue.merged(with: deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: EntityTemplate.self)) + case parent?.property?.link: + propertyValue = propertyValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.propertyValidator)) + default: break + } + } + if let parent = parent { + entityValue = entityValue.merged(with: parent.entity?.resolveValue(context: context, useOnlyLinks: true)) + } + var errors = mergeErrors( + entityValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "entity", level: .error)) }, + propertyValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "property", level: .error)) } + ) + if case .noValue = entityValue { + errors.append(.right(FieldError(fieldName: "entity", level: .error, error: .requiredFieldIsMissing))) + } + if case .noValue = propertyValue { + errors.append(.right(FieldError(fieldName: "property", level: .error, error: .requiredFieldIsMissing))) + } + guard + let entityNonNil = entityValue.value, + let propertyNonNil = propertyValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithArrayOfNestedItems.Item( + entity: entityNonNil, + property: propertyNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> ItemTemplate { + return self + } + + public func resolveParent(templates: Templates) throws -> ItemTemplate { + let merged = try mergedWithParent(templates: templates) + + return ItemTemplate( + entity: try merged.entity?.resolveParent(templates: templates), + property: merged.property + ) + } + } + + public static let type: String = "entity_with_array_of_nested_items" + public let parent: String? // at least 1 char + public let items: Field<[ItemTemplate]>? // at least 1 elements + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + items: try dictionary.getOptionalArray("items", templateToType: templateToType) + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "entity_with_array_of_nested_items_template." + field, representation: representation) + } + } + + init( + parent: String?, + items: Field<[ItemTemplate]>? = nil + ) { + self.parent = parent + self.items = items + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithArrayOfNestedItemsTemplate?) -> DeserializationResult { + let itemsValue = parent?.items?.resolveValue(context: context, validator: ResolvedValue.itemsValidator, useOnlyLinks: true) ?? .noValue + var errors = mergeErrors( + itemsValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "items", level: .error)) } + ) + if case .noValue = itemsValue { + errors.append(.right(FieldError(fieldName: "items", level: .error, error: .requiredFieldIsMissing))) + } + guard + let itemsNonNil = itemsValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithArrayOfNestedItems( + items: itemsNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithArrayOfNestedItemsTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var itemsValue: DeserializationResult<[EntityWithArrayOfNestedItems.Item]> = .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "items": + itemsValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.itemsValidator, type: EntityWithArrayOfNestedItemsTemplate.ItemTemplate.self).merged(with: itemsValue) + case parent?.items?.link: + itemsValue = itemsValue.merged(with: deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.itemsValidator, type: EntityWithArrayOfNestedItemsTemplate.ItemTemplate.self)) + default: break + } + } + if let parent = parent { + itemsValue = itemsValue.merged(with: parent.items?.resolveValue(context: context, validator: ResolvedValue.itemsValidator, useOnlyLinks: true)) + } + var errors = mergeErrors( + itemsValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "items", level: .error)) } + ) + if case .noValue = itemsValue { + errors.append(.right(FieldError(fieldName: "items", level: .error, error: .requiredFieldIsMissing))) + } + guard + let itemsNonNil = itemsValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithArrayOfNestedItems( + items: itemsNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithArrayOfNestedItemsTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithArrayOfNestedItemsTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithArrayOfNestedItemsTemplate( + parent: nil, + items: items ?? mergedParent.items + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithArrayOfNestedItemsTemplate { + let merged = try mergedWithParent(templates: templates) + + return EntityWithArrayOfNestedItemsTemplate( + parent: nil, + items: try merged.items?.resolveParent(templates: templates) + ) + } +} diff --git a/api_generator/tests/references/swift/EntityWithArrayTemplate.swift b/api_generator/tests/references/swift/EntityWithArrayTemplate.swift new file mode 100644 index 000000000..acdc42bda --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithArrayTemplate.swift @@ -0,0 +1,108 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithArrayTemplate: TemplateValue, TemplateDeserializable { + public static let type: String = "entity_with_array" + public let parent: String? // at least 1 char + public let array: Field<[EntityTemplate]>? // at least 1 elements + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + array: try dictionary.getOptionalArray("array", templateToType: templateToType) + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "entity_with_array_template." + field, representation: representation) + } + } + + init( + parent: String?, + array: Field<[EntityTemplate]>? = nil + ) { + self.parent = parent + self.array = array + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithArrayTemplate?) -> DeserializationResult { + let arrayValue = parent?.array?.resolveValue(context: context, validator: ResolvedValue.arrayValidator, useOnlyLinks: true) ?? .noValue + var errors = mergeErrors( + arrayValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "array", level: .error)) } + ) + if case .noValue = arrayValue { + errors.append(.right(FieldError(fieldName: "array", level: .error, error: .requiredFieldIsMissing))) + } + guard + let arrayNonNil = arrayValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithArray( + array: arrayNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithArrayTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var arrayValue: DeserializationResult<[Entity]> = .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "array": + arrayValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.arrayValidator, type: EntityTemplate.self).merged(with: arrayValue) + case parent?.array?.link: + arrayValue = arrayValue.merged(with: deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.arrayValidator, type: EntityTemplate.self)) + default: break + } + } + if let parent = parent { + arrayValue = arrayValue.merged(with: parent.array?.resolveValue(context: context, validator: ResolvedValue.arrayValidator, useOnlyLinks: true)) + } + var errors = mergeErrors( + arrayValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "array", level: .error)) } + ) + if case .noValue = arrayValue { + errors.append(.right(FieldError(fieldName: "array", level: .error, error: .requiredFieldIsMissing))) + } + guard + let arrayNonNil = arrayValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithArray( + array: arrayNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithArrayTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithArrayTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithArrayTemplate( + parent: nil, + array: array ?? mergedParent.array + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithArrayTemplate { + let merged = try mergedWithParent(templates: templates) + + return EntityWithArrayTemplate( + parent: nil, + array: try merged.array?.resolveParent(templates: templates) + ) + } +} diff --git a/api_generator/tests/references/swift/EntityWithArrayWithTransform.swift b/api_generator/tests/references/swift/EntityWithArrayWithTransform.swift new file mode 100644 index 000000000..bd53c4228 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithArrayWithTransform.swift @@ -0,0 +1,45 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithArrayWithTransform { + public static let type: String = "entity_with_array_with_transform" + public let array: [Expression] // at least 1 elements + + public func resolveArray(_ resolver: ExpressionResolver) -> [Color]? { + array.map { resolver.resolveStringBasedValue(expression: $0, initializer: Color.color(withHexString:)) }.compactMap { $0 } + } + + static let arrayValidator: AnyArrayValueValidator> = + makeArrayValidator(minItems: 1) + + init( + array: [Expression] + ) { + self.array = array + } +} + +#if DEBUG +extension EntityWithArrayWithTransform: Equatable { + public static func ==(lhs: EntityWithArrayWithTransform, rhs: EntityWithArrayWithTransform) -> Bool { + guard + lhs.array == rhs.array + else { + return false + } + return true + } +} +#endif + +extension EntityWithArrayWithTransform: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["array"] = array.map { $0.toValidSerializationValue() } + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithArrayWithTransformTemplate.swift b/api_generator/tests/references/swift/EntityWithArrayWithTransformTemplate.swift new file mode 100644 index 000000000..dc2416c5d --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithArrayWithTransformTemplate.swift @@ -0,0 +1,100 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithArrayWithTransformTemplate: TemplateValue, TemplateDeserializable { + public static let type: String = "entity_with_array_with_transform" + public let parent: String? // at least 1 char + public let array: Field<[Expression]>? // at least 1 elements + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + array: try dictionary.getOptionalExpressionArray("array", transform: Color.color(withHexString:)) + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "entity_with_array_with_transform_template." + field, representation: representation) + } + } + + init( + parent: String?, + array: Field<[Expression]>? = nil + ) { + self.parent = parent + self.array = array + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithArrayWithTransformTemplate?) -> DeserializationResult { + let arrayValue = parent?.array?.resolveValue(context: context, transform: Color.color(withHexString:), validator: ResolvedValue.arrayValidator) ?? .noValue + var errors = mergeErrors( + arrayValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "array", level: .error)) } + ) + if case .noValue = arrayValue { + errors.append(.right(FieldError(fieldName: "array", level: .error, error: .requiredFieldIsMissing))) + } + guard + let arrayNonNil = arrayValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithArrayWithTransform( + array: arrayNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithArrayWithTransformTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var arrayValue: DeserializationResult<[Expression]> = parent?.array?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "array": + arrayValue = deserialize(__dictValue, transform: Color.color(withHexString:), validator: ResolvedValue.arrayValidator).merged(with: arrayValue) + case parent?.array?.link: + arrayValue = arrayValue.merged(with: deserialize(__dictValue, transform: Color.color(withHexString:), validator: ResolvedValue.arrayValidator)) + default: break + } + } + var errors = mergeErrors( + arrayValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "array", level: .error)) } + ) + if case .noValue = arrayValue { + errors.append(.right(FieldError(fieldName: "array", level: .error, error: .requiredFieldIsMissing))) + } + guard + let arrayNonNil = arrayValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithArrayWithTransform( + array: arrayNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithArrayWithTransformTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithArrayWithTransformTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithArrayWithTransformTemplate( + parent: nil, + array: array ?? mergedParent.array + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithArrayWithTransformTemplate { + return try mergedWithParent(templates: templates) + } +} diff --git a/api_generator/tests/references/swift/EntityWithComplexProperty.swift b/api_generator/tests/references/swift/EntityWithComplexProperty.swift new file mode 100644 index 000000000..5642cc57f --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithComplexProperty.swift @@ -0,0 +1,73 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithComplexProperty { + public final class Property { + public let value: Expression + + public func resolveValue(_ resolver: ExpressionResolver) -> URL? { + resolver.resolveStringBasedValue(expression: value, initializer: URL.init(string:)) + } + + init( + value: Expression + ) { + self.value = value + } + } + + public static let type: String = "entity_with_complex_property" + public let property: Property + + init( + property: Property + ) { + self.property = property + } +} + +#if DEBUG +extension EntityWithComplexProperty: Equatable { + public static func ==(lhs: EntityWithComplexProperty, rhs: EntityWithComplexProperty) -> Bool { + guard + lhs.property == rhs.property + else { + return false + } + return true + } +} +#endif + +extension EntityWithComplexProperty: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["property"] = property.toDictionary() + return result + } +} + +#if DEBUG +extension EntityWithComplexProperty.Property: Equatable { + public static func ==(lhs: EntityWithComplexProperty.Property, rhs: EntityWithComplexProperty.Property) -> Bool { + guard + lhs.value == rhs.value + else { + return false + } + return true + } +} +#endif + +extension EntityWithComplexProperty.Property: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["value"] = value.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithComplexPropertyTemplate.swift b/api_generator/tests/references/swift/EntityWithComplexPropertyTemplate.swift new file mode 100644 index 000000000..c185504bc --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithComplexPropertyTemplate.swift @@ -0,0 +1,186 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithComplexPropertyTemplate: TemplateValue, TemplateDeserializable { + public final class PropertyTemplate: TemplateValue, TemplateDeserializable { + public let value: Field>? + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + value: try dictionary.getOptionalExpressionField("value", transform: URL.init(string:)) + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "property_template." + field, representation: representation) + } + } + + init( + value: Field>? = nil + ) { + self.value = value + } + + private static func resolveOnlyLinks(context: Context, parent: PropertyTemplate?) -> DeserializationResult { + let valueValue = parent?.value?.resolveValue(context: context, transform: URL.init(string:)) ?? .noValue + var errors = mergeErrors( + valueValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "value", level: .error)) } + ) + if case .noValue = valueValue { + errors.append(.right(FieldError(fieldName: "value", level: .error, error: .requiredFieldIsMissing))) + } + guard + let valueNonNil = valueValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithComplexProperty.Property( + value: valueNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: PropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var valueValue: DeserializationResult> = parent?.value?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "value": + valueValue = deserialize(__dictValue, transform: URL.init(string:)).merged(with: valueValue) + case parent?.value?.link: + valueValue = valueValue.merged(with: deserialize(__dictValue, transform: URL.init(string:))) + default: break + } + } + var errors = mergeErrors( + valueValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "value", level: .error)) } + ) + if case .noValue = valueValue { + errors.append(.right(FieldError(fieldName: "value", level: .error, error: .requiredFieldIsMissing))) + } + guard + let valueNonNil = valueValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithComplexProperty.Property( + value: valueNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> PropertyTemplate { + return self + } + + public func resolveParent(templates: Templates) throws -> PropertyTemplate { + return try mergedWithParent(templates: templates) + } + } + + public static let type: String = "entity_with_complex_property" + public let parent: String? // at least 1 char + public let property: Field? + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + property: try dictionary.getOptionalField("property", templateToType: templateToType) + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "entity_with_complex_property_template." + field, representation: representation) + } + } + + init( + parent: String?, + property: Field? = nil + ) { + self.parent = parent + self.property = property + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithComplexPropertyTemplate?) -> DeserializationResult { + let propertyValue = parent?.property?.resolveValue(context: context, useOnlyLinks: true) ?? .noValue + var errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "property", level: .error)) } + ) + if case .noValue = propertyValue { + errors.append(.right(FieldError(fieldName: "property", level: .error, error: .requiredFieldIsMissing))) + } + guard + let propertyNonNil = propertyValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithComplexProperty( + property: propertyNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithComplexPropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var propertyValue: DeserializationResult = .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "property": + propertyValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: EntityWithComplexPropertyTemplate.PropertyTemplate.self).merged(with: propertyValue) + case parent?.property?.link: + propertyValue = propertyValue.merged(with: deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, type: EntityWithComplexPropertyTemplate.PropertyTemplate.self)) + default: break + } + } + if let parent = parent { + propertyValue = propertyValue.merged(with: parent.property?.resolveValue(context: context, useOnlyLinks: true)) + } + var errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "property", level: .error)) } + ) + if case .noValue = propertyValue { + errors.append(.right(FieldError(fieldName: "property", level: .error, error: .requiredFieldIsMissing))) + } + guard + let propertyNonNil = propertyValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithComplexProperty( + property: propertyNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithComplexPropertyTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithComplexPropertyTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithComplexPropertyTemplate( + parent: nil, + property: property ?? mergedParent.property + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithComplexPropertyTemplate { + let merged = try mergedWithParent(templates: templates) + + return EntityWithComplexPropertyTemplate( + parent: nil, + property: try merged.property?.resolveParent(templates: templates) + ) + } +} diff --git a/api_generator/tests/references/swift/EntityWithComplexPropertyWithDefaultValue.swift b/api_generator/tests/references/swift/EntityWithComplexPropertyWithDefaultValue.swift new file mode 100644 index 000000000..c6186b1b2 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithComplexPropertyWithDefaultValue.swift @@ -0,0 +1,76 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithComplexPropertyWithDefaultValue { + public final class Property { + public let value: Expression + + public func resolveValue(_ resolver: ExpressionResolver) -> String? { + resolver.resolveStringBasedValue(expression: value, initializer: { $0 }) + } + + init( + value: Expression + ) { + self.value = value + } + } + + public static let type: String = "entity_with_complex_property_with_default_value" + public let property: Property // default value: EntityWithComplexPropertyWithDefaultValue.Property(value: .value("Default text")) + + static let propertyValidator: AnyValueValidator = + makeNoOpValueValidator() + + init( + property: Property? = nil + ) { + self.property = property ?? EntityWithComplexPropertyWithDefaultValue.Property(value: .value("Default text")) + } +} + +#if DEBUG +extension EntityWithComplexPropertyWithDefaultValue: Equatable { + public static func ==(lhs: EntityWithComplexPropertyWithDefaultValue, rhs: EntityWithComplexPropertyWithDefaultValue) -> Bool { + guard + lhs.property == rhs.property + else { + return false + } + return true + } +} +#endif + +extension EntityWithComplexPropertyWithDefaultValue: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["property"] = property.toDictionary() + return result + } +} + +#if DEBUG +extension EntityWithComplexPropertyWithDefaultValue.Property: Equatable { + public static func ==(lhs: EntityWithComplexPropertyWithDefaultValue.Property, rhs: EntityWithComplexPropertyWithDefaultValue.Property) -> Bool { + guard + lhs.value == rhs.value + else { + return false + } + return true + } +} +#endif + +extension EntityWithComplexPropertyWithDefaultValue.Property: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["value"] = value.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithComplexPropertyWithDefaultValueTemplate.swift b/api_generator/tests/references/swift/EntityWithComplexPropertyWithDefaultValueTemplate.swift new file mode 100644 index 000000000..f78671a64 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithComplexPropertyWithDefaultValueTemplate.swift @@ -0,0 +1,166 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithComplexPropertyWithDefaultValueTemplate: TemplateValue, TemplateDeserializable { + public final class PropertyTemplate: TemplateValue, TemplateDeserializable { + public let value: Field>? + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + value: try dictionary.getOptionalExpressionField("value") + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "property_template." + field, representation: representation) + } + } + + init( + value: Field>? = nil + ) { + self.value = value + } + + private static func resolveOnlyLinks(context: Context, parent: PropertyTemplate?) -> DeserializationResult { + let valueValue = parent?.value?.resolveValue(context: context) ?? .noValue + var errors = mergeErrors( + valueValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "value", level: .error)) } + ) + if case .noValue = valueValue { + errors.append(.right(FieldError(fieldName: "value", level: .error, error: .requiredFieldIsMissing))) + } + guard + let valueNonNil = valueValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithComplexPropertyWithDefaultValue.Property( + value: valueNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: PropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var valueValue: DeserializationResult> = parent?.value?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "value": + valueValue = deserialize(__dictValue).merged(with: valueValue) + case parent?.value?.link: + valueValue = valueValue.merged(with: deserialize(__dictValue)) + default: break + } + } + var errors = mergeErrors( + valueValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "value", level: .error)) } + ) + if case .noValue = valueValue { + errors.append(.right(FieldError(fieldName: "value", level: .error, error: .requiredFieldIsMissing))) + } + guard + let valueNonNil = valueValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithComplexPropertyWithDefaultValue.Property( + value: valueNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> PropertyTemplate { + return self + } + + public func resolveParent(templates: Templates) throws -> PropertyTemplate { + return try mergedWithParent(templates: templates) + } + } + + public static let type: String = "entity_with_complex_property_with_default_value" + public let parent: String? // at least 1 char + public let property: Field? // default value: EntityWithComplexPropertyWithDefaultValueTemplate.Property(value: .value("Default text")) + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + property: try dictionary.getOptionalField("property", templateToType: templateToType) + ) + } + + init( + parent: String?, + property: Field? = nil + ) { + self.parent = parent + self.property = property + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithComplexPropertyWithDefaultValueTemplate?) -> DeserializationResult { + let propertyValue = parent?.property?.resolveOptionalValue(context: context, validator: ResolvedValue.propertyValidator, useOnlyLinks: true) ?? .noValue + let errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "property", level: .warning)) } + ) + let result = EntityWithComplexPropertyWithDefaultValue( + property: propertyValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithComplexPropertyWithDefaultValueTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var propertyValue: DeserializationResult = .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "property": + propertyValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.propertyValidator, type: EntityWithComplexPropertyWithDefaultValueTemplate.PropertyTemplate.self).merged(with: propertyValue) + case parent?.property?.link: + propertyValue = propertyValue.merged(with: deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.propertyValidator, type: EntityWithComplexPropertyWithDefaultValueTemplate.PropertyTemplate.self)) + default: break + } + } + if let parent = parent { + propertyValue = propertyValue.merged(with: parent.property?.resolveOptionalValue(context: context, validator: ResolvedValue.propertyValidator, useOnlyLinks: true)) + } + let errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "property", level: .warning)) } + ) + let result = EntityWithComplexPropertyWithDefaultValue( + property: propertyValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithComplexPropertyWithDefaultValueTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithComplexPropertyWithDefaultValueTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithComplexPropertyWithDefaultValueTemplate( + parent: nil, + property: property ?? mergedParent.property + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithComplexPropertyWithDefaultValueTemplate { + let merged = try mergedWithParent(templates: templates) + + return EntityWithComplexPropertyWithDefaultValueTemplate( + parent: nil, + property: merged.property?.tryResolveParent(templates: templates) + ) + } +} diff --git a/api_generator/tests/references/swift/EntityWithEntityProperty.swift b/api_generator/tests/references/swift/EntityWithEntityProperty.swift new file mode 100644 index 000000000..1a12ca854 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithEntityProperty.swift @@ -0,0 +1,41 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithEntityProperty { + public static let type: String = "entity_with_entity_property" + public let entity: Entity // default value: .entityWithStringEnumProperty(EntityWithStringEnumProperty(property: .value(.second))) + + static let entityValidator: AnyValueValidator = + makeNoOpValueValidator() + + init( + entity: Entity? = nil + ) { + self.entity = entity ?? .entityWithStringEnumProperty(EntityWithStringEnumProperty(property: .value(.second))) + } +} + +#if DEBUG +extension EntityWithEntityProperty: Equatable { + public static func ==(lhs: EntityWithEntityProperty, rhs: EntityWithEntityProperty) -> Bool { + guard + lhs.entity == rhs.entity + else { + return false + } + return true + } +} +#endif + +extension EntityWithEntityProperty: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["entity"] = entity.toDictionary() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithEntityPropertyTemplate.swift b/api_generator/tests/references/swift/EntityWithEntityPropertyTemplate.swift new file mode 100644 index 000000000..5e8a196c1 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithEntityPropertyTemplate.swift @@ -0,0 +1,88 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithEntityPropertyTemplate: TemplateValue, TemplateDeserializable { + public static let type: String = "entity_with_entity_property" + public let parent: String? // at least 1 char + public let entity: Field? // default value: .entityWithStringEnumProperty(EntityWithStringEnumProperty(property: .value(.second))) + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + entity: try dictionary.getOptionalField("entity", templateToType: templateToType) + ) + } + + init( + parent: String?, + entity: Field? = nil + ) { + self.parent = parent + self.entity = entity + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithEntityPropertyTemplate?) -> DeserializationResult { + let entityValue = parent?.entity?.resolveOptionalValue(context: context, validator: ResolvedValue.entityValidator, useOnlyLinks: true) ?? .noValue + let errors = mergeErrors( + entityValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "entity", level: .warning)) } + ) + let result = EntityWithEntityProperty( + entity: entityValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithEntityPropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var entityValue: DeserializationResult = .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "entity": + entityValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.entityValidator, type: EntityTemplate.self).merged(with: entityValue) + case parent?.entity?.link: + entityValue = entityValue.merged(with: deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.entityValidator, type: EntityTemplate.self)) + default: break + } + } + if let parent = parent { + entityValue = entityValue.merged(with: parent.entity?.resolveOptionalValue(context: context, validator: ResolvedValue.entityValidator, useOnlyLinks: true)) + } + let errors = mergeErrors( + entityValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "entity", level: .warning)) } + ) + let result = EntityWithEntityProperty( + entity: entityValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithEntityPropertyTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithEntityPropertyTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithEntityPropertyTemplate( + parent: nil, + entity: entity ?? mergedParent.entity + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithEntityPropertyTemplate { + let merged = try mergedWithParent(templates: templates) + + return EntityWithEntityPropertyTemplate( + parent: nil, + entity: merged.entity?.tryResolveParent(templates: templates) + ) + } +} diff --git a/api_generator/tests/references/swift/EntityWithOptionalComplexProperty.swift b/api_generator/tests/references/swift/EntityWithOptionalComplexProperty.swift new file mode 100644 index 000000000..c4be2fe9b --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithOptionalComplexProperty.swift @@ -0,0 +1,76 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithOptionalComplexProperty { + public final class Property { + public let value: Expression + + public func resolveValue(_ resolver: ExpressionResolver) -> URL? { + resolver.resolveStringBasedValue(expression: value, initializer: URL.init(string:)) + } + + init( + value: Expression + ) { + self.value = value + } + } + + public static let type: String = "entity_with_optional_complex_property" + public let property: Property? + + static let propertyValidator: AnyValueValidator = + makeNoOpValueValidator() + + init( + property: Property? = nil + ) { + self.property = property + } +} + +#if DEBUG +extension EntityWithOptionalComplexProperty: Equatable { + public static func ==(lhs: EntityWithOptionalComplexProperty, rhs: EntityWithOptionalComplexProperty) -> Bool { + guard + lhs.property == rhs.property + else { + return false + } + return true + } +} +#endif + +extension EntityWithOptionalComplexProperty: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["property"] = property?.toDictionary() + return result + } +} + +#if DEBUG +extension EntityWithOptionalComplexProperty.Property: Equatable { + public static func ==(lhs: EntityWithOptionalComplexProperty.Property, rhs: EntityWithOptionalComplexProperty.Property) -> Bool { + guard + lhs.value == rhs.value + else { + return false + } + return true + } +} +#endif + +extension EntityWithOptionalComplexProperty.Property: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["value"] = value.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithOptionalComplexPropertyTemplate.swift b/api_generator/tests/references/swift/EntityWithOptionalComplexPropertyTemplate.swift new file mode 100644 index 000000000..64dd1346f --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithOptionalComplexPropertyTemplate.swift @@ -0,0 +1,166 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithOptionalComplexPropertyTemplate: TemplateValue, TemplateDeserializable { + public final class PropertyTemplate: TemplateValue, TemplateDeserializable { + public let value: Field>? + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + value: try dictionary.getOptionalExpressionField("value", transform: URL.init(string:)) + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "property_template." + field, representation: representation) + } + } + + init( + value: Field>? = nil + ) { + self.value = value + } + + private static func resolveOnlyLinks(context: Context, parent: PropertyTemplate?) -> DeserializationResult { + let valueValue = parent?.value?.resolveValue(context: context, transform: URL.init(string:)) ?? .noValue + var errors = mergeErrors( + valueValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "value", level: .error)) } + ) + if case .noValue = valueValue { + errors.append(.right(FieldError(fieldName: "value", level: .error, error: .requiredFieldIsMissing))) + } + guard + let valueNonNil = valueValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithOptionalComplexProperty.Property( + value: valueNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: PropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var valueValue: DeserializationResult> = parent?.value?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "value": + valueValue = deserialize(__dictValue, transform: URL.init(string:)).merged(with: valueValue) + case parent?.value?.link: + valueValue = valueValue.merged(with: deserialize(__dictValue, transform: URL.init(string:))) + default: break + } + } + var errors = mergeErrors( + valueValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "value", level: .error)) } + ) + if case .noValue = valueValue { + errors.append(.right(FieldError(fieldName: "value", level: .error, error: .requiredFieldIsMissing))) + } + guard + let valueNonNil = valueValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithOptionalComplexProperty.Property( + value: valueNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> PropertyTemplate { + return self + } + + public func resolveParent(templates: Templates) throws -> PropertyTemplate { + return try mergedWithParent(templates: templates) + } + } + + public static let type: String = "entity_with_optional_complex_property" + public let parent: String? // at least 1 char + public let property: Field? + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + property: try dictionary.getOptionalField("property", templateToType: templateToType) + ) + } + + init( + parent: String?, + property: Field? = nil + ) { + self.parent = parent + self.property = property + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithOptionalComplexPropertyTemplate?) -> DeserializationResult { + let propertyValue = parent?.property?.resolveOptionalValue(context: context, validator: ResolvedValue.propertyValidator, useOnlyLinks: true) ?? .noValue + let errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "property", level: .warning)) } + ) + let result = EntityWithOptionalComplexProperty( + property: propertyValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithOptionalComplexPropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var propertyValue: DeserializationResult = .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "property": + propertyValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.propertyValidator, type: EntityWithOptionalComplexPropertyTemplate.PropertyTemplate.self).merged(with: propertyValue) + case parent?.property?.link: + propertyValue = propertyValue.merged(with: deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.propertyValidator, type: EntityWithOptionalComplexPropertyTemplate.PropertyTemplate.self)) + default: break + } + } + if let parent = parent { + propertyValue = propertyValue.merged(with: parent.property?.resolveOptionalValue(context: context, validator: ResolvedValue.propertyValidator, useOnlyLinks: true)) + } + let errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "property", level: .warning)) } + ) + let result = EntityWithOptionalComplexProperty( + property: propertyValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithOptionalComplexPropertyTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithOptionalComplexPropertyTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithOptionalComplexPropertyTemplate( + parent: nil, + property: property ?? mergedParent.property + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithOptionalComplexPropertyTemplate { + let merged = try mergedWithParent(templates: templates) + + return EntityWithOptionalComplexPropertyTemplate( + parent: nil, + property: merged.property?.tryResolveParent(templates: templates) + ) + } +} diff --git a/api_generator/tests/references/swift/EntityWithOptionalProperty.swift b/api_generator/tests/references/swift/EntityWithOptionalProperty.swift new file mode 100644 index 000000000..4d8f8e9c9 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithOptionalProperty.swift @@ -0,0 +1,45 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithOptionalProperty { + public static let type: String = "entity_with_optional_property" + public let property: Expression? // at least 1 char + + public func resolveProperty(_ resolver: ExpressionResolver) -> String? { + resolver.resolveStringBasedValue(expression: property, initializer: { $0 }) + } + + static let propertyValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + init( + property: Expression? = nil + ) { + self.property = property + } +} + +#if DEBUG +extension EntityWithOptionalProperty: Equatable { + public static func ==(lhs: EntityWithOptionalProperty, rhs: EntityWithOptionalProperty) -> Bool { + guard + lhs.property == rhs.property + else { + return false + } + return true + } +} +#endif + +extension EntityWithOptionalProperty: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["property"] = property?.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithOptionalPropertyTemplate.swift b/api_generator/tests/references/swift/EntityWithOptionalPropertyTemplate.swift new file mode 100644 index 000000000..1d5edf562 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithOptionalPropertyTemplate.swift @@ -0,0 +1,80 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithOptionalPropertyTemplate: TemplateValue, TemplateDeserializable { + public static let type: String = "entity_with_optional_property" + public let parent: String? // at least 1 char + public let property: Field>? // at least 1 char + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + property: try dictionary.getOptionalExpressionField("property") + ) + } + + init( + parent: String?, + property: Field>? = nil + ) { + self.parent = parent + self.property = property + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithOptionalPropertyTemplate?) -> DeserializationResult { + let propertyValue = parent?.property?.resolveOptionalValue(context: context, validator: ResolvedValue.propertyValidator) ?? .noValue + let errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "property", level: .warning)) } + ) + let result = EntityWithOptionalProperty( + property: propertyValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithOptionalPropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var propertyValue: DeserializationResult> = parent?.property?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "property": + propertyValue = deserialize(__dictValue, validator: ResolvedValue.propertyValidator).merged(with: propertyValue) + case parent?.property?.link: + propertyValue = propertyValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.propertyValidator)) + default: break + } + } + let errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "property", level: .warning)) } + ) + let result = EntityWithOptionalProperty( + property: propertyValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithOptionalPropertyTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithOptionalPropertyTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithOptionalPropertyTemplate( + parent: nil, + property: property ?? mergedParent.property + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithOptionalPropertyTemplate { + return try mergedWithParent(templates: templates) + } +} diff --git a/api_generator/tests/references/swift/EntityWithOptionalStringEnumProperty.swift b/api_generator/tests/references/swift/EntityWithOptionalStringEnumProperty.swift new file mode 100644 index 000000000..c6a8b5853 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithOptionalStringEnumProperty.swift @@ -0,0 +1,50 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithOptionalStringEnumProperty { + public enum Property: String, CaseIterable { + case first = "first" + case second = "second" + } + + public static let type: String = "entity_with_optional_string_enum_property" + public let property: Expression? + + public func resolveProperty(_ resolver: ExpressionResolver) -> Property? { + resolver.resolveStringBasedValue(expression: property, initializer: Property.init(rawValue:)) + } + + static let propertyValidator: AnyValueValidator = + makeNoOpValueValidator() + + init( + property: Expression? = nil + ) { + self.property = property + } +} + +#if DEBUG +extension EntityWithOptionalStringEnumProperty: Equatable { + public static func ==(lhs: EntityWithOptionalStringEnumProperty, rhs: EntityWithOptionalStringEnumProperty) -> Bool { + guard + lhs.property == rhs.property + else { + return false + } + return true + } +} +#endif + +extension EntityWithOptionalStringEnumProperty: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["property"] = property?.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithOptionalStringEnumPropertyTemplate.swift b/api_generator/tests/references/swift/EntityWithOptionalStringEnumPropertyTemplate.swift new file mode 100644 index 000000000..6916a7321 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithOptionalStringEnumPropertyTemplate.swift @@ -0,0 +1,82 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithOptionalStringEnumPropertyTemplate: TemplateValue, TemplateDeserializable { + public typealias Property = EntityWithOptionalStringEnumProperty.Property + + public static let type: String = "entity_with_optional_string_enum_property" + public let parent: String? // at least 1 char + public let property: Field>? + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + property: try dictionary.getOptionalExpressionField("property") + ) + } + + init( + parent: String?, + property: Field>? = nil + ) { + self.parent = parent + self.property = property + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithOptionalStringEnumPropertyTemplate?) -> DeserializationResult { + let propertyValue = parent?.property?.resolveOptionalValue(context: context, validator: ResolvedValue.propertyValidator) ?? .noValue + let errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "property", level: .warning)) } + ) + let result = EntityWithOptionalStringEnumProperty( + property: propertyValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithOptionalStringEnumPropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var propertyValue: DeserializationResult> = parent?.property?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "property": + propertyValue = deserialize(__dictValue, validator: ResolvedValue.propertyValidator).merged(with: propertyValue) + case parent?.property?.link: + propertyValue = propertyValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.propertyValidator)) + default: break + } + } + let errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "property", level: .warning)) } + ) + let result = EntityWithOptionalStringEnumProperty( + property: propertyValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithOptionalStringEnumPropertyTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithOptionalStringEnumPropertyTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithOptionalStringEnumPropertyTemplate( + parent: nil, + property: property ?? mergedParent.property + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithOptionalStringEnumPropertyTemplate { + return try mergedWithParent(templates: templates) + } +} diff --git a/api_generator/tests/references/swift/EntityWithPropertyWithDefaultValue.swift b/api_generator/tests/references/swift/EntityWithPropertyWithDefaultValue.swift new file mode 100644 index 000000000..7fbd1ae02 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithPropertyWithDefaultValue.swift @@ -0,0 +1,124 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithPropertyWithDefaultValue { + public final class Nested { + public let int: Expression // constraint: number >= 0; default value: 0 + public let nonOptional: Expression + public let url: Expression // valid schemes: [https]; default value: https://yandex.ru + + public func resolveInt(_ resolver: ExpressionResolver) -> Int { + resolver.resolveNumericValue(expression: int) ?? 0 + } + + public func resolveNonOptional(_ resolver: ExpressionResolver) -> String? { + resolver.resolveStringBasedValue(expression: nonOptional, initializer: { $0 }) + } + + public func resolveUrl(_ resolver: ExpressionResolver) -> URL { + resolver.resolveStringBasedValue(expression: url, initializer: URL.init(string:)) ?? URL(string: "https://yandex.ru")! + } + + static let intValidator: AnyValueValidator = + makeValueValidator(valueValidator: { $0 >= 0 }) + + static let urlValidator: AnyValueValidator = + makeURLValidator(schemes: ["https"]) + + init( + int: Expression? = nil, + nonOptional: Expression, + url: Expression? = nil + ) { + self.int = int ?? .value(0) + self.nonOptional = nonOptional + self.url = url ?? .value(URL(string: "https://yandex.ru")!) + } + } + + public static let type: String = "entity_with_property_with_default_value" + public let int: Expression // constraint: number >= 0; default value: 0 + public let nested: Nested? + public let url: Expression // valid schemes: [https]; default value: https://yandex.ru + + public func resolveInt(_ resolver: ExpressionResolver) -> Int { + resolver.resolveNumericValue(expression: int) ?? 0 + } + + public func resolveUrl(_ resolver: ExpressionResolver) -> URL { + resolver.resolveStringBasedValue(expression: url, initializer: URL.init(string:)) ?? URL(string: "https://yandex.ru")! + } + + static let intValidator: AnyValueValidator = + makeValueValidator(valueValidator: { $0 >= 0 }) + + static let nestedValidator: AnyValueValidator = + makeNoOpValueValidator() + + static let urlValidator: AnyValueValidator = + makeURLValidator(schemes: ["https"]) + + init( + int: Expression? = nil, + nested: Nested? = nil, + url: Expression? = nil + ) { + self.int = int ?? .value(0) + self.nested = nested + self.url = url ?? .value(URL(string: "https://yandex.ru")!) + } +} + +#if DEBUG +extension EntityWithPropertyWithDefaultValue: Equatable { + public static func ==(lhs: EntityWithPropertyWithDefaultValue, rhs: EntityWithPropertyWithDefaultValue) -> Bool { + guard + lhs.int == rhs.int, + lhs.nested == rhs.nested, + lhs.url == rhs.url + else { + return false + } + return true + } +} +#endif + +extension EntityWithPropertyWithDefaultValue: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["int"] = int.toValidSerializationValue() + result["nested"] = nested?.toDictionary() + result["url"] = url.toValidSerializationValue() + return result + } +} + +#if DEBUG +extension EntityWithPropertyWithDefaultValue.Nested: Equatable { + public static func ==(lhs: EntityWithPropertyWithDefaultValue.Nested, rhs: EntityWithPropertyWithDefaultValue.Nested) -> Bool { + guard + lhs.int == rhs.int, + lhs.nonOptional == rhs.nonOptional, + lhs.url == rhs.url + else { + return false + } + return true + } +} +#endif + +extension EntityWithPropertyWithDefaultValue.Nested: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["int"] = int.toValidSerializationValue() + result["non_optional"] = nonOptional.toValidSerializationValue() + result["url"] = url.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithPropertyWithDefaultValueTemplate.swift b/api_generator/tests/references/swift/EntityWithPropertyWithDefaultValueTemplate.swift new file mode 100644 index 000000000..0440eb50c --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithPropertyWithDefaultValueTemplate.swift @@ -0,0 +1,226 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithPropertyWithDefaultValueTemplate: TemplateValue, TemplateDeserializable { + public final class NestedTemplate: TemplateValue, TemplateDeserializable { + public let int: Field>? // constraint: number >= 0; default value: 0 + public let nonOptional: Field>? + public let url: Field>? // valid schemes: [https]; default value: https://yandex.ru + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + int: try dictionary.getOptionalExpressionField("int"), + nonOptional: try dictionary.getOptionalExpressionField("non_optional"), + url: try dictionary.getOptionalExpressionField("url", transform: URL.init(string:)) + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "nested_template." + field, representation: representation) + } + } + + init( + int: Field>? = nil, + nonOptional: Field>? = nil, + url: Field>? = nil + ) { + self.int = int + self.nonOptional = nonOptional + self.url = url + } + + private static func resolveOnlyLinks(context: Context, parent: NestedTemplate?) -> DeserializationResult { + let intValue = parent?.int?.resolveOptionalValue(context: context, validator: ResolvedValue.intValidator) ?? .noValue + let nonOptionalValue = parent?.nonOptional?.resolveValue(context: context) ?? .noValue + let urlValue = parent?.url?.resolveOptionalValue(context: context, transform: URL.init(string:), validator: ResolvedValue.urlValidator) ?? .noValue + var errors = mergeErrors( + intValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "int", level: .warning)) }, + nonOptionalValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "non_optional", level: .error)) }, + urlValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "url", level: .warning)) } + ) + if case .noValue = nonOptionalValue { + errors.append(.right(FieldError(fieldName: "non_optional", level: .error, error: .requiredFieldIsMissing))) + } + guard + let nonOptionalNonNil = nonOptionalValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithPropertyWithDefaultValue.Nested( + int: intValue.value, + nonOptional: nonOptionalNonNil, + url: urlValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: NestedTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var intValue: DeserializationResult> = parent?.int?.value() ?? .noValue + var nonOptionalValue: DeserializationResult> = parent?.nonOptional?.value() ?? .noValue + var urlValue: DeserializationResult> = parent?.url?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "int": + intValue = deserialize(__dictValue, validator: ResolvedValue.intValidator).merged(with: intValue) + case "non_optional": + nonOptionalValue = deserialize(__dictValue).merged(with: nonOptionalValue) + case "url": + urlValue = deserialize(__dictValue, transform: URL.init(string:), validator: ResolvedValue.urlValidator).merged(with: urlValue) + case parent?.int?.link: + intValue = intValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.intValidator)) + case parent?.nonOptional?.link: + nonOptionalValue = nonOptionalValue.merged(with: deserialize(__dictValue)) + case parent?.url?.link: + urlValue = urlValue.merged(with: deserialize(__dictValue, transform: URL.init(string:), validator: ResolvedValue.urlValidator)) + default: break + } + } + var errors = mergeErrors( + intValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "int", level: .warning)) }, + nonOptionalValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "non_optional", level: .error)) }, + urlValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "url", level: .warning)) } + ) + if case .noValue = nonOptionalValue { + errors.append(.right(FieldError(fieldName: "non_optional", level: .error, error: .requiredFieldIsMissing))) + } + guard + let nonOptionalNonNil = nonOptionalValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithPropertyWithDefaultValue.Nested( + int: intValue.value, + nonOptional: nonOptionalNonNil, + url: urlValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> NestedTemplate { + return self + } + + public func resolveParent(templates: Templates) throws -> NestedTemplate { + return try mergedWithParent(templates: templates) + } + } + + public static let type: String = "entity_with_property_with_default_value" + public let parent: String? // at least 1 char + public let int: Field>? // constraint: number >= 0; default value: 0 + public let nested: Field? + public let url: Field>? // valid schemes: [https]; default value: https://yandex.ru + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + int: try dictionary.getOptionalExpressionField("int"), + nested: try dictionary.getOptionalField("nested", templateToType: templateToType), + url: try dictionary.getOptionalExpressionField("url", transform: URL.init(string:)) + ) + } + + init( + parent: String?, + int: Field>? = nil, + nested: Field? = nil, + url: Field>? = nil + ) { + self.parent = parent + self.int = int + self.nested = nested + self.url = url + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithPropertyWithDefaultValueTemplate?) -> DeserializationResult { + let intValue = parent?.int?.resolveOptionalValue(context: context, validator: ResolvedValue.intValidator) ?? .noValue + let nestedValue = parent?.nested?.resolveOptionalValue(context: context, validator: ResolvedValue.nestedValidator, useOnlyLinks: true) ?? .noValue + let urlValue = parent?.url?.resolveOptionalValue(context: context, transform: URL.init(string:), validator: ResolvedValue.urlValidator) ?? .noValue + let errors = mergeErrors( + intValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "int", level: .warning)) }, + nestedValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "nested", level: .warning)) }, + urlValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "url", level: .warning)) } + ) + let result = EntityWithPropertyWithDefaultValue( + int: intValue.value, + nested: nestedValue.value, + url: urlValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithPropertyWithDefaultValueTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var intValue: DeserializationResult> = parent?.int?.value() ?? .noValue + var nestedValue: DeserializationResult = .noValue + var urlValue: DeserializationResult> = parent?.url?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "int": + intValue = deserialize(__dictValue, validator: ResolvedValue.intValidator).merged(with: intValue) + case "nested": + nestedValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.nestedValidator, type: EntityWithPropertyWithDefaultValueTemplate.NestedTemplate.self).merged(with: nestedValue) + case "url": + urlValue = deserialize(__dictValue, transform: URL.init(string:), validator: ResolvedValue.urlValidator).merged(with: urlValue) + case parent?.int?.link: + intValue = intValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.intValidator)) + case parent?.nested?.link: + nestedValue = nestedValue.merged(with: deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.nestedValidator, type: EntityWithPropertyWithDefaultValueTemplate.NestedTemplate.self)) + case parent?.url?.link: + urlValue = urlValue.merged(with: deserialize(__dictValue, transform: URL.init(string:), validator: ResolvedValue.urlValidator)) + default: break + } + } + if let parent = parent { + nestedValue = nestedValue.merged(with: parent.nested?.resolveOptionalValue(context: context, validator: ResolvedValue.nestedValidator, useOnlyLinks: true)) + } + let errors = mergeErrors( + intValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "int", level: .warning)) }, + nestedValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "nested", level: .warning)) }, + urlValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "url", level: .warning)) } + ) + let result = EntityWithPropertyWithDefaultValue( + int: intValue.value, + nested: nestedValue.value, + url: urlValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithPropertyWithDefaultValueTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithPropertyWithDefaultValueTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithPropertyWithDefaultValueTemplate( + parent: nil, + int: int ?? mergedParent.int, + nested: nested ?? mergedParent.nested, + url: url ?? mergedParent.url + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithPropertyWithDefaultValueTemplate { + let merged = try mergedWithParent(templates: templates) + + return EntityWithPropertyWithDefaultValueTemplate( + parent: nil, + int: merged.int, + nested: merged.nested?.tryResolveParent(templates: templates), + url: merged.url + ) + } +} diff --git a/api_generator/tests/references/swift/EntityWithRequiredProperty.swift b/api_generator/tests/references/swift/EntityWithRequiredProperty.swift new file mode 100644 index 000000000..387b6237f --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithRequiredProperty.swift @@ -0,0 +1,45 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithRequiredProperty { + public static let type: String = "entity_with_required_property" + public let property: Expression // at least 1 char + + public func resolveProperty(_ resolver: ExpressionResolver) -> String? { + resolver.resolveStringBasedValue(expression: property, initializer: { $0 }) + } + + static let propertyValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + init( + property: Expression + ) { + self.property = property + } +} + +#if DEBUG +extension EntityWithRequiredProperty: Equatable { + public static func ==(lhs: EntityWithRequiredProperty, rhs: EntityWithRequiredProperty) -> Bool { + guard + lhs.property == rhs.property + else { + return false + } + return true + } +} +#endif + +extension EntityWithRequiredProperty: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["property"] = property.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithRequiredPropertyTemplate.swift b/api_generator/tests/references/swift/EntityWithRequiredPropertyTemplate.swift new file mode 100644 index 000000000..c5e81ef53 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithRequiredPropertyTemplate.swift @@ -0,0 +1,100 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithRequiredPropertyTemplate: TemplateValue, TemplateDeserializable { + public static let type: String = "entity_with_required_property" + public let parent: String? // at least 1 char + public let property: Field>? // at least 1 char + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + property: try dictionary.getOptionalExpressionField("property") + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "entity_with_required_property_template." + field, representation: representation) + } + } + + init( + parent: String?, + property: Field>? = nil + ) { + self.parent = parent + self.property = property + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithRequiredPropertyTemplate?) -> DeserializationResult { + let propertyValue = parent?.property?.resolveValue(context: context, validator: ResolvedValue.propertyValidator) ?? .noValue + var errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "property", level: .error)) } + ) + if case .noValue = propertyValue { + errors.append(.right(FieldError(fieldName: "property", level: .error, error: .requiredFieldIsMissing))) + } + guard + let propertyNonNil = propertyValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithRequiredProperty( + property: propertyNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithRequiredPropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var propertyValue: DeserializationResult> = parent?.property?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "property": + propertyValue = deserialize(__dictValue, validator: ResolvedValue.propertyValidator).merged(with: propertyValue) + case parent?.property?.link: + propertyValue = propertyValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.propertyValidator)) + default: break + } + } + var errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "property", level: .error)) } + ) + if case .noValue = propertyValue { + errors.append(.right(FieldError(fieldName: "property", level: .error, error: .requiredFieldIsMissing))) + } + guard + let propertyNonNil = propertyValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithRequiredProperty( + property: propertyNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithRequiredPropertyTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithRequiredPropertyTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithRequiredPropertyTemplate( + parent: nil, + property: property ?? mergedParent.property + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithRequiredPropertyTemplate { + return try mergedWithParent(templates: templates) + } +} diff --git a/api_generator/tests/references/swift/EntityWithSimpleProperties.swift b/api_generator/tests/references/swift/EntityWithSimpleProperties.swift new file mode 100644 index 000000000..1bc8845f3 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithSimpleProperties.swift @@ -0,0 +1,136 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithSimpleProperties: EntityProtocol { + public static let type: String = "entity_with_simple_properties" + public let boolean: Expression? + public let booleanInt: Expression? + public let color: Expression? + public let double: Expression? + public let id: Int? + public let integer: Expression? + public let positiveInteger: Expression? // constraint: number > 0 + public let string: Expression? // at least 1 char + public let url: Expression? + + public func resolveBoolean(_ resolver: ExpressionResolver) -> Bool? { + resolver.resolveNumericValue(expression: boolean) + } + + public func resolveBooleanInt(_ resolver: ExpressionResolver) -> Bool? { + resolver.resolveNumericValue(expression: booleanInt) + } + + public func resolveColor(_ resolver: ExpressionResolver) -> Color? { + resolver.resolveStringBasedValue(expression: color, initializer: Color.color(withHexString:)) + } + + public func resolveDouble(_ resolver: ExpressionResolver) -> Double? { + resolver.resolveNumericValue(expression: double) + } + + public func resolveInteger(_ resolver: ExpressionResolver) -> Int? { + resolver.resolveNumericValue(expression: integer) + } + + public func resolvePositiveInteger(_ resolver: ExpressionResolver) -> Int? { + resolver.resolveNumericValue(expression: positiveInteger) + } + + public func resolveString(_ resolver: ExpressionResolver) -> String? { + resolver.resolveStringBasedValue(expression: string, initializer: { $0 }) + } + + public func resolveUrl(_ resolver: ExpressionResolver) -> URL? { + resolver.resolveStringBasedValue(expression: url, initializer: URL.init(string:)) + } + + static let booleanValidator: AnyValueValidator = + makeNoOpValueValidator() + + static let booleanIntValidator: AnyValueValidator = + makeNoOpValueValidator() + + static let colorValidator: AnyValueValidator = + makeNoOpValueValidator() + + static let positiveIntegerValidator: AnyValueValidator = + makeValueValidator(valueValidator: { $0 > 0 }) + + static let stringValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + static let urlValidator: AnyValueValidator = + makeNoOpValueValidator() + + init( + boolean: Expression? = nil, + booleanInt: Expression? = nil, + color: Expression? = nil, + double: Expression? = nil, + id: Int? = nil, + integer: Expression? = nil, + positiveInteger: Expression? = nil, + string: Expression? = nil, + url: Expression? = nil + ) { + self.boolean = boolean + self.booleanInt = booleanInt + self.color = color + self.double = double + self.id = id + self.integer = integer + self.positiveInteger = positiveInteger + self.string = string + self.url = url + } +} + +#if DEBUG +extension EntityWithSimpleProperties: Equatable { + public static func ==(lhs: EntityWithSimpleProperties, rhs: EntityWithSimpleProperties) -> Bool { + guard + lhs.boolean == rhs.boolean, + lhs.booleanInt == rhs.booleanInt, + lhs.color == rhs.color + else { + return false + } + guard + lhs.double == rhs.double, + lhs.id == rhs.id, + lhs.integer == rhs.integer + else { + return false + } + guard + lhs.positiveInteger == rhs.positiveInteger, + lhs.string == rhs.string, + lhs.url == rhs.url + else { + return false + } + return true + } +} +#endif + +extension EntityWithSimpleProperties: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["boolean"] = boolean?.toValidSerializationValue() + result["boolean_int"] = booleanInt?.toValidSerializationValue() + result["color"] = color?.toValidSerializationValue() + result["double"] = double?.toValidSerializationValue() + result["id"] = id + result["integer"] = integer?.toValidSerializationValue() + result["positive_integer"] = positiveInteger?.toValidSerializationValue() + result["string"] = string?.toValidSerializationValue() + result["url"] = url?.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithSimplePropertiesTemplate.swift b/api_generator/tests/references/swift/EntityWithSimplePropertiesTemplate.swift new file mode 100644 index 000000000..17de9e332 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithSimplePropertiesTemplate.swift @@ -0,0 +1,200 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithSimplePropertiesTemplate: TemplateValue, EntityProtocol, TemplateDeserializable { + public static let type: String = "entity_with_simple_properties" + public let parent: String? // at least 1 char + public let boolean: Field>? + public let booleanInt: Field>? + public let color: Field>? + public let double: Field>? + public let id: Field? + public let integer: Field>? + public let positiveInteger: Field>? // constraint: number > 0 + public let string: Field>? // at least 1 char + public let url: Field>? + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + boolean: try dictionary.getOptionalExpressionField("boolean"), + booleanInt: try dictionary.getOptionalExpressionField("boolean_int"), + color: try dictionary.getOptionalExpressionField("color", transform: Color.color(withHexString:)), + double: try dictionary.getOptionalExpressionField("double"), + id: try dictionary.getOptionalField("id"), + integer: try dictionary.getOptionalExpressionField("integer"), + positiveInteger: try dictionary.getOptionalExpressionField("positive_integer"), + string: try dictionary.getOptionalExpressionField("string"), + url: try dictionary.getOptionalExpressionField("url", transform: URL.init(string:)) + ) + } + + init( + parent: String?, + boolean: Field>? = nil, + booleanInt: Field>? = nil, + color: Field>? = nil, + double: Field>? = nil, + id: Field? = nil, + integer: Field>? = nil, + positiveInteger: Field>? = nil, + string: Field>? = nil, + url: Field>? = nil + ) { + self.parent = parent + self.boolean = boolean + self.booleanInt = booleanInt + self.color = color + self.double = double + self.id = id + self.integer = integer + self.positiveInteger = positiveInteger + self.string = string + self.url = url + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithSimplePropertiesTemplate?) -> DeserializationResult { + let booleanValue = parent?.boolean?.resolveOptionalValue(context: context, validator: ResolvedValue.booleanValidator) ?? .noValue + let booleanIntValue = parent?.booleanInt?.resolveOptionalValue(context: context, validator: ResolvedValue.booleanIntValidator) ?? .noValue + let colorValue = parent?.color?.resolveOptionalValue(context: context, transform: Color.color(withHexString:), validator: ResolvedValue.colorValidator) ?? .noValue + let doubleValue = parent?.double?.resolveOptionalValue(context: context) ?? .noValue + let idValue = parent?.id?.resolveOptionalValue(context: context) ?? .noValue + let integerValue = parent?.integer?.resolveOptionalValue(context: context) ?? .noValue + let positiveIntegerValue = parent?.positiveInteger?.resolveOptionalValue(context: context, validator: ResolvedValue.positiveIntegerValidator) ?? .noValue + let stringValue = parent?.string?.resolveOptionalValue(context: context, validator: ResolvedValue.stringValidator) ?? .noValue + let urlValue = parent?.url?.resolveOptionalValue(context: context, transform: URL.init(string:), validator: ResolvedValue.urlValidator) ?? .noValue + let errors = mergeErrors( + booleanValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "boolean", level: .warning)) }, + booleanIntValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "boolean_int", level: .warning)) }, + colorValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "color", level: .warning)) }, + doubleValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "double", level: .warning)) }, + idValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "id", level: .warning)) }, + integerValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "integer", level: .warning)) }, + positiveIntegerValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "positive_integer", level: .warning)) }, + stringValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "string", level: .warning)) }, + urlValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "url", level: .warning)) } + ) + let result = EntityWithSimpleProperties( + boolean: booleanValue.value, + booleanInt: booleanIntValue.value, + color: colorValue.value, + double: doubleValue.value, + id: idValue.value, + integer: integerValue.value, + positiveInteger: positiveIntegerValue.value, + string: stringValue.value, + url: urlValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithSimplePropertiesTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var booleanValue: DeserializationResult> = parent?.boolean?.value() ?? .noValue + var booleanIntValue: DeserializationResult> = parent?.booleanInt?.value() ?? .noValue + var colorValue: DeserializationResult> = parent?.color?.value() ?? .noValue + var doubleValue: DeserializationResult> = parent?.double?.value() ?? .noValue + var idValue: DeserializationResult = parent?.id?.value() ?? .noValue + var integerValue: DeserializationResult> = parent?.integer?.value() ?? .noValue + var positiveIntegerValue: DeserializationResult> = parent?.positiveInteger?.value() ?? .noValue + var stringValue: DeserializationResult> = parent?.string?.value() ?? .noValue + var urlValue: DeserializationResult> = parent?.url?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "boolean": + booleanValue = deserialize(__dictValue, validator: ResolvedValue.booleanValidator).merged(with: booleanValue) + case "boolean_int": + booleanIntValue = deserialize(__dictValue, validator: ResolvedValue.booleanIntValidator).merged(with: booleanIntValue) + case "color": + colorValue = deserialize(__dictValue, transform: Color.color(withHexString:), validator: ResolvedValue.colorValidator).merged(with: colorValue) + case "double": + doubleValue = deserialize(__dictValue).merged(with: doubleValue) + case "id": + idValue = deserialize(__dictValue).merged(with: idValue) + case "integer": + integerValue = deserialize(__dictValue).merged(with: integerValue) + case "positive_integer": + positiveIntegerValue = deserialize(__dictValue, validator: ResolvedValue.positiveIntegerValidator).merged(with: positiveIntegerValue) + case "string": + stringValue = deserialize(__dictValue, validator: ResolvedValue.stringValidator).merged(with: stringValue) + case "url": + urlValue = deserialize(__dictValue, transform: URL.init(string:), validator: ResolvedValue.urlValidator).merged(with: urlValue) + case parent?.boolean?.link: + booleanValue = booleanValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.booleanValidator)) + case parent?.booleanInt?.link: + booleanIntValue = booleanIntValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.booleanIntValidator)) + case parent?.color?.link: + colorValue = colorValue.merged(with: deserialize(__dictValue, transform: Color.color(withHexString:), validator: ResolvedValue.colorValidator)) + case parent?.double?.link: + doubleValue = doubleValue.merged(with: deserialize(__dictValue)) + case parent?.id?.link: + idValue = idValue.merged(with: deserialize(__dictValue)) + case parent?.integer?.link: + integerValue = integerValue.merged(with: deserialize(__dictValue)) + case parent?.positiveInteger?.link: + positiveIntegerValue = positiveIntegerValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.positiveIntegerValidator)) + case parent?.string?.link: + stringValue = stringValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.stringValidator)) + case parent?.url?.link: + urlValue = urlValue.merged(with: deserialize(__dictValue, transform: URL.init(string:), validator: ResolvedValue.urlValidator)) + default: break + } + } + let errors = mergeErrors( + booleanValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "boolean", level: .warning)) }, + booleanIntValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "boolean_int", level: .warning)) }, + colorValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "color", level: .warning)) }, + doubleValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "double", level: .warning)) }, + idValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "id", level: .warning)) }, + integerValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "integer", level: .warning)) }, + positiveIntegerValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "positive_integer", level: .warning)) }, + stringValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "string", level: .warning)) }, + urlValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "url", level: .warning)) } + ) + let result = EntityWithSimpleProperties( + boolean: booleanValue.value, + booleanInt: booleanIntValue.value, + color: colorValue.value, + double: doubleValue.value, + id: idValue.value, + integer: integerValue.value, + positiveInteger: positiveIntegerValue.value, + string: stringValue.value, + url: urlValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithSimplePropertiesTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithSimplePropertiesTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithSimplePropertiesTemplate( + parent: nil, + boolean: boolean ?? mergedParent.boolean, + booleanInt: booleanInt ?? mergedParent.booleanInt, + color: color ?? mergedParent.color, + double: double ?? mergedParent.double, + id: id ?? mergedParent.id, + integer: integer ?? mergedParent.integer, + positiveInteger: positiveInteger ?? mergedParent.positiveInteger, + string: string ?? mergedParent.string, + url: url ?? mergedParent.url + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithSimplePropertiesTemplate { + return try mergedWithParent(templates: templates) + } +} diff --git a/api_generator/tests/references/swift/EntityWithStrictArray.swift b/api_generator/tests/references/swift/EntityWithStrictArray.swift new file mode 100644 index 000000000..6f052db7e --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithStrictArray.swift @@ -0,0 +1,41 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithStrictArray { + public static let type: String = "entity_with_strict_array" + public let array: [Entity] // at least 1 elements; all received elements must be valid + + static let arrayValidator: AnyArrayValueValidator = + makeStrictArrayValidator(minItems: 1) + + init( + array: [Entity] + ) { + self.array = array + } +} + +#if DEBUG +extension EntityWithStrictArray: Equatable { + public static func ==(lhs: EntityWithStrictArray, rhs: EntityWithStrictArray) -> Bool { + guard + lhs.array == rhs.array + else { + return false + } + return true + } +} +#endif + +extension EntityWithStrictArray: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["array"] = array.map { $0.toDictionary() } + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithStrictArrayTemplate.swift b/api_generator/tests/references/swift/EntityWithStrictArrayTemplate.swift new file mode 100644 index 000000000..06ff4827f --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithStrictArrayTemplate.swift @@ -0,0 +1,111 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithStrictArrayTemplate: TemplateValue, TemplateDeserializable { + public static let type: String = "entity_with_strict_array" + public let parent: String? // at least 1 char + public let array: Field<[EntityTemplate]>? // at least 1 elements; all received elements must be valid + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + static let arrayValidator: AnyArrayValueValidator = + makeStrictArrayValidator(minItems: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + array: try dictionary.getOptionalArray("array", templateToType: templateToType, validator: Self.arrayValidator) + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "entity_with_strict_array_template." + field, representation: representation) + } + } + + init( + parent: String?, + array: Field<[EntityTemplate]>? = nil + ) { + self.parent = parent + self.array = array + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithStrictArrayTemplate?) -> DeserializationResult { + let arrayValue = parent?.array?.resolveValue(context: context, validator: ResolvedValue.arrayValidator, useOnlyLinks: true) ?? .noValue + var errors = mergeErrors( + arrayValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "array", level: .error)) } + ) + if case .noValue = arrayValue { + errors.append(.right(FieldError(fieldName: "array", level: .error, error: .requiredFieldIsMissing))) + } + guard + let arrayNonNil = arrayValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithStrictArray( + array: arrayNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithStrictArrayTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var arrayValue: DeserializationResult<[Entity]> = .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "array": + arrayValue = deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.arrayValidator, type: EntityTemplate.self).merged(with: arrayValue) + case parent?.array?.link: + arrayValue = arrayValue.merged(with: deserialize(__dictValue, templates: context.templates, templateToType: context.templateToType, validator: ResolvedValue.arrayValidator, type: EntityTemplate.self)) + default: break + } + } + if let parent = parent { + arrayValue = arrayValue.merged(with: parent.array?.resolveValue(context: context, validator: ResolvedValue.arrayValidator, useOnlyLinks: true)) + } + var errors = mergeErrors( + arrayValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "array", level: .error)) } + ) + if case .noValue = arrayValue { + errors.append(.right(FieldError(fieldName: "array", level: .error, error: .requiredFieldIsMissing))) + } + guard + let arrayNonNil = arrayValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithStrictArray( + array: arrayNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithStrictArrayTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithStrictArrayTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithStrictArrayTemplate( + parent: nil, + array: array ?? mergedParent.array + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithStrictArrayTemplate { + let merged = try mergedWithParent(templates: templates) + + return EntityWithStrictArrayTemplate( + parent: nil, + array: try merged.array?.resolveParent(templates: templates, validator: Self.arrayValidator) + ) + } +} diff --git a/api_generator/tests/references/swift/EntityWithStringArrayProperty.swift b/api_generator/tests/references/swift/EntityWithStringArrayProperty.swift new file mode 100644 index 000000000..5e105fe1b --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithStringArrayProperty.swift @@ -0,0 +1,45 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithStringArrayProperty { + public static let type: String = "entity_with_string_array_property" + public let array: [Expression] // at least 1 elements + + public func resolveArray(_ resolver: ExpressionResolver) -> [String]? { + array.map { resolver.resolveStringBasedValue(expression: $0, initializer: { $0 }) }.compactMap { $0 } + } + + static let arrayValidator: AnyArrayValueValidator> = + makeArrayValidator(minItems: 1) + + init( + array: [Expression] + ) { + self.array = array + } +} + +#if DEBUG +extension EntityWithStringArrayProperty: Equatable { + public static func ==(lhs: EntityWithStringArrayProperty, rhs: EntityWithStringArrayProperty) -> Bool { + guard + lhs.array == rhs.array + else { + return false + } + return true + } +} +#endif + +extension EntityWithStringArrayProperty: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["array"] = array.map { $0.toValidSerializationValue() } + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithStringArrayPropertyTemplate.swift b/api_generator/tests/references/swift/EntityWithStringArrayPropertyTemplate.swift new file mode 100644 index 000000000..8cb6adb75 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithStringArrayPropertyTemplate.swift @@ -0,0 +1,100 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithStringArrayPropertyTemplate: TemplateValue, TemplateDeserializable { + public static let type: String = "entity_with_string_array_property" + public let parent: String? // at least 1 char + public let array: Field<[Expression]>? // at least 1 elements + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + array: try dictionary.getOptionalExpressionArray("array") + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "entity_with_string_array_property_template." + field, representation: representation) + } + } + + init( + parent: String?, + array: Field<[Expression]>? = nil + ) { + self.parent = parent + self.array = array + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithStringArrayPropertyTemplate?) -> DeserializationResult { + let arrayValue = parent?.array?.resolveValue(context: context, validator: ResolvedValue.arrayValidator) ?? .noValue + var errors = mergeErrors( + arrayValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "array", level: .error)) } + ) + if case .noValue = arrayValue { + errors.append(.right(FieldError(fieldName: "array", level: .error, error: .requiredFieldIsMissing))) + } + guard + let arrayNonNil = arrayValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithStringArrayProperty( + array: arrayNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithStringArrayPropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var arrayValue: DeserializationResult<[Expression]> = parent?.array?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "array": + arrayValue = deserialize(__dictValue, validator: ResolvedValue.arrayValidator).merged(with: arrayValue) + case parent?.array?.link: + arrayValue = arrayValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.arrayValidator)) + default: break + } + } + var errors = mergeErrors( + arrayValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "array", level: .error)) } + ) + if case .noValue = arrayValue { + errors.append(.right(FieldError(fieldName: "array", level: .error, error: .requiredFieldIsMissing))) + } + guard + let arrayNonNil = arrayValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithStringArrayProperty( + array: arrayNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithStringArrayPropertyTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithStringArrayPropertyTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithStringArrayPropertyTemplate( + parent: nil, + array: array ?? mergedParent.array + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithStringArrayPropertyTemplate { + return try mergedWithParent(templates: templates) + } +} diff --git a/api_generator/tests/references/swift/EntityWithStringEnumProperty.swift b/api_generator/tests/references/swift/EntityWithStringEnumProperty.swift new file mode 100644 index 000000000..ea0e6c88f --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithStringEnumProperty.swift @@ -0,0 +1,47 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithStringEnumProperty { + public enum Property: String, CaseIterable { + case first = "first" + case second = "second" + } + + public static let type: String = "entity_with_string_enum_property" + public let property: Expression + + public func resolveProperty(_ resolver: ExpressionResolver) -> Property? { + resolver.resolveStringBasedValue(expression: property, initializer: Property.init(rawValue:)) + } + + init( + property: Expression + ) { + self.property = property + } +} + +#if DEBUG +extension EntityWithStringEnumProperty: Equatable { + public static func ==(lhs: EntityWithStringEnumProperty, rhs: EntityWithStringEnumProperty) -> Bool { + guard + lhs.property == rhs.property + else { + return false + } + return true + } +} +#endif + +extension EntityWithStringEnumProperty: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["property"] = property.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithStringEnumPropertyTemplate.swift b/api_generator/tests/references/swift/EntityWithStringEnumPropertyTemplate.swift new file mode 100644 index 000000000..03e46937c --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithStringEnumPropertyTemplate.swift @@ -0,0 +1,102 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithStringEnumPropertyTemplate: TemplateValue, TemplateDeserializable { + public typealias Property = EntityWithStringEnumProperty.Property + + public static let type: String = "entity_with_string_enum_property" + public let parent: String? // at least 1 char + public let property: Field>? + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + do { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + property: try dictionary.getOptionalExpressionField("property") + ) + } catch let DeserializationError.invalidFieldRepresentation(field: field, representation: representation) { + throw DeserializationError.invalidFieldRepresentation(field: "entity_with_string_enum_property_template." + field, representation: representation) + } + } + + init( + parent: String?, + property: Field>? = nil + ) { + self.parent = parent + self.property = property + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithStringEnumPropertyTemplate?) -> DeserializationResult { + let propertyValue = parent?.property?.resolveValue(context: context) ?? .noValue + var errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "property", level: .error)) } + ) + if case .noValue = propertyValue { + errors.append(.right(FieldError(fieldName: "property", level: .error, error: .requiredFieldIsMissing))) + } + guard + let propertyNonNil = propertyValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithStringEnumProperty( + property: propertyNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithStringEnumPropertyTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var propertyValue: DeserializationResult> = parent?.property?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "property": + propertyValue = deserialize(__dictValue).merged(with: propertyValue) + case parent?.property?.link: + propertyValue = propertyValue.merged(with: deserialize(__dictValue)) + default: break + } + } + var errors = mergeErrors( + propertyValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "property", level: .error)) } + ) + if case .noValue = propertyValue { + errors.append(.right(FieldError(fieldName: "property", level: .error, error: .requiredFieldIsMissing))) + } + guard + let propertyNonNil = propertyValue.value + else { + return .failure(NonEmptyArray(errors)!) + } + let result = EntityWithStringEnumProperty( + property: propertyNonNil + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithStringEnumPropertyTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithStringEnumPropertyTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithStringEnumPropertyTemplate( + parent: nil, + property: property ?? mergedParent.property + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithStringEnumPropertyTemplate { + return try mergedWithParent(templates: templates) + } +} diff --git a/api_generator/tests/references/swift/EntityWithStringEnumPropertyWithDefaultValue.swift b/api_generator/tests/references/swift/EntityWithStringEnumPropertyWithDefaultValue.swift new file mode 100644 index 000000000..0ed563f26 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithStringEnumPropertyWithDefaultValue.swift @@ -0,0 +1,51 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithStringEnumPropertyWithDefaultValue { + public enum Value: String, CaseIterable { + case first = "first" + case second = "second" + case third = "third" + } + + public static let type: String = "entity_with_string_enum_property_with_default_value" + public let value: Expression // default value: second + + public func resolveValue(_ resolver: ExpressionResolver) -> Value { + resolver.resolveStringBasedValue(expression: value, initializer: Value.init(rawValue:)) ?? Value.second + } + + static let valueValidator: AnyValueValidator = + makeNoOpValueValidator() + + init( + value: Expression? = nil + ) { + self.value = value ?? .value(.second) + } +} + +#if DEBUG +extension EntityWithStringEnumPropertyWithDefaultValue: Equatable { + public static func ==(lhs: EntityWithStringEnumPropertyWithDefaultValue, rhs: EntityWithStringEnumPropertyWithDefaultValue) -> Bool { + guard + lhs.value == rhs.value + else { + return false + } + return true + } +} +#endif + +extension EntityWithStringEnumPropertyWithDefaultValue: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + result["value"] = value.toValidSerializationValue() + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithStringEnumPropertyWithDefaultValueTemplate.swift b/api_generator/tests/references/swift/EntityWithStringEnumPropertyWithDefaultValueTemplate.swift new file mode 100644 index 000000000..35359adfe --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithStringEnumPropertyWithDefaultValueTemplate.swift @@ -0,0 +1,82 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithStringEnumPropertyWithDefaultValueTemplate: TemplateValue, TemplateDeserializable { + public typealias Value = EntityWithStringEnumPropertyWithDefaultValue.Value + + public static let type: String = "entity_with_string_enum_property_with_default_value" + public let parent: String? // at least 1 char + public let value: Field>? // default value: second + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator), + value: try dictionary.getOptionalExpressionField("value") + ) + } + + init( + parent: String?, + value: Field>? = nil + ) { + self.parent = parent + self.value = value + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithStringEnumPropertyWithDefaultValueTemplate?) -> DeserializationResult { + let valueValue = parent?.value?.resolveOptionalValue(context: context, validator: ResolvedValue.valueValidator) ?? .noValue + let errors = mergeErrors( + valueValue.errorsOrWarnings?.map { .right($0.asError(deserializing: "value", level: .warning)) } + ) + let result = EntityWithStringEnumPropertyWithDefaultValue( + value: valueValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + public static func resolveValue(context: Context, parent: EntityWithStringEnumPropertyWithDefaultValueTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + if useOnlyLinks { + return resolveOnlyLinks(context: context, parent: parent) + } + var valueValue: DeserializationResult> = parent?.value?.value() ?? .noValue + context.templateData.forEach { key, __dictValue in + switch key { + case "value": + valueValue = deserialize(__dictValue, validator: ResolvedValue.valueValidator).merged(with: valueValue) + case parent?.value?.link: + valueValue = valueValue.merged(with: deserialize(__dictValue, validator: ResolvedValue.valueValidator)) + default: break + } + } + let errors = mergeErrors( + valueValue.errorsOrWarnings?.map { Either.right($0.asError(deserializing: "value", level: .warning)) } + ) + let result = EntityWithStringEnumPropertyWithDefaultValue( + value: valueValue.value + ) + return errors.isEmpty ? .success(result) : .partialSuccess(result, warnings: NonEmptyArray(errors)!) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithStringEnumPropertyWithDefaultValueTemplate { + guard let parent = parent, parent != Self.type else { return self } + guard let parentTemplate = templates[parent] as? EntityWithStringEnumPropertyWithDefaultValueTemplate else { + throw DeserializationError.unknownType(type: parent) + } + let mergedParent = try parentTemplate.mergedWithParent(templates: templates) + + return EntityWithStringEnumPropertyWithDefaultValueTemplate( + parent: nil, + value: value ?? mergedParent.value + ) + } + + public func resolveParent(templates: Templates) throws -> EntityWithStringEnumPropertyWithDefaultValueTemplate { + return try mergedWithParent(templates: templates) + } +} diff --git a/api_generator/tests/references/swift/EntityWithoutProperties.swift b/api_generator/tests/references/swift/EntityWithoutProperties.swift new file mode 100644 index 000000000..0dcbfd088 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithoutProperties.swift @@ -0,0 +1,27 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithoutProperties { + public static let type: String = "entity_without_properties" + + init() {} +} + +#if DEBUG +extension EntityWithoutProperties: Equatable { + public static func ==(lhs: EntityWithoutProperties, rhs: EntityWithoutProperties) -> Bool { + return true + } +} +#endif + +extension EntityWithoutProperties: Serializable { + public func toDictionary() -> [String: ValidSerializationValue] { + var result: [String: ValidSerializationValue] = [:] + result["type"] = Self.type + return result + } +} diff --git a/api_generator/tests/references/swift/EntityWithoutPropertiesTemplate.swift b/api_generator/tests/references/swift/EntityWithoutPropertiesTemplate.swift new file mode 100644 index 000000000..3587bac95 --- /dev/null +++ b/api_generator/tests/references/swift/EntityWithoutPropertiesTemplate.swift @@ -0,0 +1,41 @@ +// Generated code. Do not modify. + +import CommonCore +import Foundation +import Serialization + +public final class EntityWithoutPropertiesTemplate: TemplateValue, TemplateDeserializable { + public static let type: String = "entity_without_properties" + public let parent: String? // at least 1 char + + static let parentValidator: AnyValueValidator = + makeStringValidator(minLength: 1) + + public convenience init(dictionary: [String: Any], templateToType: TemplateToType) throws { + self.init( + parent: try dictionary.getOptionalField("type", validator: Self.parentValidator) + ) + } + + init( + parent: String? + ) { + self.parent = parent + } + + private static func resolveOnlyLinks(context: Context, parent: EntityWithoutPropertiesTemplate?) -> DeserializationResult { + return .success(EntityWithoutProperties()) + } + + public static func resolveValue(context: Context, parent: EntityWithoutPropertiesTemplate?, useOnlyLinks: Bool) -> DeserializationResult { + return .success(EntityWithoutProperties()) + } + + private func mergedWithParent(templates: Templates) throws -> EntityWithoutPropertiesTemplate { + return self + } + + public func resolveParent(templates: Templates) throws -> EntityWithoutPropertiesTemplate { + return self + } +} diff --git a/api_generator/tests/references/type_script/Entity.ts b/api_generator/tests/references/type_script/Entity.ts new file mode 100644 index 000000000..e0cf213d9 --- /dev/null +++ b/api_generator/tests/references/type_script/Entity.ts @@ -0,0 +1,44 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +import { + EntityWithArray, + EntityWithArrayOfNestedItems, + EntityWithArrayWithTransform, + EntityWithComplexProperty, + EntityWithComplexPropertyWithDefaultValue, + EntityWithEntityProperty, + EntityWithOptionalComplexProperty, + EntityWithOptionalProperty, + EntityWithOptionalStringEnumProperty, + EntityWithPropertyWithDefaultValue, + EntityWithRequiredProperty, + EntityWithSimpleProperties, + EntityWithStrictArray, + EntityWithStringArrayProperty, + EntityWithStringEnumProperty, + EntityWithStringEnumPropertyWithDefaultValue, + EntityWithoutProperties, +} from './'; + +export type Entity = + | EntityWithArray + | EntityWithArrayOfNestedItems + | EntityWithArrayWithTransform + | EntityWithComplexProperty + | EntityWithComplexPropertyWithDefaultValue + | EntityWithEntityProperty + | EntityWithOptionalComplexProperty + | EntityWithOptionalProperty + | EntityWithOptionalStringEnumProperty + | EntityWithPropertyWithDefaultValue + | EntityWithRequiredProperty + | EntityWithSimpleProperties + | EntityWithStrictArray + | EntityWithStringArrayProperty + | EntityWithStringEnumProperty + | EntityWithStringEnumPropertyWithDefaultValue + | EntityWithoutProperties; diff --git a/api_generator/tests/references/type_script/EntityWithArray.ts b/api_generator/tests/references/type_script/EntityWithArray.ts new file mode 100644 index 000000000..0fa0bb85a --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithArray.ts @@ -0,0 +1,24 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +import { + Entity, +} from './'; + +export class EntityWithArray { + readonly _props?: Exact; + + readonly type = 'entity_with_array'; + array: Type>; + + constructor(props: Exact) { + this.array = props.array; + } +} + +interface EntityWithArrayProps { + array: Type>; +} diff --git a/api_generator/tests/references/type_script/EntityWithArrayOfNestedItems.ts b/api_generator/tests/references/type_script/EntityWithArrayOfNestedItems.ts new file mode 100644 index 000000000..71c973704 --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithArrayOfNestedItems.ts @@ -0,0 +1,29 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +import { + Entity, +} from './'; + +export class EntityWithArrayOfNestedItems { + readonly _props?: Exact; + + readonly type = 'entity_with_array_of_nested_items'; + items: Type>; + + constructor(props: Exact) { + this.items = props.items; + } +} + +interface EntityWithArrayOfNestedItemsProps { + items: Type>; +} + +export interface IEntityWithArrayOfNestedItemsItem { + entity: Type; + property: Type | DivExpression; +} diff --git a/api_generator/tests/references/type_script/EntityWithArrayWithTransform.ts b/api_generator/tests/references/type_script/EntityWithArrayWithTransform.ts new file mode 100644 index 000000000..6c629e5f3 --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithArrayWithTransform.ts @@ -0,0 +1,20 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithArrayWithTransform { + readonly _props?: Exact; + + readonly type = 'entity_with_array_with_transform'; + array: Type>; + + constructor(props: Exact) { + this.array = props.array; + } +} + +interface EntityWithArrayWithTransformProps { + array: Type>; +} diff --git a/api_generator/tests/references/type_script/EntityWithComplexProperty.ts b/api_generator/tests/references/type_script/EntityWithComplexProperty.ts new file mode 100644 index 000000000..c651102aa --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithComplexProperty.ts @@ -0,0 +1,24 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithComplexProperty { + readonly _props?: Exact; + + readonly type = 'entity_with_complex_property'; + property: Type; + + constructor(props: Exact) { + this.property = props.property; + } +} + +interface EntityWithComplexPropertyProps { + property: Type; +} + +export interface IEntityWithComplexPropertyProperty { + value: Type | DivExpression; +} diff --git a/api_generator/tests/references/type_script/EntityWithComplexPropertyWithDefaultValue.ts b/api_generator/tests/references/type_script/EntityWithComplexPropertyWithDefaultValue.ts new file mode 100644 index 000000000..24f4c22ad --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithComplexPropertyWithDefaultValue.ts @@ -0,0 +1,24 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithComplexPropertyWithDefaultValue { + readonly _props?: Exact; + + readonly type = 'entity_with_complex_property_with_default_value'; + property?: Type; + + constructor(props?: Exact) { + this.property = props?.property; + } +} + +interface EntityWithComplexPropertyWithDefaultValueProps { + property?: Type; +} + +export interface IEntityWithComplexPropertyWithDefaultValueProperty { + value: Type | DivExpression; +} diff --git a/api_generator/tests/references/type_script/EntityWithEntityProperty.ts b/api_generator/tests/references/type_script/EntityWithEntityProperty.ts new file mode 100644 index 000000000..4c07235c7 --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithEntityProperty.ts @@ -0,0 +1,24 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +import { + Entity, +} from './'; + +export class EntityWithEntityProperty { + readonly _props?: Exact; + + readonly type = 'entity_with_entity_property'; + entity?: Type; + + constructor(props?: Exact) { + this.entity = props?.entity; + } +} + +interface EntityWithEntityPropertyProps { + entity?: Type; +} diff --git a/api_generator/tests/references/type_script/EntityWithOptionalComplexProperty.ts b/api_generator/tests/references/type_script/EntityWithOptionalComplexProperty.ts new file mode 100644 index 000000000..0dfe3cc50 --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithOptionalComplexProperty.ts @@ -0,0 +1,24 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithOptionalComplexProperty { + readonly _props?: Exact; + + readonly type = 'entity_with_optional_complex_property'; + property?: Type; + + constructor(props?: Exact) { + this.property = props?.property; + } +} + +interface EntityWithOptionalComplexPropertyProps { + property?: Type; +} + +export interface IEntityWithOptionalComplexPropertyProperty { + value: Type | DivExpression; +} diff --git a/api_generator/tests/references/type_script/EntityWithOptionalProperty.ts b/api_generator/tests/references/type_script/EntityWithOptionalProperty.ts new file mode 100644 index 000000000..ba5e0fc12 --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithOptionalProperty.ts @@ -0,0 +1,20 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithOptionalProperty { + readonly _props?: Exact; + + readonly type = 'entity_with_optional_property'; + property?: Type | DivExpression; + + constructor(props?: Exact) { + this.property = props?.property; + } +} + +interface EntityWithOptionalPropertyProps { + property?: Type | DivExpression; +} diff --git a/api_generator/tests/references/type_script/EntityWithOptionalStringEnumProperty.ts b/api_generator/tests/references/type_script/EntityWithOptionalStringEnumProperty.ts new file mode 100644 index 000000000..c096b4677 --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithOptionalStringEnumProperty.ts @@ -0,0 +1,24 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithOptionalStringEnumProperty { + readonly _props?: Exact; + + readonly type = 'entity_with_optional_string_enum_property'; + property?: Type | DivExpression; + + constructor(props?: Exact) { + this.property = props?.property; + } +} + +interface EntityWithOptionalStringEnumPropertyProps { + property?: Type | DivExpression; +} + +export type EntityWithOptionalStringEnumPropertyProperty = + | 'first' + | 'second'; diff --git a/api_generator/tests/references/type_script/EntityWithPropertyWithDefaultValue.ts b/api_generator/tests/references/type_script/EntityWithPropertyWithDefaultValue.ts new file mode 100644 index 000000000..bbae2b2aa --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithPropertyWithDefaultValue.ts @@ -0,0 +1,44 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithPropertyWithDefaultValue { + readonly _props?: Exact; + + readonly type = 'entity_with_property_with_default_value'; + int?: Type | DivExpression; + /** + * non_optional is used to suppress auto-generation of default value for object with all-optional + * fields. + */ + nested?: Type; + url?: Type | DivExpression; + + constructor(props?: Exact) { + this.int = props?.int; + this.nested = props?.nested; + this.url = props?.url; + } +} + +interface EntityWithPropertyWithDefaultValueProps { + int?: Type | DivExpression; + /** + * non_optional is used to suppress auto-generation of default value for object with all-optional + * fields. + */ + nested?: Type; + url?: Type | DivExpression; +} + +/** + * non_optional is used to suppress auto-generation of default value for object with all-optional + * fields. + */ +export interface IEntityWithPropertyWithDefaultValueNested { + int?: Type | DivExpression; + non_optional: Type | DivExpression; + url?: Type | DivExpression; +} diff --git a/api_generator/tests/references/type_script/EntityWithRequiredProperty.ts b/api_generator/tests/references/type_script/EntityWithRequiredProperty.ts new file mode 100644 index 000000000..a6586ae99 --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithRequiredProperty.ts @@ -0,0 +1,20 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithRequiredProperty { + readonly _props?: Exact; + + readonly type = 'entity_with_required_property'; + property: Type | DivExpression; + + constructor(props: Exact) { + this.property = props.property; + } +} + +interface EntityWithRequiredPropertyProps { + property: Type | DivExpression; +} diff --git a/api_generator/tests/references/type_script/EntityWithSimpleProperties.ts b/api_generator/tests/references/type_script/EntityWithSimpleProperties.ts new file mode 100644 index 000000000..ccca8339c --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithSimpleProperties.ts @@ -0,0 +1,99 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +/** + * Entity with simple properties. + */ +export class EntityWithSimpleProperties { + readonly _props?: Exact; + + readonly type = 'entity_with_simple_properties'; + /** + * Boolean property. + */ + boolean?: Type | DivExpression; + /** + * Boolean value in numeric format. + * + * @deprecated + */ + boolean_int?: Type | DivExpression; + /** + * Color. + */ + color?: Type | DivExpression; + /** + * Floating point number. + */ + double?: Type | DivExpression; + /** + * ID. Can't contain expressions. + */ + id?: Type; + /** + * Integer. + */ + integer?: Type | DivExpression; + /** + * Positive integer. + */ + positive_integer?: Type | DivExpression; + /** + * String. + */ + string?: Type | DivExpression; + url?: Type | DivExpression; + + constructor(props?: Exact) { + this.boolean = props?.boolean; + this.boolean_int = props?.boolean_int; + this.color = props?.color; + this.double = props?.double; + this.id = props?.id; + this.integer = props?.integer; + this.positive_integer = props?.positive_integer; + this.string = props?.string; + this.url = props?.url; + } +} + +interface EntityWithSimplePropertiesProps { + /** + * Boolean property. + */ + boolean?: Type | DivExpression; + /** + * Boolean value in numeric format. + * + * @deprecated + */ + boolean_int?: Type | DivExpression; + /** + * Color. + */ + color?: Type | DivExpression; + /** + * Floating point number. + */ + double?: Type | DivExpression; + /** + * ID. Can't contain expressions. + */ + id?: Type; + /** + * Integer. + */ + integer?: Type | DivExpression; + /** + * Positive integer. + */ + positive_integer?: Type | DivExpression; + /** + * String. + */ + string?: Type | DivExpression; + url?: Type | DivExpression; +} diff --git a/api_generator/tests/references/type_script/EntityWithStrictArray.ts b/api_generator/tests/references/type_script/EntityWithStrictArray.ts new file mode 100644 index 000000000..3bb6a7d66 --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithStrictArray.ts @@ -0,0 +1,24 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +import { + Entity, +} from './'; + +export class EntityWithStrictArray { + readonly _props?: Exact; + + readonly type = 'entity_with_strict_array'; + array: Type>; + + constructor(props: Exact) { + this.array = props.array; + } +} + +interface EntityWithStrictArrayProps { + array: Type>; +} diff --git a/api_generator/tests/references/type_script/EntityWithStringArrayProperty.ts b/api_generator/tests/references/type_script/EntityWithStringArrayProperty.ts new file mode 100644 index 000000000..e6c36de5e --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithStringArrayProperty.ts @@ -0,0 +1,20 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithStringArrayProperty { + readonly _props?: Exact; + + readonly type = 'entity_with_string_array_property'; + array: Type>; + + constructor(props: Exact) { + this.array = props.array; + } +} + +interface EntityWithStringArrayPropertyProps { + array: Type>; +} diff --git a/api_generator/tests/references/type_script/EntityWithStringEnumProperty.ts b/api_generator/tests/references/type_script/EntityWithStringEnumProperty.ts new file mode 100644 index 000000000..23ff3fb7f --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithStringEnumProperty.ts @@ -0,0 +1,24 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithStringEnumProperty { + readonly _props?: Exact; + + readonly type = 'entity_with_string_enum_property'; + property: Type | DivExpression; + + constructor(props: Exact) { + this.property = props.property; + } +} + +interface EntityWithStringEnumPropertyProps { + property: Type | DivExpression; +} + +export type EntityWithStringEnumPropertyProperty = + | 'first' + | 'second'; diff --git a/api_generator/tests/references/type_script/EntityWithStringEnumPropertyWithDefaultValue.ts b/api_generator/tests/references/type_script/EntityWithStringEnumPropertyWithDefaultValue.ts new file mode 100644 index 000000000..0d5ef4f3f --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithStringEnumPropertyWithDefaultValue.ts @@ -0,0 +1,25 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithStringEnumPropertyWithDefaultValue { + readonly _props?: Exact; + + readonly type = 'entity_with_string_enum_property_with_default_value'; + value?: Type | DivExpression; + + constructor(props?: Exact) { + this.value = props?.value; + } +} + +interface EntityWithStringEnumPropertyWithDefaultValueProps { + value?: Type | DivExpression; +} + +export type EntityWithStringEnumPropertyWithDefaultValueValue = + | 'first' + | 'second' + | 'third'; diff --git a/api_generator/tests/references/type_script/EntityWithoutProperties.ts b/api_generator/tests/references/type_script/EntityWithoutProperties.ts new file mode 100644 index 000000000..5093cd018 --- /dev/null +++ b/api_generator/tests/references/type_script/EntityWithoutProperties.ts @@ -0,0 +1,17 @@ +// Generated code. Do not modify. + +import { TemplateBlock } from '../blocks'; +import { Exact, IntBoolean, NonEmptyArray } from '../helper'; +import { Type } from '../template'; + +export class EntityWithoutProperties { + readonly _props?: Exact; + + readonly type = 'entity_without_properties'; + + constructor(props?: Exact) { + } +} + +interface EntityWithoutPropertiesProps { +} diff --git a/api_generator/tests/references/type_script/index.ts b/api_generator/tests/references/type_script/index.ts new file mode 100644 index 000000000..b53cc87ff --- /dev/null +++ b/api_generator/tests/references/type_script/index.ts @@ -0,0 +1,20 @@ +// Generated code. Do not modify. + +export * from './Entity'; +export * from './EntityWithArray'; +export * from './EntityWithArrayOfNestedItems'; +export * from './EntityWithArrayWithTransform'; +export * from './EntityWithComplexProperty'; +export * from './EntityWithComplexPropertyWithDefaultValue'; +export * from './EntityWithEntityProperty'; +export * from './EntityWithOptionalComplexProperty'; +export * from './EntityWithOptionalProperty'; +export * from './EntityWithOptionalStringEnumProperty'; +export * from './EntityWithPropertyWithDefaultValue'; +export * from './EntityWithRequiredProperty'; +export * from './EntityWithSimpleProperties'; +export * from './EntityWithStrictArray'; +export * from './EntityWithStringArrayProperty'; +export * from './EntityWithStringEnumProperty'; +export * from './EntityWithStringEnumPropertyWithDefaultValue'; +export * from './EntityWithoutProperties'; \ No newline at end of file diff --git a/api_generator/tests/test_api_generator.py b/api_generator/tests/test_api_generator.py new file mode 100644 index 000000000..2f83f1004 --- /dev/null +++ b/api_generator/tests/test_api_generator.py @@ -0,0 +1,86 @@ +import utils +import os + +try: + # full package name for ya make + from divkit.public.api_generator.api_generator.config import Config + from divkit.public.api_generator.api_generator.schema.preprocessing.preprocessor import resolve_structure + from divkit.public.api_generator.api_generator.generator import generate_api + +except ModuleNotFoundError: + from api_generator.config import Config + from api_generator.schema.preprocessing.preprocessor import resolve_structure + from api_generator.generator import generate_api + +# Set SHOULD_UPDATE_REFERENCES = True and run pytest to update reference files. +SHOULD_UPDATE_REFERENCES = False + +TEST_SCHEMA_PATH = utils.path_divkit_test_data('test_schema') +REAL_SCHEMA_PATH = utils.path_divkit_test_data(os.path.join('..', 'schema')) +OUTPUT_PATH = utils.path_generator_tests('output') + + +def test_schema_preprocessor(): + def assert_json_preprocessor_test(filename: str) -> None: + path = utils.path_generator_tests(os.path.join('references', filename)) + if SHOULD_UPDATE_REFERENCES: + utils.update_json_reference(filename=path, json_content=root_directory.as_json) + assert False, 'Updated references. Don\'t forget to restore SHOULD_UPDATE_REFERENCES flag.' + utils.assert_as_json_test(file_expected=path, content_actual=root_directory.as_json) + + test_config = Config(config_path=utils.path_generator_tests(os.path.join('configs', 'swift_config.json')), + schema_path=TEST_SCHEMA_PATH, + output_path=OUTPUT_PATH) + + root_directory = resolve_structure(test_config) + assert_json_preprocessor_test(filename='reference_resolve_structure.json') + + root_directory.merge_all_ofs(test_config.generation.lang) + assert_json_preprocessor_test(filename='reference_merge_allOfs.json') + + root_directory.resolve_references(test_config.generation.lang) + assert_json_preprocessor_test(filename='reference_resolve_references.json') + + root_directory.clean_unused_definitions() + assert_json_preprocessor_test(filename='reference_clean_unused_definitions.json') + + +def test_swift_generator(): + assert_test_generator(config_filename='swift_config.json', + schema_path=TEST_SCHEMA_PATH, + references_folder_name='swift') + + +def test_kotlin_generator(): + assert_test_generator(config_filename='kotlin_config.json', + schema_path=TEST_SCHEMA_PATH, + references_folder_name='kotlin') + + +def test_documentation_generator(): + assert_test_generator(config_filename='documentation_config.json', + schema_path=TEST_SCHEMA_PATH, + references_folder_name='documentation') + + +def test_type_script_generator(): + assert_test_generator(config_filename='typescript_config.json', + schema_path=TEST_SCHEMA_PATH, + references_folder_name='type_script') + + +def assert_test_generator(config_filename: str, schema_path: str, references_folder_name: str): + config = Config(config_path=utils.path_generator_tests(os.path.join('configs', config_filename)), + schema_path=schema_path, + output_path=OUTPUT_PATH) + if not os.path.exists(OUTPUT_PATH): + os.makedirs(OUTPUT_PATH) + generate_api(config) + references_path = utils.path_generator_tests(os.path.join('references', references_folder_name)) + + if SHOULD_UPDATE_REFERENCES: + utils.clear_content_of_directory(directory=references_path) + utils.update_references(source_path=OUTPUT_PATH, destination_path=references_path) + assert False, 'Updated references. Don\'t forget to restore SHOULD_UPDATE_REFERENCES flag.' + + utils.compare_dirs(references_path, OUTPUT_PATH) diff --git a/api_generator/tests/utils.py b/api_generator/tests/utils.py new file mode 100644 index 000000000..20e9459f7 --- /dev/null +++ b/api_generator/tests/utils.py @@ -0,0 +1,103 @@ +import json +import os +import shutil +from typing import Dict, Any +from deepdiff import DeepDiff + +try: + import yatest.common as yc + + PROJECT_TESTS_PATH = os.path.join('divkit', 'public', 'api_generator', 'tests') + DIVKIT_TESTS_PATH = os.path.join('divkit', 'public', 'test_data') + + def path_generator_tests(relative_path: str) -> str: + return yc.build_path(os.path.join(PROJECT_TESTS_PATH, relative_path)) + + def path_divkit_test_data(relative_path: str) -> str: + return yc.build_path(os.path.join(DIVKIT_TESTS_PATH, relative_path)) + +except ModuleNotFoundError: + PROJECT_TESTS_PATH = os.path.join('tests') + DIVKIT_TESTS_PATH = os.path.join('..', 'test_data') + + def path_generator_tests(relative_path: str) -> str: + return os.path.join(PROJECT_TESTS_PATH, relative_path) + + def path_divkit_test_data(relative_path: str) -> str: + return os.path.join(DIVKIT_TESTS_PATH, relative_path) + + +def assert_as_json_test(file_expected: str, content_actual: Dict) -> None: + with open(file_expected, 'r') as expected: + content_expected = json.loads(expected.read()) + diff = DeepDiff(content_expected, content_actual, ignore_order=True) + assert len(diff) == 0 + + +def update_reference(filename: str, content: Any): + with open(filename, 'w') as updated_reference: + updated_reference.write(str(content)) + + +def update_json_reference(filename: str, json_content: Dict[str, Any]): + update_reference(filename, json.dumps(json_content, indent=2, ensure_ascii=False)) + + +def clear_content_of_directory(directory: str) -> None: + for file in os.listdir(directory): + file_path = os.path.join(directory, file) + try: + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + except Exception as e: + print(f'Failed to delete {file_path}. Reason: {e}') + + +def update_references(source_path: str, destination_path: str): + for file in os.listdir(source_path): + if os.path.isfile(os.path.join(source_path, file)): + src_file_path, dst_file_path = __join_paths(source_path, destination_path, file) + shutil.copy(src_file_path, dst_file_path) + else: + src_file_path, dst_file_path = __join_paths(source_path, destination_path, file) + if not os.path.exists(dst_file_path): + os.makedirs(dst_file_path) + update_references(src_file_path, dst_file_path) + + +def compare_dirs(references_path: str, generated_path: str): + for file in os.listdir(references_path): + if os.path.isfile(os.path.join(references_path, file)): + ref_file_path, gen_file_path = __join_paths(references_path, generated_path, file) + compare_files(file, ref_file_path, gen_file_path) + else: + ref_dir_path, gen_dir_path = __join_paths(references_path, generated_path, file) + compare_dirs(ref_dir_path, gen_dir_path) + + +def __join_paths(references_path: str, generated_path: str, new_path: str) -> (str, str): + ref_path = os.path.join(references_path, new_path) + gen_path = os.path.join(generated_path, new_path) + return ref_path, gen_path + + +def compare_files(filename: str, ref_file_path: str, generated_file_path: str): + with open(ref_file_path, 'r') as ref_file: + with open(generated_file_path, 'r') as gen_file: + ref_lines, gen_lines = ref_file.readlines(), gen_file.readlines() + lines_count = min(len(ref_lines), len(gen_lines)) + for line_ind in range(lines_count): + ref_line, gen_line = ref_lines[line_ind], gen_lines[line_ind] + are_equal = ref_line == gen_line + if not are_equal: + print(f'Lines are not equal in file {filename}. Line number: {line_ind + 1}') + print(f'Expected: [ {ref_line.strip()} ]') + print(f'Actual: [ {gen_line.strip()} ]') + assert are_equal + lines_count_equal = len(ref_lines) == len(gen_lines) + if not lines_count_equal: + print(f'Different lines count in reference({len(ref_lines)} lines) ' + + f'and generated({len(gen_lines)} lines) file {filename}.') + assert lines_count_equal diff --git a/client/android/.gitignore b/client/android/.gitignore new file mode 100644 index 000000000..41f081bba --- /dev/null +++ b/client/android/.gitignore @@ -0,0 +1,16 @@ +.gclient +.gclient_entries +*.iml +/.mainframer +*~ +/captures +/deps/android-sdk +/deps/jdk +*.pyc +*.swp +venv +/.cipd +/yahooks/linters/detekt/temp +.hpe +.sdk +__pycache__ diff --git a/client/android/assertion/build.gradle b/client/android/assertion/build.gradle new file mode 100644 index 000000000..2661e9dd2 --- /dev/null +++ b/client/android/assertion/build.gradle @@ -0,0 +1,16 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +android { + buildFeatures { buildConfig = true } + + defaultConfig { + buildConfigField "boolean", "DISABLE_ASSERTS", "${project.property("disableAssertsInBuild").toBoolean()}" + } +} + +dependencies { + implementation project(path: ':logging') + implementation "androidx.core:core:$versions.androidx.core" +} diff --git a/client/android/assertion/jacoco.excludes b/client/android/assertion/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/assertion/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/assertion/proguard-rules.pro b/client/android/assertion/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/client/android/assertion/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/client/android/assertion/src/main/AndroidManifest.xml b/client/android/assertion/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0c3f34e06 --- /dev/null +++ b/client/android/assertion/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/client/android/assertion/src/main/java/com/yandex/div/core/util/Assert.java b/client/android/assertion/src/main/java/com/yandex/div/core/util/Assert.java new file mode 100644 index 000000000..296a0e6cc --- /dev/null +++ b/client/android/assertion/src/main/java/com/yandex/div/core/util/Assert.java @@ -0,0 +1,324 @@ +package com.yandex.div.core.util; + +import android.os.Looper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.div.core.assertion.BuildConfig; + +/** + * Contains methods to make assertions that help capture programming errors. Should be disabled in the production environment. + * Disabled by default. + */ +public class Assert { + + public interface AssertPerformer { + default void performFail(@NonNull AssertionError assertionError) { + throw assertionError; + } + } + + @NonNull + private static AssertPerformer sAssertPerformer = new AssertPerformer() { + }; + private static volatile boolean sEnabled = false; + + private Assert() { + // prevent instantiation + } + + /** + * Asserts that a condition is true. If it isn't it throws an {@link AssertionError} with the given message. + */ + public static void assertTrue(@Nullable String message, boolean condition) { + if (!condition) { + fail(message); + } + } + + /** + * Asserts that a condition is true. If it isn't it throws an {@link AssertionError} without a message. + */ + public static void assertTrue(boolean condition) { + assertTrue(null, condition); + } + + /** + * Asserts that a condition is false. If it isn't it throws an {@link AssertionError} with the given message. + */ + public static void assertFalse(@Nullable String message, boolean condition) { + assertTrue(message, !condition); + } + + /** + * Asserts that a condition is false. If it isn't it throws an {@link AssertionError} without a message. + */ + public static void assertFalse(boolean condition) { + assertFalse(null, condition); + } + + /** + * Fails with no message. + */ + public static void fail() { + fail(null); + } + + /** + * Fails with the given message. + */ + public static void fail(@Nullable String message) { + if (BuildConfig.DISABLE_ASSERTS) { + return; + } + + if (sEnabled) { + performFail(new AssertionError(message == null ? "" : message)); + } + } + + /** + * Fails with the given message and throwable that caused the failure.. + */ + public static void fail(@Nullable String message, @Nullable Throwable cause) { + if (BuildConfig.DISABLE_ASSERTS) { + return; + } + + if (sEnabled) { + AssertionError assertionError = new AssertionError(message); + assertionError.initCause(cause); + performFail(assertionError); + } + } + + public static void setEnabled(boolean enabled) { + sEnabled = enabled; + } + + /** + * Returns value indicating if assertions are enabled. This class won't throw any {@link AssertionError}, if disabled. + */ + public static boolean isEnabled() { + if (BuildConfig.DISABLE_ASSERTS) { + return false; + } + + return sEnabled; + } + + /** + * Set custom {@link AssertPerformer} to override on fail behavior + */ + public static void setAssertPerformer(@NonNull AssertPerformer assertPerformer) { + sAssertPerformer = assertPerformer; + } + + /** + * Asserts that two objects are equal. If they are not, an {@link AssertionError} without a message is thrown. + * If expected and actual are null, they are considered equal. + * + * @param expected expected value + * @param actual the value to check against expected + */ + public static void assertEquals(@Nullable Object expected, @Nullable Object actual) { + assertEquals(null, expected, actual); + } + + /** + * Asserts that two objects are equal. If they are not, an {@link AssertionError} is thrown with the given message. + * If expected and actual are null, they are considered equal. + * + * @param message the identifying message for the {@link AssertionError} (null okay) + * @param expected expected value + * @param actual actual value + */ + public static void assertEquals(@Nullable String message, @Nullable Object expected, @Nullable Object actual) { + if (expected == null && actual == null) { + return; + } + if (expected != null && expected.equals(actual)) { + return; + } + if (expected instanceof String && actual instanceof String) { + String cleanMessage = message == null ? "" : message; + performFail(new ComparisonFailure(cleanMessage, (String) expected, (String) actual)); + } else { + failNotEquals(message, expected, actual); + } + } + + /** + * Asserts that two longs are equal. If they are not, an {@link AssertionError} is thrown. + * + * @param expected expected long value. + * @param actual actual long value + */ + public static void assertEquals(long expected, long actual) { + assertEquals(null, expected, actual); + } + + /** + * Asserts that two ints are equal. If they are not, an {@link AssertionError} is thrown. + * + * @param expected expected long value. + * @param actual actual long value + */ + public static void assertEquals(int expected, int actual) { + assertEquals(null, expected, actual); + } + + /** + * Asserts that two longs are equal. If they are not, an {@link AssertionError} is thrown with the given message. + * + * @param message the identifying message for the {@link AssertionError} (null okay) + * @param expected long expected value. + * @param actual long actual value + */ + public static void assertEquals(@Nullable String message, long expected, long actual) { + assertEquals(message, (Long) expected, (Long) actual); + } + + /** + * Asserts that an object isn't null. If it is an {@link AssertionError} is thrown with the given message. + * + * @param message the identifying message for the {@link AssertionError} + * @param object Object to check or null + */ + public static void assertNotNull(@Nullable String message, @Nullable Object object) { + assertTrue(message, object != null); + } + + /** + * Asserts that an object isn't null. If it is an {@link AssertionError} is thrown. + * + * @param object Object to check or null + */ + public static void assertNotNull(@Nullable Object object) { + assertNotNull(null, object); + } + + /** + * Asserts that an object is null. If it is not, an {@link AssertionError} is thrown with the given message. + * + * @param message the identifying message for the {@link AssertionError} + * @param object Object to check or null + */ + public static void assertNull(@Nullable String message, @Nullable Object object) { + assertTrue(message, object == null); + } + + /** + * Asserts that an object is null. If it isn't an {@link AssertionError} is thrown. + * + * @param object Object to check or null + */ + public static void assertNull(@Nullable Object object) { + assertNull(null, object); + } + + /** + * Asserts that two objects refer to the same object. If they are not, an {@link AssertionError} is thrown with the given message. + * + * @param message the identifying message for the {@link AssertionError} + * @param expected the expected object + * @param actual the object to compare to expected + */ + public static void assertSame(@Nullable String message, @Nullable Object expected, @Nullable Object actual) { + if (expected == actual) { + return; + } + failNotSame(message, expected, actual); + } + + /** + * Asserts that two objects refer to the same object. If they are not the same, an {@link AssertionError} without a message is thrown. + * + * @param expected the expected object + * @param actual the object to compare to expected + */ + public static void assertSame(@Nullable Object expected, @Nullable Object actual) { + assertSame(null, expected, actual); + } + + /** + * Asserts that two objects do not refer to the same object. If they do refer to the same object, + * an {@link AssertionError} is thrown with the given message. + * + * @param message the identifying message for the {@link AssertionError} + * @param unexpected the object you don't expect + * @param actual the object to compare to unexpected + */ + public static void assertNotSame(@Nullable String message, @Nullable Object unexpected, @Nullable Object actual) { + if (unexpected == actual) { + failSame(message); + } + } + + /** + * Asserts that two objects do not refer to the same object. If they do refer to the same object, + * an {@link AssertionError} without a message is thrown. + * + * @param unexpected the object you don't expect + * @param actual the object to compare to unexpected + */ + public static void assertNotSame(@Nullable Object unexpected, @Nullable Object actual) { + assertNotSame(null, unexpected, actual); + } + + public static void assertMainThread() { + assertSame("Code run not in main thread!", Looper.getMainLooper(), Looper.myLooper()); + } + + public static void assertNotMainThread() { + assertNotSame("Code run in main thread!", Looper.getMainLooper(), Looper.myLooper()); + } + + private static void failSame(@Nullable String message) { + String formatted = ""; + if (message != null) { + formatted = message + " "; + } + fail(formatted + "expected not same"); + } + + private static void failNotSame(@Nullable String message, @Nullable Object expected, @Nullable Object actual) { + String formatted = ""; + if (message != null) { + formatted = message + " "; + } + fail(formatted + "expected same:<" + expected + "> was not:<" + actual + ">"); + } + + private static void failNotEquals(@Nullable String message, @Nullable Object expected, @Nullable Object actual) { + fail(format(message, expected, actual)); + } + + static String format(@Nullable String message, @Nullable Object expected, @Nullable Object actual) { + String formatted = ""; + if (message != null && !message.equals("")) { + formatted = message + " "; + } + String expectedString = String.valueOf(expected); + String actualString = String.valueOf(actual); + if (expectedString.equals(actualString)) { + return formatted + "expected: " + + formatClassAndValue(expected, expectedString) + + " but was: " + formatClassAndValue(actual, actualString); + } else { + return formatted + "expected:<" + expectedString + "> but was:<" + actualString + ">"; + } + } + + private static String formatClassAndValue(@Nullable Object value, @Nullable String valueString) { + String className = value == null ? "null" : value.getClass().getName(); + return className + "<" + valueString + ">"; + } + + private static void performFail(@NonNull AssertionError assertionError) { + if (!isEnabled()) { + return; + } + + sAssertPerformer.performFail(assertionError); + } +} diff --git a/client/android/assertion/src/main/java/com/yandex/div/core/util/ComparisonFailure.java b/client/android/assertion/src/main/java/com/yandex/div/core/util/ComparisonFailure.java new file mode 100644 index 000000000..b3d1c57b1 --- /dev/null +++ b/client/android/assertion/src/main/java/com/yandex/div/core/util/ComparisonFailure.java @@ -0,0 +1,145 @@ +package com.yandex.div.core.util; + +/** + * Thrown when an {@link Assert#assertEquals(Object, Object)} fails. + */ +public class ComparisonFailure extends AssertionError { + /** + * The maximum length for mExpected and mActual. If it is exceeded, the strings should be shortened. + * + * @see ComparisonCompactor + */ + private static final int MAX_CONTEXT_LENGTH = 20; + private static final long serialVersionUID = 1L; + + private String mExpected; + private String mActual; + + /** + * Constructs a comparison failure. + * + * @param message the identifying message or null + * @param expected the expected string value + * @param actual the actual string value + */ + public ComparisonFailure(String message, String expected, String actual) { + super(message); + mExpected = expected; + mActual = actual; + } + + /** + * Returns "..." in place of common prefix and "..." in + * place of common suffix between expected and actual. + * + * @see Throwable#getMessage() + */ + @Override + public String getMessage() { + return new ComparisonCompactor(MAX_CONTEXT_LENGTH, mExpected, mActual).compact(super.getMessage()); + } + + /** + * Returns the actual string value + * + * @return the actual string value + */ + public String getActual() { + return mActual; + } + + /** + * Returns the expected string value + * + * @return the expected string value + */ + public String getExpected() { + return mExpected; + } + + private static class ComparisonCompactor { + private static final String ELLIPSIS = "..."; + private static final String DELTA_END = "]"; + private static final String DELTA_START = "["; + + /** + * The maximum length for expected and actual. When contextLength + * is exceeded, the Strings are shortened + */ + private int mContextLength; + private String mExpected; + private String mActual; + private int mPrefix; + private int mSuffix; + + /** + * @param contextLength the maximum length for expected and actual. When contextLength + * is exceeded, the Strings are shortened + * @param expected the expected string value + * @param actual the actual string value + */ + public ComparisonCompactor(int contextLength, String expected, String actual) { + mContextLength = contextLength; + mExpected = expected; + mActual = actual; + } + + private String compact(String message) { + if (mExpected == null || mActual == null || areStringsEqual()) { + return Assert.format(message, mExpected, mActual); + } + + findCommonPrefix(); + findCommonSuffix(); + String expected = compactString(mExpected); + String actual = compactString(mActual); + return Assert.format(message, expected, actual); + } + + private String compactString(String source) { + String result = DELTA_START + source.substring(mPrefix, source.length() - mSuffix + 1) + DELTA_END; + if (mPrefix > 0) { + result = computeCommonPrefix() + result; + } + if (mSuffix > 0) { + result = result + computeCommonSuffix(); + } + return result; + } + + private void findCommonPrefix() { + mPrefix = 0; + int end = Math.min(mExpected.length(), mActual.length()); + for (; mPrefix < end; mPrefix++) { + if (mExpected.charAt(mPrefix) != mActual.charAt(mPrefix)) { + break; + } + } + } + + private void findCommonSuffix() { + int expectedSuffix = mExpected.length() - 1; + int actualSuffix = mActual.length() - 1; + for (; actualSuffix >= mPrefix && expectedSuffix >= mPrefix; actualSuffix--, expectedSuffix--) { + if (mExpected.charAt(expectedSuffix) != mActual.charAt(actualSuffix)) { + break; + } + } + mSuffix = mExpected.length() - expectedSuffix; + } + + private String computeCommonPrefix() { + return (mPrefix > mContextLength ? ELLIPSIS : "") + mExpected.substring(Math.max(0, mPrefix - mContextLength), mPrefix); + } + + private String computeCommonSuffix() { + int end = Math.min(mExpected.length() - mSuffix + 1 + mContextLength, mExpected.length()); + return mExpected.substring(mExpected.length() - mSuffix + 1, end) + (mExpected.length() - mSuffix + 1 < mExpected.length() - + mContextLength ? ELLIPSIS : ""); + } + + private boolean areStringsEqual() { + return mExpected.equals(mActual); + } + } +} diff --git a/client/android/assertion/src/main/java/com/yandex/div/core/util/KAssert.kt b/client/android/assertion/src/main/java/com/yandex/div/core/util/KAssert.kt new file mode 100644 index 000000000..d643a7863 --- /dev/null +++ b/client/android/assertion/src/main/java/com/yandex/div/core/util/KAssert.kt @@ -0,0 +1,142 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.yandex.div.core.util + +/** + * Kotlin wrapper for [Assert] using inline [Assert.isEnabled] checks. + */ +object KAssert { + + /** + * @see [Assert.fail] + */ + inline fun fail(message: () -> String) { + if (Assert.isEnabled()) { + Assert.fail(message()) + } + } + + /** + * @see [Assert.fail] + */ + inline fun fail(cause: Throwable?, message: () -> String = { "" }) { + if (Assert.isEnabled()) { + Assert.fail(message(), cause) + } + } + + /** + * [KAssert.fail] + [KLog.e] + */ + inline fun failWithLog(tag: String, message: () -> String) { + KLog.e(tag, message) + fail(message) + } + + /** + * [KAssert.fail] + [KLog.e] + */ + inline fun failWithLog(tag: String, cause: Throwable, message: () -> String = { "" }) { + KLog.e(tag, cause, message) + fail(cause, message) + } + + /** + * @see [Assert.assertTrue] + */ + inline fun assertTrue(condition: Boolean, message: () -> String = { "" }) { + if (Assert.isEnabled() && !condition) { + Assert.fail(message()) + } + } + + /** + * @see [Assert.assertFalse] + */ + inline fun assertFalse(condition: Boolean, message: () -> String = { "" }) { + if (Assert.isEnabled() && condition) { + Assert.fail(message()) + } + } + + /** + * @see [Assert.assertTrue] + */ + inline fun assertTrue(condition: () -> Boolean, message: () -> String = { "" }) { + if (Assert.isEnabled() && !condition()) { + Assert.fail(message()) + } + } + + /** + * @see [Assert.assertFalse] + */ + inline fun assertFalse(condition: () -> Boolean, message: () -> String = { "" }) { + if (Assert.isEnabled() && condition()) { + Assert.fail(message()) + } + } + + /** + * @see [Assert.assertEquals] + */ + inline fun assertEquals(expected: Any?, actual: Any?, message: () -> String = { "" }) { + if (Assert.isEnabled()) { + Assert.assertEquals(message(), expected, actual) + } + } + + /** + * @see [Assert.assertNotNull] + */ + inline fun assertNotNull(nullable: Any?, message: () -> String = { "" }) { + if (Assert.isEnabled() && nullable == null) { + Assert.fail(message()) + } + } + + /** + * @see [Assert.assertNull] + */ + inline fun assertNull(nullable: Any?, message: () -> String = { "" }) { + if (Assert.isEnabled() && nullable != null) { + Assert.fail(message()) + } + } + + /** + * @see [Assert.assertSame] + */ + inline fun assertSame(expected: Any?, actual: Any?, message: () -> String = { "" }) { + if (Assert.isEnabled()) { + Assert.assertSame(message(), expected, actual) + } + } + + /** + * @see [Assert.assertNotSame] + */ + inline fun assertNotSame(expected: Any?, actual: Any?, message: () -> String = { "" }) { + if (Assert.isEnabled()) { + Assert.assertNotSame(message(), expected, actual) + } + } + + /** + * @see [Assert.assertMainThread] + */ + inline fun assertMainThread() { + if (Assert.isEnabled()) { + Assert.assertMainThread() + } + } + + /** + * @see [Assert.assertNotMainThread] + */ + inline fun assertNotMainThread() { + if (Assert.isEnabled()) { + Assert.assertNotMainThread() + } + } +} diff --git a/client/android/assertion/src/main/java/com/yandex/div/core/util/KLog.kt b/client/android/assertion/src/main/java/com/yandex/div/core/util/KLog.kt new file mode 100644 index 000000000..3d6b619b2 --- /dev/null +++ b/client/android/assertion/src/main/java/com/yandex/div/core/util/KLog.kt @@ -0,0 +1,95 @@ +package com.yandex.div.core.util + +/** + * Kotlin wrapper for [Log] using inline [Log.isEnabled] checks. + */ +object KLog { + + val listeners = mutableListOf() + + inline fun v(tag: String, message: () -> String) { + if (Log.isEnabled()) { + print(android.util.Log.VERBOSE, tag, message()) + } + } + + inline fun v(tag: String, th: Throwable, message: () -> String) { + if (Log.isEnabled()) { + android.util.Log.v(tag, message(), th) + } + } + + inline fun d(tag: String, message: () -> String) { + if (Log.isEnabled()) { + print(android.util.Log.DEBUG, tag, message()) + } + } + + inline fun d(tag: String, th: Throwable, message: () -> String) { + if (Log.isEnabled()) { + android.util.Log.d(tag, message(), th) + } + } + + inline fun w(tag: String, message: () -> String) { + if (Log.isEnabled()) { + print(android.util.Log.WARN, tag, message()) + } + } + + inline fun w(tag: String, th: Throwable, message: () -> String) { + if (Log.isEnabled()) { + android.util.Log.w(tag, message(), th) + } + } + + inline fun i(tag: String, message: () -> String) { + if (Log.isEnabled()) { + print(android.util.Log.INFO, tag, message()) + } + } + + inline fun i(tag: String, th: Throwable, message: () -> String) { + if (Log.isEnabled()) { + android.util.Log.i(tag, message(), th) + } + } + + inline fun e(tag: String, message: () -> String) { + if (Log.isEnabled()) { + print(android.util.Log.ERROR, tag, message()) + } + } + + inline fun e(tag: String, th: Throwable?, message: () -> String = { "" }) { + if (Log.isEnabled()) { + android.util.Log.e(tag, message(), th) + } + } + + fun addListener(listener: LogListener) { + synchronized(listeners) { + listeners.add(listener) + } + } + + fun removeListener(listener: LogListener) { + synchronized(listeners) { + listeners.remove(listener) + } + } + + @PublishedApi + internal fun print(priority: Int, tag: String, message: String) { + android.util.Log.println(priority, tag, message) + synchronized(listeners) { + listeners.forEach { listener -> + listener.onNewMessage(priority, tag, message) + } + } + } +} + +interface LogListener { + fun onNewMessage(priority: Int, tag: String, message: String) +} diff --git a/client/android/beacon/build.gradle b/client/android/beacon/build.gradle new file mode 100644 index 000000000..3e6970bd4 --- /dev/null +++ b/client/android/beacon/build.gradle @@ -0,0 +1,12 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +dependencies { + implementation project(path: ':assertion') + implementation project(path: ':div-core') + implementation project(path: ':logging') + implementation project(path: ':utils') + + implementation "androidx.collection:collection:$versions.androidx.collection" +} diff --git a/client/android/beacon/jacoco.excludes b/client/android/beacon/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/beacon/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/beacon/proguard-rules.pro b/client/android/beacon/proguard-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/client/android/beacon/src/main/AndroidManifest.xml b/client/android/beacon/src/main/AndroidManifest.xml new file mode 100644 index 000000000..d3ef67521 --- /dev/null +++ b/client/android/beacon/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/BeaconItem.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/BeaconItem.kt new file mode 100644 index 000000000..7d04fb30f --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/BeaconItem.kt @@ -0,0 +1,45 @@ +package com.yandex.android.beacon + +import android.net.Uri +import com.yandex.android.net.CookieStorage +import org.json.JSONObject + +abstract class BeaconItem internal constructor( + val url: Uri, + val headers: Map, + val payload: JSONObject?, + val addTimestamp: Long +) { + + abstract val cookieStorage: CookieStorage? + + abstract fun asPersistent(): Persistent? + + override fun toString(): String { + return "BeaconItem{url=$url, headers=$headers, addTimestamp=$addTimestamp" + } + + class NonPersistent( + url: Uri, + headers: Map, + payload: JSONObject?, + addTimestamp: Long, + override val cookieStorage: CookieStorage + ) : BeaconItem(url, headers, payload, addTimestamp) { + + override fun asPersistent(): Persistent? = null + } + + class Persistent( + url: Uri, + headers: Map, + payload: JSONObject?, + addTimestamp: Long, + val rowId: Long + ) : BeaconItem(url, headers, payload, addTimestamp) { + + override val cookieStorage: CookieStorage? = null + + override fun asPersistent(): Persistent = this + } +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconConfiguration.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconConfiguration.kt new file mode 100644 index 000000000..c0a6cef47 --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconConfiguration.kt @@ -0,0 +1,13 @@ +package com.yandex.android.beacon + +import com.yandex.div.core.annotations.PublicApi +import java.util.concurrent.Executor + +@PublicApi +class SendBeaconConfiguration( + val executor: Executor, + val requestExecutor: SendBeaconRequestExecutor, + val workerScheduler: SendBeaconWorkerScheduler, + val perWorkerLogger: SendBeaconPerWorkerLogger, + val databaseName: String +) diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconDb.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconDb.kt new file mode 100644 index 000000000..839dfa073 --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconDb.kt @@ -0,0 +1,151 @@ +package com.yandex.android.beacon + +import android.app.Application +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.net.Uri +import android.provider.BaseColumns +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import com.yandex.android.beacon.BeaconItem.Persistent +import com.yandex.android.beacon.SendBeaconDb.Factory +import com.yandex.android.util.deserializeMap +import com.yandex.android.util.serialize +import com.yandex.div.core.annotations.Mockable +import com.yandex.div.core.util.Assert +import org.json.JSONException +import org.json.JSONObject + +@Mockable +internal class SendBeaconDb constructor( + context: Context, + databaseName: String +) : SQLiteOpenHelper(context, databaseName, null, DATABASE_VERSION) { + + init { + Assert.assertTrue(context is Application) + } + + override fun onCreate(sqLiteDatabase: SQLiteDatabase) { + sqLiteDatabase.execSQL(DATABASE_CREATE) + } + + override fun onUpgrade(sqLiteDatabase: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion == DATABASE_INIT_VERSION) { + sqLiteDatabase.execSQL(ADD_PAYLOAD_COLUMN_TO_ITEM) + } + } + + @WorkerThread + fun allItems(): List { + val result = mutableListOf() + val db = readableDatabase + var cursor: Cursor? = null + try { + cursor = db.query(TABLE_ITEMS, QUERY_COLUMNS, null, null, null, null, null, null) + if (cursor != null) { + while (cursor.moveToNext()) { + result.add(beaconItemFromCursor(cursor)) + } + } + } finally { + cursor?.close() + db.close() + } + return result + } + + @WorkerThread + fun add(url: Uri, headers: Map, addTimestamp: Long, payload: JSONObject?): Persistent { + val contentValues = ContentValues(3).apply { + put(COLUMN_URL, url.toString()) + put(COLUMN_HEADERS, headers.serialize()) + put(COLUMN_ADD_TIMESTAMP, addTimestamp) + } + + val database = writableDatabase + val rowId = database.use { + database.insert(TABLE_ITEMS, null, contentValues) + } + return Persistent(url, headers, payload, addTimestamp, rowId) + } + + @WorkerThread + fun remove(item: Persistent?): Boolean { + if (item == null) return false + + val database = writableDatabase + val res = database.use { + database.delete(TABLE_ITEMS, REMOVE_CLAUSE, arrayOf(item.rowId.toString())) + } + return res != 0 + } + + private fun beaconItemFromCursor(cursor: Cursor): Persistent { + return Persistent( + Uri.parse(cursor.getString(1)), + cursor.getString(2).deserializeMap(), + cursor.getNullableJson(4), + cursor.getLong(3), + cursor.getLong(0) + ) + } + + fun interface Factory { + + fun create(context: Context, databaseName: String): SendBeaconDb + } + + companion object { + + private const val DATABASE_VERSION = 2 + private const val DATABASE_INIT_VERSION = 1 + + private const val TABLE_ITEMS = "items" + private const val COLUMN_ID = BaseColumns._ID + private const val COLUMN_URL = "url" + private const val COLUMN_HEADERS = "headers" + private const val COLUMN_ADD_TIMESTAMP = "add_timestamp" + private const val COLUMN_PAYLOAD = "payload" + + private const val DATABASE_CREATE = """ + CREATE TABLE $TABLE_ITEMS( + $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLUMN_URL TEXT NOT NULL, + $COLUMN_HEADERS TEXT, + $COLUMN_ADD_TIMESTAMP INTEGER, + $COLUMN_PAYLOAD TEXT)""" + + private const val ADD_PAYLOAD_COLUMN_TO_ITEM = """ + ALTER TABLE $TABLE_ITEMS ADD COLUMN $COLUMN_PAYLOAD TEXT; + """ + + private val QUERY_COLUMNS = arrayOf(COLUMN_ID, COLUMN_URL, COLUMN_HEADERS, COLUMN_ADD_TIMESTAMP, COLUMN_PAYLOAD) + private const val REMOVE_CLAUSE = "$COLUMN_ID = ?" + + @JvmField + @VisibleForTesting + internal var factory: Factory = Factory(::SendBeaconDb) + } + + private fun Cursor.getNullableString(columnIndex: Int): String? = + if (isNull(columnIndex)) null else getString(columnIndex) + + private fun Cursor.getNullableJson(columnIndex: Int): JSONObject? { + return getNullableString(columnIndex)?.let { + if (it.isNotEmpty()) { + try { + JSONObject(it) + } catch (e : JSONException) { + Assert.fail("Payload parsing exception: $e") + null + } + } else { + null + } + } + } +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconManager.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconManager.kt new file mode 100644 index 000000000..0bec2ff61 --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconManager.kt @@ -0,0 +1,43 @@ +package com.yandex.android.beacon + +import android.content.Context +import android.net.Uri +import com.yandex.android.net.CookieStorage +import com.yandex.div.core.annotations.PublicApi +import org.json.JSONObject + +/** + * A service similar to W3C's sendBeacon function from web-page world. It accepts requests with + * no payloads and zero byte responses and delivers them no matter how many retries it will take. + */ +@PublicApi +class SendBeaconManager( + context: Context, + configuration: SendBeaconConfiguration +) { + private val sendBeaconWorker = SendBeaconWorkerImpl( + context, + configuration + ) + + @JvmOverloads + fun addUrl(url: Uri, headers: Map = emptyMap(), payload: JSONObject? = null) { + sendBeaconWorker.add(url, headers, payload, TRY_IMMEDIATELY) + } + + fun addNonPersistentUrl(url: Uri, cookieStorage: CookieStorage, payload: JSONObject? = null) { + sendBeaconWorker.addNonPersistentUrl(url, emptyMap(), cookieStorage, payload, TRY_IMMEDIATELY) + } + + fun onStart(callback: SendBeaconWorker.Callback): Boolean { + return sendBeaconWorker.onStart(callback) + } + + fun onStop(): Boolean { + return sendBeaconWorker.onStop() + } + + private companion object { + private const val TRY_IMMEDIATELY = true + } +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconPerWorkerLogger.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconPerWorkerLogger.kt new file mode 100644 index 000000000..ef783a08f --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconPerWorkerLogger.kt @@ -0,0 +1,54 @@ +package com.yandex.android.beacon + +import com.yandex.div.core.util.KLog + +/** + * Send beacon logging facility. + */ +interface SendBeaconPerWorkerLogger { + + /** + * Called when beacon request was prepared and about to be executed. + */ + fun onTrySendUrl(url: String) + + /** + * Called when beacon request was successfully executed. + */ + fun onSuccessSendUrl(url: String) + + /** + * Called when beacon request was failed due to general error. + */ + fun onFailedSendUrl(url: String, exceptionCaught: Boolean) + + /** + * Called when beacon request was failed due to server error (usually this means that server + * responded with 5XX code for this beacon request). + */ + fun onFailedSendUrlDueServerError(url: String) + + /** + * No-op implementation. + */ + object NoOp : SendBeaconPerWorkerLogger { + override fun onTrySendUrl(url: String) = Unit + override fun onSuccessSendUrl(url: String) = Unit + override fun onFailedSendUrl(url: String, exceptionCaught: Boolean) = Unit + override fun onFailedSendUrlDueServerError(url: String) = Unit + } + + /** + * Logcat implementation. + */ + object Logcat : SendBeaconPerWorkerLogger { + override fun onTrySendUrl(url: String) = KLog.i(TAG) { "onTrySendUrl: $url" } + override fun onSuccessSendUrl(url: String) = KLog.i(TAG) { "onSuccessSendUrl: $url" } + override fun onFailedSendUrl(url: String, exceptionCaught: Boolean) = KLog.i(TAG) { "onFailedSendUrl: $url" } + override fun onFailedSendUrlDueServerError(url: String) = KLog.i(TAG) { "onFailedSendUrlDueServerError: $url" } + } + + private companion object { + private const val TAG = "SendBeaconPerWorkerLogger" + } +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconRequest.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconRequest.kt new file mode 100644 index 000000000..edf715b22 --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconRequest.kt @@ -0,0 +1,29 @@ +package com.yandex.android.beacon + +import android.net.Uri +import com.yandex.android.net.CookieStorage +import org.json.JSONObject + +/** + * Send beacon network request primitive. + */ +data class SendBeaconRequest( + val url: Uri, + val headers: Map, + val payload: JSONObject?, + val cookieStorage: CookieStorage? +) { + + companion object { + + @JvmStatic + fun from(beaconItem: BeaconItem): SendBeaconRequest { + return SendBeaconRequest( + beaconItem.url, + beaconItem.headers, + beaconItem.payload, + beaconItem.cookieStorage + ) + } + } +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconRequestExecutor.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconRequestExecutor.kt new file mode 100644 index 000000000..130ff9a18 --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconRequestExecutor.kt @@ -0,0 +1,18 @@ +package com.yandex.android.beacon + +import androidx.annotation.WorkerThread +import java.io.IOException + +/** + * Responsible for send beacon request execution. + */ +interface SendBeaconRequestExecutor { + + /** + * Executes given send beacon request. + * @throws [IOException] during network communication or parsing error. + */ + @WorkerThread + @Throws(IOException::class) + fun execute(request: SendBeaconRequest): SendBeaconResponse +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconResponse.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconResponse.kt new file mode 100644 index 000000000..8b0c3cbf3 --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconResponse.kt @@ -0,0 +1,17 @@ +package com.yandex.android.beacon + +/** + * Send beacon network response primitive. + */ +interface SendBeaconResponse { + + /** + * HTTP response code. + */ + val responseCode: Int + + /** + * Whether response is valid. + */ + fun isValid(): Boolean +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconWorker.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconWorker.kt new file mode 100644 index 000000000..b81f7d3c5 --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconWorker.kt @@ -0,0 +1,41 @@ +package com.yandex.android.beacon + +import androidx.annotation.AnyThread +import androidx.annotation.MainThread + +/** + * Send beacon worker interface definition. + */ +interface SendBeaconWorker { + + /** + * Starts worker. + * + * @param callback callback to call after worker finishes its routine. + * @return `true`, if worker started, `false` if already working on its tasks. + */ + @MainThread + fun onStart(callback: Callback): Boolean + + /** + * Stops worker. + * + * @return `true`, if worker started, `false` if already working on its tasks. + */ + @MainThread + fun onStop(): Boolean + + /** + * Callback to pass to [.onStart] in order to receive result notification. + */ + fun interface Callback { + /** + * Notifies about current beacons batch processing is finished. + * + * @param needsReschedule `true`, if some beacons was not sent, + * `false` otherwise. + */ + @AnyThread + fun finish(needsReschedule: Boolean) + } +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconWorkerImpl.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconWorkerImpl.kt new file mode 100644 index 000000000..6f6cce095 --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconWorkerImpl.kt @@ -0,0 +1,336 @@ +package com.yandex.android.beacon + +import android.content.Context +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import com.yandex.android.beacon.BeaconItem.NonPersistent +import com.yandex.android.net.CookieStorage +import com.yandex.div.core.util.Assert +import com.yandex.div.core.util.Log +import com.yandex.div.util.Clock +import com.yandex.div.util.SingleThreadExecutor +import org.json.JSONObject +import java.io.IOException +import java.util.ArrayDeque +import java.util.Deque +import java.util.concurrent.Executor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * A base of {@link SendBeaconManager} subsystem. + *

    + * It is driven by {@link #onStart(Callback)} and {@link #onStop()} methods plus + * it can try to send the request one time right away, given that some network-using activity + * is responsible for this. The request are stores in SQL DB and will be retried also after + * the app restart. There is a time expiration for requests that should help us to get rid of + * malformed urls that are never to be successfully delivered. + *

    + * All real work is done in a 'worker thread', which is a synthetic over a multi-thread executor. + * If Android requires the work be stopped, it happens in-between sending requests, there is not + * cancelling of a request already started. + */ +internal class SendBeaconWorkerImpl( + private val context: Context, + private val configuration: SendBeaconConfiguration +) : SendBeaconWorker { + + private val workerThreadExecutor = WorkerThreadExecutor(configuration.executor) + private val implThread = ImplThread() + private val runningJob = AtomicReference(null) + + private val requestExecutor: SendBeaconRequestExecutor + get() = configuration.requestExecutor + + private val hostCallback: SendBeaconWorkerScheduler + get() = configuration.workerScheduler + + private val extraLogger: SendBeaconPerWorkerLogger + get() = configuration.perWorkerLogger + + // Update from worker thread + @Volatile + private var hasMoreWork: Boolean? = null + + init { + Log.d(TAG, "SendBeaconWorker created") + } + + /** + * @param tryImmediately true if we want to send the url without starting a service. + */ + fun add(url: Uri, headers: Map, payload: JSONObject?, tryImmediately: Boolean) { + Log.d(TAG, "Adding url $url") + workerThreadExecutor.post { + implThread.addUrl(url, headers, payload, tryImmediately) + } + } + + fun addNonPersistentUrl( + url: Uri, + headers: Map, + cookieStorage: CookieStorage, + payload: JSONObject?, + tryImmediately: Boolean + ) { + Log.d(TAG, "Adding non persistent url $url") + workerThreadExecutor.post { + implThread.addNonPersistentUrl(url, headers, cookieStorage, payload, tryImmediately) + } + } + + override fun onStart(callback: SendBeaconWorker.Callback): Boolean { + Log.d(TAG, "Starting job") + + if (hasMoreWork == false) { + Log.d(TAG, "Starting job, return false") + return false + } + val newJob = RunningJob(callback) + val previousJob = runningJob.getAndSet(newJob) + Assert.assertNull(previousJob) + + workerThreadExecutor.post { + implThread.executeJob(newJob) + } + Log.d(TAG, "Starting job, return true") + return true + } + + override fun onStop(): Boolean { + Log.d(TAG, "Stopping job") + + // Android may or may not call stopJob if we called jobFinished. + + // First stop the job, then check if it has more work. Otherwise the worker thread can + // add declare work (which we miss) and not schedule, because the job is still running. + runningJob.set(null) + + // Our returning true here – is it going to overpower a previous back-off + // request from other thread? + val result = hasMoreWork != false + Log.d(TAG, "Stopping job: $result") + return result + } + + private class WorkerThreadExecutor( + executor: Executor + ) : SingleThreadExecutor(executor, "SendBeacon") { + + override fun handleError(e: RuntimeException) = Unit // TODO + } + + private class RunningJob(private val callback: SendBeaconWorker.Callback) { + + fun sendFinishToCallback(backingOff: Boolean) { + callback.finish(backingOff) + } + } + + @WorkerThread + private inner class ImplThread { + + private val workerData by lazy { WorkerData(context, configuration.databaseName) } + + fun addUrl(url: Uri, headers: Map, payload: JSONObject?, tryImmediately: Boolean) { + val nowMs = Clock.get().currentTimeMs + + // Initialization can be in this call. + val beaconData = workerData.push(url, headers, nowMs, payload) + addBeaconItem(tryImmediately, workerData, beaconData) + } + + fun addNonPersistentUrl( + url: Uri, + headers: Map, + cookieStorage: CookieStorage, + payload: JSONObject?, + tryImmediately: Boolean + ) { + val nowMs = Clock.get().currentTimeMs + + // Initialization can be in this call. + val beaconData = workerData.pushNonPersistent(url, headers, nowMs, cookieStorage, payload) + addBeaconItem(tryImmediately, workerData, beaconData) + } + + private fun addBeaconItem(tryImmediately: Boolean, workerData: WorkerData, beaconData: BeaconItem) { + if (tryImmediately) { + val sendResult = sendItem(beaconData) + if (sendResult) { + workerData.pop() + return + } + } + + val job = runningJob.get() + if (job == null) { + hostCallback.schedule(this@SendBeaconWorkerImpl) + } else { + // Job is already scheduled from the main thread, but wasn't yet executed here. + // No need for schedule. + } + } + + fun executeJob(job: RunningJob) { + try { + proceedJobImpl() + } finally { + val changed: Boolean = runningJob.compareAndSet(job, null) + if (changed) { + val backingOff: Boolean + if (hasMoreWork == false) { + Log.d(TAG, "Finishing job") + backingOff = false + } else { + Log.d(TAG, "Giving up in the end") + backingOff = true + } + job.sendFinishToCallback(backingOff) + } + } + } + + private fun proceedJobImpl() { + // Initialization can be in this call. + val nowMs = Clock.get().currentTimeMs + + val it = workerData.iterator() + while (it.hasNext()) { + val item = it.next() + if (runningJob.get() == null) return + + if (item.addTimestamp + URL_EXPIRE_PERIOD_MS < nowMs) { + Log.w(TAG, "Drop outdated url: " + item.url) + it.remove() + continue + } + + Log.d(TAG, "Trying to send " + item.url) + val sendResult = sendItem(item) + Log.d(TAG, "Trying to send, result $sendResult") + if (sendResult) { + it.remove() + } + } + } + + private fun sendItem(beaconData: BeaconItem): Boolean { + val request = SendBeaconRequest.from(beaconData) + val url = beaconData.url + + val s = request.url.toString() + extraLogger.onTrySendUrl(s) + + return try { + val response = requestExecutor.execute(request) + when { + response.isValid() -> { + extraLogger.onSuccessSendUrl(s) + Log.d(TAG, "Sent url ok $url") + true + } + + is5xxHttpCode(response) -> { + extraLogger.onFailedSendUrlDueServerError(s) + Log.e(TAG, "Failed to send url $url, but treat as sent.") + true + } + + else -> { + extraLogger.onFailedSendUrl(s, false) + Log.e(TAG, "Failed to send url $url") + false + } + } + } catch (e: IOException) { + extraLogger.onFailedSendUrl(s, true) + Log.e(TAG, "Failed to send url $url", e) + false + } + } + + private fun is5xxHttpCode(response: SendBeaconResponse): Boolean { + return response.responseCode / 100 == 5 + } + } + + @WorkerThread + private inner class WorkerData( + context: Context, + databaseName: String + ): Iterable { + + private val db = SendBeaconDb.factory.create(context, databaseName) + private val itemCache: Deque = ArrayDeque(db.allItems()) + + init { + Log.e(TAG, "Reading from database, items count: " + itemCache.size) + updateHasMoreWork() + } + + fun push(url: Uri, headers: Map, nowMs: Long, payload: JSONObject?): BeaconItem { + val item = db.add(url, headers, nowMs, payload) + itemCache.push(item) + updateHasMoreWork() + return item + } + + fun pushNonPersistent( + url: Uri, + headers: Map, + nowMs: Long, + cookieStorage: CookieStorage, + payload: JSONObject? + ): BeaconItem { + val item = NonPersistent(url, headers, payload, nowMs, cookieStorage) + itemCache.push(item) + updateHasMoreWork() + return item + } + + fun pop() { + val item = itemCache.pop() + db.remove(item.asPersistent()) + updateHasMoreWork() + } + + override fun iterator(): MutableIterator { + val it = itemCache.iterator() + return object : MutableIterator { + + var last: BeaconItem? = null + + override fun hasNext(): Boolean { + return it.hasNext() + } + + override fun next(): BeaconItem { + val item = it.next() + last = item + return item + } + + override fun remove() { + it.remove() + db.remove(last?.asPersistent()) + updateHasMoreWork() + } + } + } + + private fun updateHasMoreWork() { + hasMoreWork = !itemCache.isEmpty() + } + } + + companion object { + + private const val TAG = "SendBeaconWorker" + + @VisibleForTesting + @JvmField + internal val URL_EXPIRE_PERIOD_MS = TimeUnit.DAYS.toMillis(1) // 1 day. + } +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconWorkerScheduler.kt b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconWorkerScheduler.kt new file mode 100644 index 000000000..8f5c97c5c --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/beacon/SendBeaconWorkerScheduler.kt @@ -0,0 +1,12 @@ +package com.yandex.android.beacon + +import androidx.annotation.AnyThread + +/** + * Callback to schedule worker's new routine. + */ +interface SendBeaconWorkerScheduler { + + @AnyThread + fun schedule(worker: SendBeaconWorker) +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/net/CookieStorage.kt b/client/android/beacon/src/main/java/com/yandex/android/net/CookieStorage.kt new file mode 100644 index 000000000..4836c2da8 --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/net/CookieStorage.kt @@ -0,0 +1,21 @@ +package com.yandex.android.net + +import android.net.Uri + +/** + * Provides cookies for outgoing requests. Usually the implementation will wrap Android's CookieManager. + */ +interface CookieStorage { + + fun getCookies(uri: Uri): String? + + /** + * Accepts cookies into the storage. + */ + fun processCookies(cookieValues: List?, url: String) + + object NoOp : CookieStorage { + override fun getCookies(uri: Uri): String? = null + override fun processCookies(cookieValues: List?, url: String) = Unit + } +} diff --git a/client/android/beacon/src/main/java/com/yandex/android/util/Serialization.kt b/client/android/beacon/src/main/java/com/yandex/android/util/Serialization.kt new file mode 100644 index 000000000..2a2bbc575 --- /dev/null +++ b/client/android/beacon/src/main/java/com/yandex/android/util/Serialization.kt @@ -0,0 +1,44 @@ +@file:JvmName("SerializationUtils") + +package com.yandex.android.util + +import androidx.collection.ArrayMap +import com.yandex.div.core.util.Assert + +private const val MAP_KEY_VALUE_DELIMITER = '\t' +private const val MAP_KEY_VALUE_PAIR_DELIMITER = '\u0000' + +internal fun Map.serialize(): String? { + if (isEmpty()) return null + + val stringBuilder = StringBuilder() + forEach { (key, value) -> + stringBuilder.append(key) + .append(MAP_KEY_VALUE_DELIMITER) + .append(value) + .append(MAP_KEY_VALUE_PAIR_DELIMITER) + } + return stringBuilder.toString() +} + +internal fun String?.deserializeMap(): Map { + if (this == null) return emptyMap() + + val pairs: List = split(MAP_KEY_VALUE_PAIR_DELIMITER) + if (pairs.isEmpty()) { + Assert.fail("Incorrect serialization: empty map should be serialized into null value!") + return emptyMap() + } + + val result: MutableMap = ArrayMap(pairs.size) + for (i in pairs.indices) { + val keyAndValue = pairs[i].split(MAP_KEY_VALUE_DELIMITER) + if (keyAndValue.size == 1) { + result[keyAndValue[0]] = "" + } else { + result[keyAndValue[0]] = keyAndValue[1] + } + } + + return result +} diff --git a/client/android/beacon/src/test/java/com/yandex/android/TestExecutor.kt b/client/android/beacon/src/test/java/com/yandex/android/TestExecutor.kt new file mode 100644 index 000000000..e2e67d9bd --- /dev/null +++ b/client/android/beacon/src/test/java/com/yandex/android/TestExecutor.kt @@ -0,0 +1,28 @@ +package com.yandex.android + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import java.util.concurrent.Executor + +internal class TestExecutor : Executor { + + val commands = mutableListOf() + + override fun execute(r: Runnable) { + commands.add(r) + } + + fun runAll() { + val commandsCopy = ArrayList(commands) + commands.clear() + commandsCopy.forEach { command -> + command.run() + } + } + + fun runAllOnce() { + assertFalse(commands.isEmpty()) + runAll() + assertTrue(commands.isEmpty()) + } +} diff --git a/client/android/beacon/src/test/java/com/yandex/android/beacon/OkSendBeaconResponse.kt b/client/android/beacon/src/test/java/com/yandex/android/beacon/OkSendBeaconResponse.kt new file mode 100644 index 000000000..279104e1c --- /dev/null +++ b/client/android/beacon/src/test/java/com/yandex/android/beacon/OkSendBeaconResponse.kt @@ -0,0 +1,10 @@ +package com.yandex.android.beacon + +internal class OkSendBeaconResponse : SendBeaconResponse { + + override val responseCode = 200 + + override fun isValid(): Boolean { + return true + } +} diff --git a/client/android/beacon/src/test/java/com/yandex/android/beacon/SendBeaconWorkerTest.kt b/client/android/beacon/src/test/java/com/yandex/android/beacon/SendBeaconWorkerTest.kt new file mode 100644 index 000000000..86ebef878 --- /dev/null +++ b/client/android/beacon/src/test/java/com/yandex/android/beacon/SendBeaconWorkerTest.kt @@ -0,0 +1,248 @@ +package com.yandex.android.beacon + +import android.net.Uri +import com.yandex.android.TestExecutor +import com.yandex.android.beacon.BeaconItem.Persistent +import com.yandex.div.util.Clock +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.io.IOException + +@RunWith(RobolectricTestRunner::class) +class SendBeaconWorkerTest { + + private val context = RuntimeEnvironment.application + private val requestExecutor = TestSendBeaconRequestExecutor() + private val executor = TestExecutor() + private val hostCallback = mock() + private val logger = mock() + + private val goUrl = mock { on { host } doReturn GO_DOMAIN } + private val noGoUrl = mock { on { host } doReturn NO_GO_DOMAIN } + + private var now = 0L + private val realClock = Clock.get() + private val mockClock = mock { on { currentTimeMs } doAnswer { now } } + + private var testDb = TestSendBeaconDb(context) + private val realDbFactory = SendBeaconDb.factory + private val testDbFactory = SendBeaconDb.Factory { _, _ -> testDb } + + private lateinit var worker: SendBeaconWorkerImpl + + @Before + fun setUp() { + SendBeaconDb.factory = testDbFactory + Clock.setForTests(mockClock) + + worker = SendBeaconWorkerImpl( + context = context, + SendBeaconConfiguration( + requestExecutor = requestExecutor, + executor = executor, + workerScheduler = hostCallback, + perWorkerLogger = logger, + databaseName = TestSendBeaconDb.DB_NAME + ) + ) + } + + @After + fun tearDown() { + SendBeaconDb.factory = realDbFactory + Clock.setForTests(realClock) + } + + @Test + fun `try immediately`() { + worker.add(goUrl, emptyMap(), null, true) + + assertFalse(executor.commands.isEmpty()) + assertTrue(requestExecutor.sentOut.isEmpty()) + + executor.runAll() + + assertTrue(requestExecutor.sentOut.contains(goUrl)) + assertTrue(testDb.items.isEmpty()) + verify(hostCallback, never()).schedule(eq(worker)) + } + + @Test + fun `schedule for service`() { + requestExecutor.badDomains.add(NO_GO_DOMAIN) + + worker.add(goUrl, emptyMap(), null, false) + worker.add(noGoUrl, emptyMap(), null, false) + + assertFalse(executor.commands.isEmpty()) + assertTrue(requestExecutor.sentOut.isEmpty()) + verify(hostCallback, never()).schedule(eq(worker)) + + executor.runAllOnce() + + assertEquals(2, testDb.items.size) + verify(hostCallback, times(2)).schedule(eq(worker)) + + val job1Callback = mock() + var startResult = worker.onStart(job1Callback) + assertTrue(startResult) + + requestExecutor.ioException = IOException("No network") + executor.runAllOnce() + + assertFalse(requestExecutor.sentOut.contains(goUrl)) + assertEquals(2, testDb.items.size) + verify(job1Callback, times(1)).finish(true) + + val job2Callback = mock() + startResult = worker.onStart(job2Callback) + assertTrue(startResult) + + requestExecutor.ioException = null + executor.runAllOnce() + + assertEquals(1, requestExecutor.sentOut.size) + assertEquals(1, testDb.items.size) + + // Sometimes we call it, sometimes not. Imitating race conditions. + var stopResult = worker.onStop() + assertTrue(stopResult) + + verify(job2Callback, times(1)).finish(true) + verify(job2Callback, times(1)).finish(anyBoolean()) + + val job3Callback = mock() + startResult = worker.onStart(job3Callback) + assertTrue(startResult) + + requestExecutor.badDomains.clear() + executor.runAllOnce() + + assertEquals(2, requestExecutor.sentOut.size) + assertEquals(0, testDb.items.size) + verify(job3Callback, times(1)).finish(false) + verify(job3Callback, times(1)).finish(anyBoolean()) + + stopResult = worker.onStop() + assertFalse(stopResult) + + assertTrue(requestExecutor.sentOut.contains(noGoUrl)) + verify(job1Callback, times(1)).finish(anyBoolean()) + } + + @Test + fun `start stop start`() { + worker.add(goUrl, emptyMap(), null, false) + worker.add(noGoUrl, emptyMap(), null, false) + + assertFalse(executor.commands.isEmpty()) + assertTrue(requestExecutor.sentOut.isEmpty()) + verify(hostCallback, never()).schedule(eq(worker)) + + executor.runAllOnce() + + assertEquals(2, testDb.items.size) + verify(hostCallback, times(2)).schedule(eq(worker)) + + val job1Callback = mock() + var startResult = worker.onStart(job1Callback) + assertTrue(startResult) + + val stopResult = worker.onStop() + assertTrue(stopResult) + + executor.runAllOnce() + + assertTrue(requestExecutor.sentOut.isEmpty()) + assertEquals(2, testDb.items.size) + verify(job1Callback, never()).finish(anyBoolean()) + + val job2Callback = mock() + startResult = worker.onStart(job2Callback) + assertTrue(startResult) + + requestExecutor.postSendHook = Runnable { worker.onStop() } + executor.runAllOnce() + + assertEquals(1, requestExecutor.sentOut.size) + assertEquals(1, testDb.items.size) + verify(job1Callback, never()).finish(anyBoolean()) + verify(job2Callback, never()).finish(anyBoolean()) + } + + @Test + fun `after restart and expire`() { + val baselineTime = 200000L + val expirePeriodMs = SendBeaconWorkerImpl.URL_EXPIRE_PERIOD_MS + + testDb.items.apply { + add(Persistent(goUrl, emptyMap(), null, baselineTime, 0)) + add(Persistent(noGoUrl, emptyMap(), null, baselineTime + expirePeriodMs / 2, 1)) + } + + now = baselineTime + (expirePeriodMs * 0.75).toLong() + + requestExecutor.ioException = IOException("No network") + + val job1Callback = mock() + var startResult = worker.onStart(job1Callback) + assertTrue(startResult) + + executor.runAllOnce() + + assertEquals(2, testDb.items.size) + verify(job1Callback, times(1)).finish(true) + verify(job1Callback, times(1)).finish(anyBoolean()) + + now = baselineTime + (expirePeriodMs * 1.25).toLong() + + val job2Callback = mock() + startResult = worker.onStart(job2Callback) + assertTrue(startResult) + + executor.runAllOnce() + + assertEquals(1, testDb.items.size) + verify(job2Callback, times(1)).finish(true) + verify(job2Callback, times(1)).finish(anyBoolean()) + + requestExecutor.ioException = null + + val job3Callback = mock() + startResult = worker.onStart(job3Callback) + assertTrue(startResult) + + executor.runAllOnce() + + verify(job3Callback, times(1)).finish(false) + verify(job3Callback, times(1)).finish(anyBoolean()) + + val job4Callback = mock() + startResult = worker.onStart(job4Callback) + assertFalse(startResult) + + assertTrue(executor.commands.isEmpty()) + verify(job4Callback, times(0)).finish(anyBoolean()) + } + + private companion object { + + private const val GO_DOMAIN = "yandex.ru" + private const val NO_GO_DOMAIN = "yandex.nogo" + } +} diff --git a/client/android/beacon/src/test/java/com/yandex/android/beacon/TestSendBeaconDb.kt b/client/android/beacon/src/test/java/com/yandex/android/beacon/TestSendBeaconDb.kt new file mode 100644 index 000000000..65bbfe125 --- /dev/null +++ b/client/android/beacon/src/test/java/com/yandex/android/beacon/TestSendBeaconDb.kt @@ -0,0 +1,29 @@ +package com.yandex.android.beacon + +import android.content.Context +import android.net.Uri +import com.yandex.android.beacon.BeaconItem.Persistent +import org.json.JSONObject + +internal class TestSendBeaconDb(context: Context) : SendBeaconDb(context, DB_NAME) { + + val items = mutableListOf() + + override fun allItems(): List { + return ArrayList(items) + } + + override fun add(url: Uri, headers: Map, addTimestamp: Long, payload: JSONObject?): Persistent { + val item = Persistent(url, headers, payload, addTimestamp, 0) + items.add(item) + return item + } + + override fun remove(item: Persistent?): Boolean { + return items.remove(item) + } + + companion object { + const val DB_NAME = "testBeacon.db" + } +} diff --git a/client/android/beacon/src/test/java/com/yandex/android/beacon/TestSendBeaconRequestExecutor.kt b/client/android/beacon/src/test/java/com/yandex/android/beacon/TestSendBeaconRequestExecutor.kt new file mode 100644 index 000000000..7af9ebe07 --- /dev/null +++ b/client/android/beacon/src/test/java/com/yandex/android/beacon/TestSendBeaconRequestExecutor.kt @@ -0,0 +1,33 @@ +package com.yandex.android.beacon + +import android.net.Uri +import androidx.annotation.WorkerThread +import java.io.IOException + +internal class TestSendBeaconRequestExecutor : SendBeaconRequestExecutor { + + val badDomains = mutableSetOf() + val sentOut = mutableListOf() + + var ioException: IOException? = null + var postSendHook: Runnable? = null + + @WorkerThread + @Throws(IOException::class) + override fun execute(request: SendBeaconRequest): SendBeaconResponse { + ioException?.let { throw it } + + if (badDomains.contains(request.url.host)) { + throw IOException("Bad domain") + } + + sentOut.add(request.url) + if (postSendHook != null) { + val hook = postSendHook + postSendHook = null + hook?.run() + } + + return OkSendBeaconResponse() + } +} diff --git a/client/android/beacon/src/test/resources/robolectric.properties b/client/android/beacon/src/test/resources/robolectric.properties new file mode 100644 index 000000000..8bfbb3c55 --- /dev/null +++ b/client/android/beacon/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=21 diff --git a/client/android/build.gradle b/client/android/build.gradle new file mode 100644 index 000000000..f2246921a --- /dev/null +++ b/client/android/build.gradle @@ -0,0 +1,176 @@ +import com.yandex.div.gradle.PublicationType + +apply from: "$projectDir/version.gradle" +apply from: "$projectDir/dependencies.gradle" + +def publicationType = PublicationType.fromString(project.findProperty("publicationType")) + +ext { + minSdkVersion = 21 + compileSdkVersion = 33 + targetSdkVersion = 31 + buildToolsVersion = "33.0.0" + + divkitVersionName = "${divkitVersion.versionName}${publicationType.getVersionSuffix(project.properties)}" +} + +buildscript { + + apply from: "$projectDir/dependencies.gradle" + apply from: "$projectDir/repositories.gradle", to: buildscript + + dependencies { + classpath "com.android.tools.build:gradle:$versions.gradlePlugin" + classpath "com.asarkar.gradle:build-time-tracker:3.0.1" + classpath "com.yandex.android:appmetrica-build-plugin:$versions.metricaCrashPlugin" + classpath "com.yandex.mobile.perftests:perftests-plugin:1.3.3" + classpath "com.yandex.mobile.tools:upload-beta:$versions.uploadBeta" + classpath "com.yandex.mobile.tools:yandex-signer:$versions.signer" + classpath "com.yandex.test:screenshot-test-plugin:1.0.0" + classpath "org.jetbrains.dokka:dokka-gradle-plugin:$versions.kotlin" + classpath "org.jetbrains.kotlin:kotlin-allopen:$versions.kotlin" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin" + classpath 'com.getkeepsafe.dexcount:dexcount-gradle-plugin:3.0.1' + } +} + +allprojects { + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + freeCompilerArgs = ['-Xjvm-default=all'] + languageVersion = "1.5" + apiVersion = "1.5" + } + } + + repositories { + maven { url 'https://artifactory.yandex.net/alicekit' } + maven { url 'https://artifactory.yandex.net/google-android' } + maven { url 'https://artifactory.yandex.net/central' } + google() + mavenCentral() + } +} + +subprojects { + tasks.withType(Test) { + // Do not use parallel forks, because profit from parallel execution is eaten by Robolectric initialization in every test process + maxParallelForks = 1 + maxHeapSize = "4g" + } + + configurations.all { + exclude group: 'com.intellij', module: 'annotations' + } + + afterEvaluate { + if (plugins.hasPlugin("maven-publish")) { + project.tasks.withType(PublishToMavenRepository) { task -> + teamcityPublish.dependsOn(task) + sandboxRelease.dependsOn(task) + } + } + + if (plugins.hasPlugin("jacoco")) { + project.tasks.named('jacocoTestReport') { task -> + unitTests.dependsOn(task) + } + } + + if (project.hasProperty('teamcity.version')) { + project.tasks.withType(PublishToMavenRepository) { task -> + task.finalizedBy(reportBuildNumberToTeamcity) + } + } + + project.tasks.withType(PublishToMavenLocal) { task -> + task.finalizedBy(reportVersion) + } + } +} + +apply plugin: 'com.asarkar.gradle.build-time-tracker' + +buildTimeTracker { + minTaskDuration = Duration.ofSeconds(1) + sort = true + showBars = false +} + +wrapper { + gradleVersion "7.4.1" + distributionType = Wrapper.DistributionType.ALL + distributionUrl "https://artifactory.yandex.net/artifactory/gradle-distributions/distributions/gradle-$gradleVersion-all.zip" +} + +task reportVersion { + doLast { + println "DivKit version ${rootProject.property('divkitVersionName')}" + } +} + +task reportBuildNumberToTeamcity { + doLast { + println "##teamcity[buildNumber \'${rootProject.property('divkitVersionName')}\']" + } +} + +task assembleDemoDebug { + dependsOn ':divkit-demo-app:assembleDebug' +} + +task assembleDemoDebugBundle { + dependsOn ':divkit-demo-app:bundleDebug' +} + +task assembleDemoRelease { + dependsOn ':divkit-demo-app:assembleRelease' +} + +task assembleDemoReleaseBundle { + dependsOn ':divkit-demo-app:bundleRelease' +} + +task uploadBeta { + dependsOn ':divkit-demo-app:uploadBeta' +} + +task unitTests + +task finalVerification { + dependsOn unitTests +} + +task teamcityPullRequests { + dependsOn assembleDemoDebug, assembleDemoRelease + finalizedBy finalVerification +} + +task teamcityDev { + dependsOn assembleDemoDebug, assembleDemoRelease + finalizedBy finalVerification, uploadBeta +} + +task teamcityPublish { + dependsOn assembleDemoRelease + finalizedBy finalVerification +} + +task sandboxRelease { + dependsOn assembleDemoRelease, assembleDemoReleaseBundle + finalizedBy finalVerification, uploadBeta +} + +apply plugin: 'perfTests' + +task assemblePerf { + dependsOn ':divkit-demo-app:assemblePerfTests', ':divkit-perftests:assemblePerfTests' +} + +setupHPE { + perfRoot = file("${buildDir.absolutePath}/div/perf") +} + +task teamcityPerf { + dependsOn assemblePerf +} diff --git a/client/android/buildSrc/build.gradle b/client/android/buildSrc/build.gradle new file mode 100644 index 000000000..bdf8cde6f --- /dev/null +++ b/client/android/buildSrc/build.gradle @@ -0,0 +1,36 @@ +apply plugin: 'groovy' +apply plugin: 'kotlin' + +apply from: "${project.projectDir}/../dependencies.gradle" + +repositories { + maven { url 'https://artifactory.yandex.net/google-android' } + maven { url 'https://artifactory.yandex.net/central' } + google() + mavenCentral() +} + +dependencies { + implementation gradleApi() + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$versions.kotlin" + implementation "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin" +} + +buildscript { + apply from: "${project.projectDir}/../dependencies.gradle" + + repositories { + maven { url 'https://artifactory.yandex.net/central' } + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin" + } +} + +wrapper { + gradleVersion "7.4.1" + distributionType = Wrapper.DistributionType.ALL + distributionUrl "https://artifactory.yandex.net/artifactory/gradle-distributions/distributions/gradle-$gradleVersion-all.zip" +} diff --git a/client/android/buildSrc/src/main/java/com/yandex/div/gradle/PublicationType.kt b/client/android/buildSrc/src/main/java/com/yandex/div/gradle/PublicationType.kt new file mode 100644 index 000000000..bdaa68a73 --- /dev/null +++ b/client/android/buildSrc/src/main/java/com/yandex/div/gradle/PublicationType.kt @@ -0,0 +1,41 @@ +package com.yandex.div.gradle + +import java.text.SimpleDateFormat +import java.util.Date + +private const val RELEASE_ARTIFACTORY_URL = "https://artifactory.yandex.net/artifactory/yandex_mobile_releases/" +private const val SNAPSHOT_ARTIFACTORY_URL = "https://artifactory.yandex.net/artifactory/yandex_mobile_snapshots/" + +private val VERSION_DATE_FORMAT = SimpleDateFormat("yyyyMMdd.HHmmss") + +enum class PublicationType(val artifactoryUrl: String) { + + dev(SNAPSHOT_ARTIFACTORY_URL) { + override fun getVersionSuffix(properties: Map): String { + val now = Date() + return "-dev.${VERSION_DATE_FORMAT.format(now)}" + } + }, + + release(RELEASE_ARTIFACTORY_URL) { + override fun getVersionSuffix(properties: Map) = "" + }; + + // TODO: delete unused parameter + abstract fun getVersionSuffix(properties: Map): String + + companion object { + + @JvmStatic + fun fromString(string: String?): PublicationType { + return try { + valueOf(string!!) + } catch (ignored: IllegalArgumentException) { + return dev + } catch (ignored: NullPointerException) { + return dev + } + } + } + +} diff --git a/client/android/buildSrc/src/main/java/com/yandex/div/gradle/Version.kt b/client/android/buildSrc/src/main/java/com/yandex/div/gradle/Version.kt new file mode 100644 index 000000000..dad6762bc --- /dev/null +++ b/client/android/buildSrc/src/main/java/com/yandex/div/gradle/Version.kt @@ -0,0 +1,43 @@ +package com.yandex.div.gradle + +import org.gradle.api.Project + +class Version private constructor( + val majorVersion: Int, + val minorVersion: Int, + val fixVersion: Int +) { + + val versionCode = computeVersionCode(majorVersion, minorVersion, fixVersion) + val versionName = "$majorVersion.$minorVersion.$fixVersion" + + var buildNumber = 0 + private set + + constructor( + project: Project, + majorVersion: Int, + minorVersion: Int, + fixVersion: Int + ) : this(majorVersion, minorVersion, fixVersion) { + if (project.hasProperty("teamcity.version")) { + buildNumber = Integer.parseInt(project.properties["build.number"] as String) + } else { + val tsrBuildNumber = System.getenv("BUILD_NUMBER") + buildNumber = tsrBuildNumber?.toInt() ?: 0 + } + } + + companion object { + + fun computeVersionCode(majorVersion: Int, minorVersion: Int, fixVersion: Int): Int { + if (minorVersion !in 0..999) { + throw IllegalArgumentException ("Minor version value must be between 0 and 999") + } + if (fixVersion !in 0..999) { + throw IllegalArgumentException ("Fix version value must be between 0 and 999") + } + return majorVersion * 1_000_000 + minorVersion * 1_000 + fixVersion + } + } +} diff --git a/client/android/dependencies.gradle b/client/android/dependencies.gradle new file mode 100644 index 000000000..9083c0cf3 --- /dev/null +++ b/client/android/dependencies.gradle @@ -0,0 +1,53 @@ +ext.versions = [ + anko : '0.10.8', + coroutines : '1.6.0', + dagger : '2.38', + exoPlayer : '2.14.2', + festAndroid : '1.0.8', + festReflect : '1.4.1', + googleAutoService : '1.0', + gradlePlugin : '7.0.1', + gson : '2.8.5', + hamcrestJUnit : '2.0.0.0', + hamcrestLibrary : '2.2', + jacoco : '0.8.7', + javax : '1', + json : '20211205', + junit : '4.12', + kotlin : '1.6.10', + kotlinpoet : '1.4.4', + metrica : '5.0.0', + metricaCrashPlugin : '0.6.1', + mockito : '3.12.4', + mockitoKotlin : '3.2.0', + okhttp : '4.9.1', + passport : '7.26.4', + perftests : '1.6.2', + picasso : '2.8', + pulse : '1.0.10', + robolectric : '4.4', + signer : '4.7.4', + stetho : '1.5.1', + strikt : '0.28.1', + tinkoffAllure : '2.1.5', + uikit : '0.6.0', + uploadBeta : '3.4.7' +] + +ext.versions.androidx = [ + annotation : '1.1.0', + appcompat : '1.2.0', + browser : '1.2.0', + collection : '1.1.0', + constraint : '2.1.1', + core : '1.7.0', + core_ktx : '1.7.0', + espresso : '3.4.0', + lifecycle : '2.4.0', + material : '1.2.1', + recycler : '1.1.0', + test : '1.4.0', + uiautomator : '2.2.0', + viewpager2 : '1.0.0', + work : '2.7.1' +] diff --git a/client/android/div-application.gradle b/client/android/div-application.gradle new file mode 100644 index 000000000..4bf9fca85 --- /dev/null +++ b/client/android/div-application.gradle @@ -0,0 +1,4 @@ +apply plugin: 'com.android.application' +apply plugin: 'yandex-signer-android' + +apply from: "${buildscript.sourceFile.parent}/div-common.gradle" diff --git a/client/android/div-common.gradle b/client/android/div-common.gradle new file mode 100644 index 000000000..3389e13a9 --- /dev/null +++ b/client/android/div-common.gradle @@ -0,0 +1,57 @@ +apply plugin: "kotlin-android" +apply plugin: "kotlin-kapt" +apply plugin: "kotlin-allopen" + +android { + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdkVersion rootProject.ext.compileSdkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + + versionName divkitVersion.versionName + versionCode divkitVersion.versionCode + + vectorDrawables.useSupportLibrary = true + } + + buildTypes { + debug { + minifyEnabled false + } + release { + minifyEnabled false + consumerProguardFiles 'proguard-rules.pro' + } + } +} + +task sourceJar(type: Jar) { + from android.sourceSets.main.java.srcDirs + classifier "sources" +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$versions.kotlin" +} + +def tanker(String action, String keySet) { + rootProject.exec { + commandLine rootProject.file('buildSrc/src/scripts/tanker/tanker.py').absolutePath + args '--action', action, '--res-path', project.file('src/main/res/'), '--keyset', keySet + }.assertNormalExitValue() +} + +ext { + tanker = this.&tanker +} + +allOpen { + annotation("com.yandex.div.core.annotations.Mockable") +} diff --git a/client/android/div-core-views/YAOWNERS b/client/android/div-core-views/YAOWNERS new file mode 100644 index 000000000..25c4bd74b --- /dev/null +++ b/client/android/div-core-views/YAOWNERS @@ -0,0 +1,11 @@ +gulevsky@yandex-team.ru +tayrinn@yandex-team.ru +jelyscs@yandex-team.ru +syntezzz@yandex-team.ru +anarkh@yandex-team.ru +ttok@yandex-team.ru +i-ts@yandex-team.ru +geaden@yandex-team.ru + +set noparent +set lock diff --git a/client/android/div-core-views/build.gradle b/client/android/div-core-views/build.gradle new file mode 100644 index 000000000..419c67661 --- /dev/null +++ b/client/android/div-core-views/build.gradle @@ -0,0 +1,15 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +dependencies { + // Keep it clean! + implementation project(path: ':assertion') + implementation project(path: ':logging') + implementation project(path: ':utils') + implementation project(path: ':div-core') + + implementation "androidx.appcompat:appcompat:$versions.androidx.appcompat" + implementation "androidx.core:core-ktx:$versions.androidx.core_ktx" + implementation "androidx.recyclerview:recyclerview:$versions.androidx.recycler" +} diff --git a/client/android/div-core-views/consumer-rules.pro b/client/android/div-core-views/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/client/android/div-core-views/jacoco.excludes b/client/android/div-core-views/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-core-views/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-core-views/proguard-rules.pro b/client/android/div-core-views/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/client/android/div-core-views/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/client/android/div-core-views/src/main/AndroidManifest.xml b/client/android/div-core-views/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9a0d11c63 --- /dev/null +++ b/client/android/div-core-views/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/drawables/LinearGradientDrawable.kt b/client/android/div-core-views/src/main/java/com/yandex/div/drawables/LinearGradientDrawable.kt new file mode 100644 index 000000000..2961ca80d --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/drawables/LinearGradientDrawable.kt @@ -0,0 +1,64 @@ +package com.yandex.div.drawables + +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.drawable.Drawable +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * Gradient drawable with different angle. (In android linear gradient drawable angle is multiple 45) + */ +class LinearGradientDrawable( + private val angle: Float, + private val colors: IntArray +) : Drawable() { + + private val paint = Paint() + private var rect = RectF() + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + bounds.let { + paint.shader = createLinearGradient(angle, colors, bounds.width(), bounds.height()) + rect.set(bounds) + } + } + + override fun draw(canvas: Canvas) { + canvas.drawRect(rect, paint) + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + invalidateSelf() + } + + override fun getOpacity(): Int = paint.alpha + + override fun setColorFilter(colorFilter: ColorFilter?) = Unit + + companion object { + + fun createLinearGradient(angle: Float, + colors: IntArray, + width: Int, + height: Int): LinearGradient { + val halfWidth = width / 2 + val halfHeight = height / 2 + val correctedWidth = halfWidth * cos(angle.toRadian()) + val correctedHeight = halfHeight * sin(angle.toRadian()) + return LinearGradient(halfWidth - correctedWidth, halfHeight + correctedHeight, + halfWidth + correctedWidth, halfHeight - correctedHeight, + colors, null, Shader.TileMode.CLAMP) + } + + private fun Float.toRadian() = (this * PI / 180f).toFloat() + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/drawables/ScalingDrawable.kt b/client/android/div-core-views/src/main/java/com/yandex/div/drawables/ScalingDrawable.kt new file mode 100644 index 000000000..fdc078c90 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/drawables/ScalingDrawable.kt @@ -0,0 +1,115 @@ +package com.yandex.div.drawables + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.Drawable + +/** + * Drawable with different scale types anf alignments. Now you can use not only center_crop, but top_crop and others. + * Examples: Fill with alignment center like center_crop in android. Fit left and top - like fit_start in android. + */ +class ScalingDrawable : Drawable() { + + enum class AlignmentHorizontal { + LEFT, CENTER, RIGHT + } + + enum class AlignmentVertical { + TOP, CENTER, BOTTOM + } + + enum class ScaleType { + NO_SCALE, FIT, FILL + } + + var customScaleType = ScaleType.NO_SCALE + var alignmentHorizontal = AlignmentHorizontal.LEFT + var alignmentVertical = AlignmentVertical.TOP + var originalBitmap: Bitmap? = null + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + private var thumbTransformMatrix: Matrix = Matrix() + private var isDirtyRect = false + private var scale = 1f + private var xTranslate = 0f + private var yTranslate = 0f + + override fun setAlpha(value: Int) { + paint.alpha = value + invalidateSelf() + } + + override fun getOpacity(): Int = paint.alpha + + fun setBitmap(bitmap: Bitmap) { + originalBitmap = bitmap + isDirtyRect = true + invalidateSelf() + } + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + isDirtyRect = true + invalidateSelf() + } + + override fun setColorFilter(colorFilter: ColorFilter?) = Unit + + override fun draw(canvas: Canvas) { + canvas.save() + + // pre drawing + val drawableWidth = originalBitmap?.width ?: 0 + val drawableHeight = originalBitmap?.height ?: 0 + + if (drawableHeight <= 0 || drawableWidth <= 0) { + originalBitmap?.let { + canvas.drawBitmap(it, thumbTransformMatrix, paint) + } + canvas.restore() + return + } + + // scaling + if (isDirtyRect) { + val viewWidth = bounds.width().toFloat() + val viewHeight = bounds.height().toFloat() + val horizontalScale = viewWidth / drawableWidth + val verticalScale = viewHeight / drawableHeight + scale = when (customScaleType) { + ScaleType.FILL -> Math.max(horizontalScale, verticalScale) + ScaleType.FIT -> Math.min(horizontalScale, verticalScale) + else -> 1f + } + + // translate + val newWidth = drawableWidth * scale + val newHeight = drawableHeight * scale + xTranslate = when (alignmentHorizontal) { + AlignmentHorizontal.CENTER -> (viewWidth - newWidth) / 2 / scale + AlignmentHorizontal.RIGHT -> (viewWidth - newWidth) / scale + else -> 0f + } + + yTranslate = when (alignmentVertical) { + AlignmentVertical.CENTER -> (viewHeight - newHeight) / 2 / scale + AlignmentVertical.BOTTOM -> (viewHeight - newHeight) / scale + else -> 0f + } + isDirtyRect = false + } + canvas.scale(scale, scale) + canvas.translate(xTranslate , yTranslate) + + // draw original image + originalBitmap?.let { + canvas.drawBitmap(it, thumbTransformMatrix, paint) + } + // restore frame and other + canvas.restore() + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/font/DivTypefaceProvider.java b/client/android/div-core-views/src/main/java/com/yandex/div/font/DivTypefaceProvider.java new file mode 100644 index 000000000..21326fe3c --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/font/DivTypefaceProvider.java @@ -0,0 +1,80 @@ +package com.yandex.div.font; + +import android.graphics.Typeface; +import androidx.annotation.Nullable; +import com.yandex.div.core.annotations.PublicApi; + +/** + * Provides custom font typefaces. + */ +@PublicApi +public interface DivTypefaceProvider { + + DivTypefaceProvider DEFAULT = new DivTypefaceProvider() { + @Nullable + @Override + public Typeface getRegular() { + return null; + } + + @Nullable + @Override + public Typeface getMedium() { + return null; + } + + @Nullable + @Override + public Typeface getLight() { + return null; + } + + @Nullable + @Override + public Typeface getBold() { + return null; + } + + @Nullable + @Override + public Typeface getRegularLegacy() { + return null; + } + }; + + /** + * Returns typeface for text elements that require regular font. + */ + @Nullable + Typeface getRegular(); + + /** + * Returns typeface for text elements that require medium font. + */ + @Nullable + Typeface getMedium(); + + /** + * Returns typeface for text elements that require light font. + */ + @Nullable + Typeface getLight(); + + /** + * Returns typeface for text elements that require bold font. + */ + @Nullable + Typeface getBold(); + + /** + * Returns typeface for text elements that require regular font. + * Also includes italic variant. To be used only in legacy elements. + * + * @deprecated Use {@link #getRegular()} instead for non-italic font. + */ + @Nullable + @Deprecated + default Typeface getRegularLegacy() { + return getRegular(); + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/font/DivTypefaceType.java b/client/android/div-core-views/src/main/java/com/yandex/div/font/DivTypefaceType.java new file mode 100644 index 000000000..9b2bac9a7 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/font/DivTypefaceType.java @@ -0,0 +1,17 @@ +package com.yandex.div.font; + +import android.graphics.Typeface; +import androidx.annotation.NonNull; + +public enum DivTypefaceType { + REGULAR, MEDIUM, BOLD, LIGHT; + + public Typeface getTypeface(@NonNull DivTypefaceProvider typefaceProvider) { + switch (this) { + case BOLD: return typefaceProvider.getBold(); + case MEDIUM: return typefaceProvider.getMedium(); + case LIGHT: return typefaceProvider.getLight(); + default: return typefaceProvider.getRegular(); + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/spannable/BitmapImageSpan.kt b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/BitmapImageSpan.kt new file mode 100644 index 000000000..5069bd2bf --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/BitmapImageSpan.kt @@ -0,0 +1,114 @@ +package com.yandex.div.spannable + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.os.Build +import android.text.style.ReplacementSpan +import androidx.annotation.ColorInt +import com.yandex.div.core.util.Assert +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +/** + * ImageSpan with symbol replacement + */ +class BitmapImageSpan @JvmOverloads constructor( + context: Context, + bitmap: Bitmap, + private val top: Float = 0.0F, + width: Int, + height: Int, + @ColorInt tintColor: Int? = null, + isSquare: Boolean = true, + private val anchorPoint: AnchorPoint = AnchorPoint.LINE_BOTTOM +) : ReplacementSpan() { + + enum class AnchorPoint { + BASELINE, + LINE_BOTTOM + } + + private val drawable: Drawable + + init { + drawable = BitmapDrawable(context.resources, bitmap) + if (isSquare) { + scaleDrawableWithAspectRatio(bitmap, width, height) + } else { + drawable.setBounds(0, 0, width, height) + } + if (tintColor != null) { + drawable.colorFilter = PorterDuffColorFilter(tintColor, PorterDuff.Mode.SRC_IN) + } + } + + private fun scaleDrawableWithAspectRatio(bitmap: Bitmap, width: Int, height: Int) { + val bitmapWidth = bitmap.width + val bitmapHeight = bitmap.height + val widthScaleFactor = if (width > 0) bitmapWidth / width.toFloat() else 1f + val heightScaleFactor = if (height > 0) bitmapHeight / height.toFloat() else 1f + val scaleFactor = Math.max(widthScaleFactor, heightScaleFactor) + drawable.setBounds( + 0, 0, + if (bitmapWidth > 0 && scaleFactor != 0f) (bitmapWidth / scaleFactor).toInt() else 0, + if (bitmapHeight > 0 && scaleFactor != 0f) (bitmapHeight / scaleFactor).toInt() else 0 + ) + } + + override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { + if (fm != null) { + // Workaround for API 27 and less: for unknown reason, + // font metrics have incorrect value if ReplacementSpan is placed + // on first char. Assuming that text is measured from start to end, + // it's safe to reset all metrics during first char measurement. + if (start == 0 && Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + fm.top = 0 + fm.ascent = 0 + fm.bottom = 0 + fm.descent = 0 + fm.leading = 0 + } + + // The code in this file is not ready for non-zero drawable top, consider revising + Assert.assertEquals(drawable.bounds.top, 0) + val imageHeight = drawable.bounds.bottom + val desiredFmTop = when (anchorPoint) { + AnchorPoint.LINE_BOTTOM -> ceil(imageHeight - top - fm.bottom).toInt() + AnchorPoint.BASELINE -> ceil(imageHeight - top).toInt() + } + fm.ascent = min(-desiredFmTop, fm.ascent) + fm.top = min(-desiredFmTop, fm.top) + val desiredFmBottom = when (anchorPoint) { + AnchorPoint.LINE_BOTTOM -> fm.bottom + AnchorPoint.BASELINE -> ceil(top).toInt() + } + fm.descent = max(desiredFmBottom, fm.descent) + fm.bottom = max(desiredFmBottom, fm.bottom) + fm.leading = fm.descent - fm.ascent + } + return drawable.bounds.right + } + + override fun draw( + canvas: Canvas, text: CharSequence, + start: Int, end: Int, x: Float, + top: Int, y: Int, bottom: Int, paint: Paint + ) { + canvas.save() + val anchor = when (anchorPoint) { + AnchorPoint.LINE_BOTTOM -> bottom + AnchorPoint.BASELINE -> y + } + val transY = anchor - drawable.bounds.bottom + this.top + canvas.translate(x, transY) + drawable.draw(canvas) + canvas.restore() + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/spannable/ImagePlaceholderSpan.kt b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/ImagePlaceholderSpan.kt new file mode 100644 index 000000000..c29b5da7a --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/ImagePlaceholderSpan.kt @@ -0,0 +1,46 @@ +package com.yandex.div.spannable + +import android.graphics.Canvas +import android.graphics.Paint +import android.os.Build +import android.text.style.ReplacementSpan +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +/** + * Placeholder image span for reserving space + */ +class ImagePlaceholderSpan constructor( + private val width: Int, + private val height: Int, + private val yOffset: Float = 0f +) : ReplacementSpan() { + override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { + fm?.apply { + if (start == 0 && Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + top = 0 + ascent = 0 + bottom = 0 + descent = 0 + leading = 0 + } + + val desiredFmTop = ceil(height - yOffset).toInt() + fm.ascent = min(-desiredFmTop, fm.ascent) + fm.top = min(-desiredFmTop, fm.top) + val desiredFmBottom = ceil(yOffset).toInt() + fm.descent = max(desiredFmBottom, fm.descent) + fm.bottom = max(desiredFmBottom, fm.bottom) + fm.leading = fm.descent - fm.ascent + } + + return width + } + + override fun draw( + canvas: Canvas, text: CharSequence, + start: Int, end: Int, x: Float, + top: Int, y: Int, bottom: Int, paint: Paint + ) = Unit +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/spannable/LetterSpacingSpan.kt b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/LetterSpacingSpan.kt new file mode 100644 index 000000000..8038c0421 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/LetterSpacingSpan.kt @@ -0,0 +1,20 @@ +package com.yandex.div.spannable + +import android.text.TextPaint +import android.text.style.MetricAffectingSpan + +class LetterSpacingSpan(val letterSpacing: Float) : MetricAffectingSpan() { + + override fun updateDrawState(paint: TextPaint) { + apply(paint) + } + + override fun updateMeasureState(paint: TextPaint) { + apply(paint) + } + + private fun apply(paint: TextPaint) { + paint.letterSpacing = letterSpacing + } + +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/spannable/NoStrikethroughSpan.kt b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/NoStrikethroughSpan.kt new file mode 100644 index 000000000..d90059dfd --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/NoStrikethroughSpan.kt @@ -0,0 +1,11 @@ +package com.yandex.div.spannable + +import android.text.TextPaint +import android.text.style.StrikethroughSpan + +class NoStrikethroughSpan: StrikethroughSpan() { + + override fun updateDrawState(ds: TextPaint) { + ds.isStrikeThruText = false + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/spannable/NoUnderlineSpan.kt b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/NoUnderlineSpan.kt new file mode 100644 index 000000000..9c0ce8153 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/NoUnderlineSpan.kt @@ -0,0 +1,11 @@ +package com.yandex.div.spannable + +import android.text.TextPaint +import android.text.style.UnderlineSpan + +class NoUnderlineSpan: UnderlineSpan() { + + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = false + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/spannable/TypefaceSpan.kt b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/TypefaceSpan.kt new file mode 100644 index 000000000..971880642 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/spannable/TypefaceSpan.kt @@ -0,0 +1,24 @@ +package com.yandex.div.spannable + +import android.graphics.Typeface +import android.text.TextPaint +import android.text.style.MetricAffectingSpan + +/** + * Span for applying text typeface + */ +class TypefaceSpan(val typeface: Typeface) : MetricAffectingSpan() { + + override fun updateDrawState(ds: TextPaint) { + apply(ds) + } + + override fun updateMeasureState(paint: TextPaint) { + apply(paint) + } + + private fun apply(paint: TextPaint) { + paint.typeface = typeface + } + +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/util/CustomInflaterContext.kt b/client/android/div-core-views/src/main/java/com/yandex/div/util/CustomInflaterContext.kt new file mode 100644 index 000000000..92dfd72c8 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/util/CustomInflaterContext.kt @@ -0,0 +1,35 @@ +package com.yandex.div.util + +import android.content.Context +import android.content.ContextWrapper +import android.view.LayoutInflater +import androidx.core.view.LayoutInflaterCompat + +abstract class CustomInflaterContext(baseContext: Context) : ContextWrapper(baseContext) { + + private var inflater: LayoutInflater? = null + + override fun getSystemService(name: String): Any? { + return if (Context.LAYOUT_INFLATER_SERVICE == name) { + getLayoutInflater() + } else baseContext.getSystemService(name) + } + + private fun getLayoutInflater(): LayoutInflater? { + var inflater = this.inflater + if (inflater != null) { + return inflater + } + synchronized(this) { + inflater = this.inflater + if (inflater == null) { + inflater = LayoutInflater.from(baseContext).cloneInContext(this) + LayoutInflaterCompat.setFactory2(inflater as LayoutInflater, createInflaterFactory()) + this.inflater = inflater + } + return inflater + } + } + + abstract fun createInflaterFactory(): LayoutInflater.Factory2 +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/util/DivViewScrollHelper.kt b/client/android/div-core-views/src/main/java/com/yandex/div/util/DivViewScrollHelper.kt new file mode 100644 index 000000000..13dbca690 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/util/DivViewScrollHelper.kt @@ -0,0 +1,106 @@ +package com.yandex.div.util + +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.viewpager.widget.ViewPager +import com.yandex.div.core.views.R + +/** + * Helps to resolve conflicts in nested scrollable views. + */ +object DivViewScrollHelper { + private val SCROLLABLE_RECYCLER_VIEW_IDS = intArrayOf(R.id.div_buttons, R.id.div_gallery) + + /** + * Checks, whether a point is inside a scrollable child + * Should be used with [android.support.v7.widget.helper.FixScrollTouchHelper] + * + * @return true if the point is inside a scrollable child + */ + @JvmStatic + fun ViewGroup.hasScrollableChildUnder(event: MotionEvent): Boolean { + val rawX = event.rawX + val rawY = event.rawY + val scrollDir = if (event.historySize < 1) { + -1 + } else { + if (event.getHistoricalX(0) < event.x) + ItemTouchHelper.LEFT + else + ItemTouchHelper.RIGHT + } + + for (i in 0 until childCount) { + val childAt = getChildAt(i) + if (childAt.id == R.id.div_tabs_block && Views.hitTest(childAt, rawX, rawY) + && (Views.findViewAndCast(childAt, R.id.div_tabs_pager_container) as ViewPager).isScrollableViewPagerUnder(rawX, rawY, scrollDir)) { + return true + } + if (childAt is androidx.recyclerview.widget.RecyclerView && childAt.isScrollableRecyclerUnder(rawX, rawY, scrollDir)) { + return true + } + } + + return false + } + + private fun ViewPager.isScrollableViewPagerUnder(rawX: Float, rawY: Float, scrollDir: Int): Boolean { + if (canScrollMore(scrollDir)) return true + + // this viewpager tab may have scrollable children + + for (v in SCROLLABLE_RECYCLER_VIEW_IDS.indices) { + val recyclerView = Views.findOptionalViewAndCast(this, SCROLLABLE_RECYCLER_VIEW_IDS[v]) + if (recyclerView != null && recyclerView.isScrollableRecyclerUnder(rawX, rawY, scrollDir)) { + return true + } + } + return false + } + + private fun androidx.recyclerview.widget.RecyclerView.isScrollableRecyclerUnder(rawX: Float, rawY: Float, scrollDir: Int): Boolean { + return Views.hitTest(this, rawX, rawY) && canScrollMore(scrollDir) + } + + /** + * Checks whether RecyclerView view can still be scrolled + * + * @param recycler view to check + * @param scrollDir should be one of [ItemTouchHelper.LEFT] etc + * @return true if the view can be scrolled into that direction + */ + private fun androidx.recyclerview.widget.RecyclerView.canScrollMore(scrollDir: Int): Boolean { + if (scrollDir < 0) { // means direction is undefined yet + return true + } + if (scrollDir == ItemTouchHelper.LEFT) { + return computeHorizontalScrollOffset() > 0 + } else if (scrollDir == ItemTouchHelper.RIGHT) { + return computeHorizontalScrollExtent() + computeHorizontalScrollOffset() < computeHorizontalScrollRange() + } + + return false + } + + /** + * Checks whether ViewPager view can still be scrolled + * + * @param viewPager view to check + * @param scrollDir should be one of [ItemTouchHelper.LEFT] etc + * @return true if the view can be scrolled into that direction + */ + private fun androidx.viewpager.widget.ViewPager.canScrollMore(scrollDir: Int): Boolean { + if (scrollDir < 0) { // means direction is undefined yet + return true + } + if (scrollDir == ItemTouchHelper.LEFT) { + // negative to left + return canScrollHorizontally(-1) + } else if (scrollDir == ItemTouchHelper.RIGHT) { + // positive to right + return canScrollHorizontally(1) + } + return false + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/util/Position.java b/client/android/div-core-views/src/main/java/com/yandex/div/util/Position.java new file mode 100644 index 000000000..f60535717 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/util/Position.java @@ -0,0 +1,6 @@ +package com.yandex.div.util; + +public enum Position { + LEFT, + RIGHT +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/util/Utils.kt b/client/android/div-core-views/src/main/java/com/yandex/div/util/Utils.kt new file mode 100644 index 000000000..856e4fbc7 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/util/Utils.kt @@ -0,0 +1,49 @@ +package com.yandex.div.util + +import java.lang.ref.WeakReference +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +internal inline fun MutableCollection.removeFirstIf(predicate: (T) -> Boolean): Boolean { + val iterator = iterator() + while (iterator.hasNext()) { + if (predicate(iterator.next())) { + iterator.remove() + return true + } + } + return false +} + +internal fun MutableMap.removeOrThrow(key: K, message: String? = null): V { + return remove(key) ?: throw NoSuchElementException(message) +} + +internal fun Map.getOrThrow(key: K, message: String? = null): V { + return get(key) ?: throw NoSuchElementException(message) +} + +/** + * Usage: + * + * val/var myWeakRef by weak(myStrongRef) + */ + +internal fun weak(obj: T? = null): ReadWriteProperty = WeakRef(obj) + +private class WeakRef(obj: T? = null) : ReadWriteProperty { + + private var weakReference: WeakReference? + + init { + this.weakReference = obj?.let { WeakReference(it) } + } + + override fun getValue(thisRef: Any?, property: KProperty<*>): T? { + return weakReference?.get() + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { + weakReference = value?.let { WeakReference(it) } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/util/Views.java b/client/android/div-core-views/src/main/java/com/yandex/div/util/Views.java new file mode 100644 index 000000000..f8dffc917 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/util/Views.java @@ -0,0 +1,241 @@ +package com.yandex.div.util; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Typeface; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.DimenRes; +import androidx.annotation.IdRes; +import androidx.annotation.IntDef; +import androidx.annotation.IntRange; +import androidx.annotation.LayoutRes; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import com.yandex.div.core.util.Assert; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayDeque; + +public class Views { + + public static final int VIEW_SIDE_BOTTOM = 1; + public static final int VIEW_SIDE_LEFT = 1 << 1; + public static final int VIEW_SIDE_RIGHT = 1 << 2; + public static final int VIEW_SIDE_TOP = 1 << 3; + + private static final int[] HIT_TEST_ARRAY = new int[2]; + + @NonNull + public static T findViewAndCast(@NonNull Activity activity, @IdRes int viewId) { + T viewById = activity.findViewById(viewId); + if (viewById == null) { + throw new IllegalStateException( + "View with id [" + activity.getResources().getResourceName(viewId) + "] doesn't exist"); + } + return viewById; + } + + @NonNull + public static T findViewAndCast(@NonNull View parent, @IdRes int viewId) { + T viewById = parent.findViewById(viewId); + if (viewById == null) { + throw new IllegalStateException( + "View with id [" + parent.getResources().getResourceName(viewId) + "] doesn't exist"); + } + return viewById; + } + + @Nullable + public static T findOptionalViewAndCast(@NonNull View parent, @IdRes int viewId) { + //noinspection unchecked + return (T) parent.findViewById(viewId); + } + + @NonNull + public static VIEW inflate(@NonNull ViewGroup viewGroup, @LayoutRes int layout) { + //noinspection unchecked + return (VIEW) LayoutInflater.from(viewGroup.getContext()).inflate(layout, viewGroup, false); + } + + @NonNull + public static VIEW inflate(@NonNull Context context, @NonNull ViewGroup viewGroup, @LayoutRes int layout) { + //noinspection unchecked + return (VIEW) LayoutInflater.from(context).inflate(layout, viewGroup, false); + } + + @NonNull + public static V inflate(@NonNull Context context, @LayoutRes int layout) { + //noinspection unchecked + return (V) LayoutInflater.from(context).inflate(layout, null); + } + + public static void setTextOrHide(@NonNull final TextView view, + @Nullable final CharSequence text) { + if (TextUtils.isEmpty(text)) { + view.setText(null); + view.setVisibility(View.GONE); + } else { + view.setText(text); + view.setVisibility(View.VISIBLE); + } + } + + public static boolean isVisible(@NonNull View view) { + return view.getVisibility() == View.VISIBLE; + } + + /** + * Checks, whether a point is inside the view + * + * @param view view to check + * @param rawX point X coordinate (should screen-based) + * @param rawY point Y coordinate (should screen-based) + * @return true if the point is inside the view + */ + @MainThread + public static boolean hitTest(@NonNull View view, float rawX, float rawY) { + view.getLocationInWindow(HIT_TEST_ARRAY); + return rawX >= HIT_TEST_ARRAY[0] && rawX <= HIT_TEST_ARRAY[0] + view.getWidth() && + rawY >= HIT_TEST_ARRAY[1] && rawY <= HIT_TEST_ARRAY[1] + view.getHeight(); + } + + public static void setWidth(@NonNull View view, + @IntRange(from = ViewGroup.LayoutParams.WRAP_CONTENT) int width) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + if (params != null) { + params.width = width; + view.setLayoutParams(params); + } + } + + public static void setHeight(@NonNull View view, + @IntRange(from = ViewGroup.LayoutParams.WRAP_CONTENT) int height) { + ViewGroup.LayoutParams params = view.getLayoutParams(); + if (params != null) { + params.height = height; + view.setLayoutParams(params); + } + } + + public static void setPadding(@NonNull View view, @DimenRes int res, @ViewSideFlags int viewSideFlags) { + int pixelSize = view.getResources().getDimensionPixelSize(res); + setPaddingPixelSize(view, pixelSize, viewSideFlags); + } + + public static void setPadding(@NonNull View view, @Px int px, @ViewSideFlags int viewSideFlags, + @SuppressWarnings("UnusedParameters") Void unused) { + setPaddingPixelSize(view, px, viewSideFlags); + } + + public static void traverseViewTree(@NonNull final View view, @NonNull final ViewProcessor viewProcessor) { + final ArrayDeque viewGroups = new ArrayDeque<>(); + viewProcessor.process(view); + + if (view instanceof ViewGroup) { + viewGroups.add((ViewGroup) view); + } + + while (!viewGroups.isEmpty()) { + final ViewGroup viewGroup = viewGroups.remove(); + final int childCount = viewGroup.getChildCount(); + + for (int index = 0; index < childCount; index++) { + final View child = viewGroup.getChildAt(index); + viewProcessor.process(child); + + if (child instanceof ViewGroup) { + viewGroups.add((ViewGroup) child); + } + } + } + } + + private static void setPaddingPixelSize( + @NonNull View view, int paddingPixelSize, @ViewSideFlags int viewSideFlags) { + view.setPadding( + (viewSideFlags & VIEW_SIDE_LEFT) != 0 ? paddingPixelSize : view.getPaddingLeft(), + (viewSideFlags & VIEW_SIDE_TOP) != 0 ? paddingPixelSize : view.getPaddingTop(), + (viewSideFlags & VIEW_SIDE_RIGHT) != 0 ? paddingPixelSize : view.getPaddingRight(), + (viewSideFlags & VIEW_SIDE_BOTTOM) != 0 ? paddingPixelSize : view.getPaddingBottom() + ); + } + + public static int getBottomMargin(@NonNull View view) { + ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); + + if (!(layoutParams instanceof ViewGroup.MarginLayoutParams)) { + return 0; + } + return ((ViewGroup.MarginLayoutParams) layoutParams).bottomMargin; + } + + public static int getVerticalMargins(@NonNull View view) { + ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); + + if (!(layoutParams instanceof ViewGroup.MarginLayoutParams)) { + return 0; + } + ViewGroup.MarginLayoutParams marginLayoutParams = (ViewGroup.MarginLayoutParams) layoutParams; + return marginLayoutParams.bottomMargin + marginLayoutParams.topMargin; + } + + /** + * @return height including vertical margins if present + */ + public static int getHeightWithMargins(@NonNull View view) { + return view.getHeight() + getVerticalMargins(view); + } + + /** + * Setup alert dialog message font + * + * @param dialog fully inflated dialog (at least one AlertDialog#show() was called) + * @param typeface font, no op on null-values + * @return styled dialog + */ + @NonNull + public static AlertDialog styleAlertDialog(@NonNull AlertDialog dialog, @Nullable Typeface typeface) { + if (typeface == null) { + return dialog; + } + + TextView messageText = dialog.findViewById(android.R.id.message); + Assert.assertNotNull("AlertDialog message textview not found", messageText); + if (messageText != null) { + messageText.setTypeface(typeface); + } + return dialog; + } + + /** + * Setup a toolbar as a support action bar + * + * @param activity to setup a toolbar + * @param toolbar to set + */ + public static void setToolbar(@Nullable Activity activity, @Nullable Toolbar toolbar) { + if (activity instanceof AppCompatActivity) { + ((AppCompatActivity) activity).setSupportActionBar(toolbar); + } + } + + @IntDef(flag = true, value = { + VIEW_SIDE_BOTTOM, VIEW_SIDE_LEFT, VIEW_SIDE_RIGHT, VIEW_SIDE_TOP + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ViewSideFlags { + } + + public interface ViewProcessor { + void process(@NonNull View view); + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/AutoEllipsizeHelper.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/AutoEllipsizeHelper.kt new file mode 100644 index 000000000..4d925d795 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/AutoEllipsizeHelper.kt @@ -0,0 +1,88 @@ +package com.yandex.div.view + +import android.view.ViewTreeObserver +import kotlin.math.max +import kotlin.math.min + +/** + * Helper to calculate and update max lines for given [textView]. + */ +internal class AutoEllipsizeHelper(private val textView: EllipsizedTextView) { + + /** + * If auto ellipsize is enabled. + */ + var isEnabled = false + + private var preDrawListener: ViewTreeObserver.OnPreDrawListener? = null + + /** + * Called when [textView] is attached to window. + */ + fun onViewAttachedToWindow() { + if (isEnabled) { + addPreDrawListener() + } + } + + /** + * Called when [textView] detached from window. + */ + fun onViewDetachedFromWindow() { + removePreDrawListener() + } + + private fun addPreDrawListener() { + if (preDrawListener != null) { + return + } + preDrawListener = ViewTreeObserver.OnPreDrawListener { + if (!isEnabled) { + return@OnPreDrawListener true + } + val textLayout = textView.layout ?: return@OnPreDrawListener true + val maxLines = textView.run { + // Sometimes, TextView height is less then "completely_visible_lines" * "line_height" + // and sometimes it's not. + // For example, if we set lineSpacingExtra, it won't be applied for the last line. + // Also textView has inner paddings like FirstBaselineToTopHeight, which makes + // calculating the number of fully visible lines nearly impossible. + var visibleLines = min(textLayout.lineCount, height / lineHeight + 1) + + // So our visibleLines variable is more like an upper bound, we should + // manually check if the last visible line is actually completely visible + while (visibleLines > 0) { + val visibleTextHeight = textLayout.getLineBottom(visibleLines - 1) + val availableHeight = height - paddingTop - paddingBottom + if (visibleTextHeight - availableHeight <= ALLOWED_TEXT_OVERFLOW_PX) { + break + } + visibleLines -= 1 + } + max(0, visibleLines) + } + if (maxLines != textView.maxLines) { + textView.maxLines = maxLines + false + } else { + removePreDrawListener() + true + } + } + textView.viewTreeObserver.addOnPreDrawListener(preDrawListener) + } + + private fun removePreDrawListener() { + if (preDrawListener != null) { + textView.viewTreeObserver.removeOnPreDrawListener(preDrawListener) + preDrawListener = null + } + } + + private companion object { + /** + * Allowed threshold in pixels height is overflowed by text. + */ + private const val ALLOWED_TEXT_OVERFLOW_PX = 3 + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/EllipsizedTextView.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/EllipsizedTextView.kt new file mode 100644 index 000000000..9e00526d2 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/EllipsizedTextView.kt @@ -0,0 +1,277 @@ +package com.yandex.div.view + +import android.content.Context +import android.text.Layout +import android.text.SpannableStringBuilder +import android.text.StaticLayout +import android.text.TextUtils +import android.util.AttributeSet +import androidx.annotation.VisibleForTesting +import androidx.appcompat.widget.AppCompatTextView +import com.yandex.div.core.views.R + +open class EllipsizedTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatTextView(context, attrs, defStyleAttr) { + + var ellipsis: CharSequence = DEFAULT_ELLIPSIS + set(value) { + if (field != value) { + field = value + onEllipsisChanged(value) + } + } + + /** + * If text auto ellipsize is enabled. + */ + var autoEllipsize: Boolean = false + set(value) { + field = value + autoEllipsizeHelper.isEnabled = value + } + + private var isRemeasureNeeded = false + + @VisibleForTesting + var ellipsizedText: CharSequence? = null + private set(value) { + if (field != value) { + field = value + setTextInternal(value) + } + } + + @VisibleForTesting + var displayText: CharSequence? = null + private set + + protected var isInternalTextChange = false + + private var lastMeasuredWidth = NOT_SET + protected var lastMeasuredHeight = NOT_SET + + private var originalText: CharSequence? = null + private var cachedEllipsisWidth = ELLIPSIS_WIDTH_UNKNOWN + private var isEllipsizeRequested = false + + @Suppress("LeakingThis") + private val autoEllipsizeHelper = AutoEllipsizeHelper(this) + + init { + if (isInEditMode) { + val array = context.obtainStyledAttributes(attrs, R.styleable.EllipsizedTextView, defStyleAttr, 0) + try { + ellipsis = array.getText(R.styleable.EllipsizedTextView_ellipsis) ?: DEFAULT_ELLIPSIS + } finally { + array.recycle() + } + } + + onEllipsisChanged(ellipsis) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + autoEllipsizeHelper.onViewAttachedToWindow() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + autoEllipsizeHelper.onViewDetachedFromWindow() + } + + private fun onEllipsisChanged(ellipsis: CharSequence) { + when { + noMaxLines() -> super.setEllipsize(null) + ellipsis == DEFAULT_ELLIPSIS -> super.setEllipsize(TextUtils.TruncateAt.END) + else -> { + super.setEllipsize(null) + requestEllipsize() + invalidateEllipsis() + } + } + requestLayout() + } + + override fun getText(): CharSequence { + return originalText ?: "" + } + + private fun setTextInternal(text: CharSequence?) { + isInternalTextChange = true + super.setText(text) + isInternalTextChange = false + } + + override fun setText(text: CharSequence?, type: BufferType?) { + displayText = text + super.setText(text, type) + } + + override fun setMaxLines(maxLines: Int) { + super.setMaxLines(maxLines) + onEllipsisChanged(ellipsis) + requestEllipsize() + invalidateEllipsis() + } + + override fun setEllipsize(where: TextUtils.TruncateAt?) = Unit + + override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + if (isInternalTextChange) return + + originalText = text + requestLayout() + requestEllipsize() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + sizeChangeInternal( + width = measuredWidth, + height = measuredHeight, + oldWidth = lastMeasuredWidth, + oldHeight = lastMeasuredHeight + ) + if (isEllipsizeRequested) { + logDebug { + "Size before ellipsize:\nwidth $measuredWidth\nheight $measuredHeight" + } + ellipsizeIfNeeded() + ellipsizedText?.takeIf { isRemeasureNeeded }?.run { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + logDebug { + "Size after ellipsize:\nwidth $measuredWidth\nheight $measuredHeight" + } + } + // |lastMeasuredHeight| is set in subclasses since it changes there. + lastMeasuredWidth = measuredWidth + } + + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + sizeChangeInternal(width, height, oldWidth, oldHeight) + } + + private fun sizeChangeInternal(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + if (width != oldWidth || height != oldHeight) { + requestEllipsize() + } + } + + private fun requestEllipsize() { + isEllipsizeRequested = true + } + + private fun invalidateEllipsis() { + cachedEllipsisWidth = ELLIPSIS_WIDTH_UNKNOWN + isRemeasureNeeded = false + } + + private fun ellipsizeIfNeeded() { + val oldEllipsizedText = ellipsizedText + val noCustomEllipsisNeeded = noMaxLines() || ellipsis == DEFAULT_ELLIPSIS + when { + ellipsizedText == null && noCustomEllipsisNeeded -> Unit + noCustomEllipsisNeeded -> ellipsizedText = originalText?.also { isRemeasureNeeded = it != oldEllipsizedText } + else -> ellipsizedText = ellipsize(originalText) + } + logDebug { + "ellipsized text: $ellipsizedText, remeasure: $isRemeasureNeeded" + } + isEllipsizeRequested = false + } + + private fun noMaxLines(): Boolean { + return maxLines < 0 || maxLines == Int.MAX_VALUE + } + + private fun ellipsize(text: CharSequence?): CharSequence? { + if (text.isNullOrEmpty()) return null + + val ellipsis = ellipsis + val fittedSymbols = calculateFittedSymbols(text, ellipsis) + when { + fittedSymbols <= 0 -> return null + fittedSymbols == text.length -> return text + } + + val stringBuilder = SpannableStringBuilder(text, 0, fittedSymbols) + stringBuilder.append(ellipsis) + return stringBuilder + } + + private fun calculateFittedSymbols(text: CharSequence, ellipsis: CharSequence): Int { + if (text.isEmpty() || maxLines == 0) return 0 + + val textWidth = availableWidth() + if (textWidth <= 0) return 0 + + val textLayout = layoutText(text, textWidth) + val lines = textLayout.lineCount + val lastLineWidth = textLayout.getLineWidth(lines - 1) + if (lines < maxLines || lines == maxLines && lastLineWidth <= textWidth) { + isRemeasureNeeded = true + return text.length + } + + if (cachedEllipsisWidth == ELLIPSIS_WIDTH_UNKNOWN) { + val ellipsisLayout = layoutText(ellipsis) + cachedEllipsisWidth = ellipsisLayout.getLineWidth(0) + } + + isRemeasureNeeded = true + + val ellipsizedTextWidth = textWidth - cachedEllipsisWidth + var fittedSymbols = textLayout.getOffsetForHorizontal(maxLines - 1, ellipsizedTextWidth) + if (textLayout.getPrimaryHorizontal(fittedSymbols) > ellipsizedTextWidth) { + fittedSymbols-- + } + + // Dropping last symbol if it represents a first byte of two-byte unicode symbol + if (fittedSymbols > 0) { + val lastChar = text[fittedSymbols - 1] + if (Character.isHighSurrogate(lastChar)) { + fittedSymbols-- + } + } + return fittedSymbols + } + + protected fun availableWidth(): Int { + return measuredWidth - compoundPaddingLeft - compoundPaddingRight + } + + @Suppress("DEPRECATION") + private fun layoutText(text: CharSequence, textWidth: Int = Int.MAX_VALUE): Layout { + return StaticLayout( + text, + paint, + textWidth, + Layout.Alignment.ALIGN_NORMAL, + lineSpacingMultiplier, + lineSpacingExtra, + true + ) + } + + companion object { + private const val DEFAULT_ELLIPSIS = "\u2026" + private const val ELLIPSIS_WIDTH_UNKNOWN = -1.0f + const val NOT_SET = -1 + + private const val TAG = "Ya:EllipsizedTextView" + private const val DEBUG = false + + private inline fun logDebug(message: () -> String) { + if (DEBUG) { + android.util.Log.i(TAG, message()) + } + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/NonScrollImageView.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/NonScrollImageView.java new file mode 100644 index 000000000..b10635ef4 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/NonScrollImageView.java @@ -0,0 +1,22 @@ +package com.yandex.div.view; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import androidx.appcompat.widget.AppCompatImageView; + +public class NonScrollImageView extends AppCompatImageView { + + public NonScrollImageView(Context context) { + super(context); + } + + public NonScrollImageView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean requestRectangleOnScreen(Rect rectangle, boolean immediate) { + return false; + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/OnInterceptTouchEventListener.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/OnInterceptTouchEventListener.kt new file mode 100644 index 000000000..d904f3d80 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/OnInterceptTouchEventListener.kt @@ -0,0 +1,31 @@ +package com.yandex.div.view + +import android.view.MotionEvent +import android.view.ViewGroup + +/** + * Interface definition for a callback that allows the application to intercept + * touch events in progress at the view hierarchy level of target [ViewGroup] + * before those touch events are considered for target's own behavior. + */ +interface OnInterceptTouchEventListener { + + /** + * Silently observe and/or take over touch events sent to the target + * before they are handled by either the target itself or its child views. + * + * + * Implement this method to intercept all touch screen motion events. This + * allows you to watch events as they are dispatched to your children, and + * take ownership of the current gesture at any point. + * + * @param target The view the touch event has been dispatched to. + * @param event The MotionEvent object containing full information about + * the event. + * + * @return True if this OnInterceptTouchEventListener wishes to begin intercepting touch events, + * false to continue with the current behavior and continue observing future events in + * the gesture. + */ + fun onInterceptTouchEvent(target: ViewGroup, event: MotionEvent): Boolean +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/OnInterceptTouchEventListenerHost.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/OnInterceptTouchEventListenerHost.kt new file mode 100644 index 000000000..95e774128 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/OnInterceptTouchEventListenerHost.kt @@ -0,0 +1,13 @@ +package com.yandex.div.view + +/** + * Interface definition for [android.view.ViewGroup] that holds and notifies + * an instance of [OnInterceptTouchEventListener] + */ +interface OnInterceptTouchEventListenerHost { + + /** + * Listener itself. + */ + var onInterceptTouchEventListener: OnInterceptTouchEventListener? +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/PaddingItemDecoration.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/PaddingItemDecoration.kt new file mode 100644 index 000000000..d7c5440c5 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/PaddingItemDecoration.kt @@ -0,0 +1,64 @@ +package com.yandex.div.view + +import android.graphics.Rect +import android.view.View +import androidx.annotation.Px +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.StaggeredGridLayoutManager +import com.yandex.div.core.util.KAssert + +class PaddingItemDecoration @JvmOverloads constructor( + @param:Px @field:Px private val paddingLeft: Int = 0, + @param:Px @field:Px private val midItemPadding: Int = 0, + @param:Px @field:Px private val paddingRight: Int = 0, + @param:Px @field:Px private val paddingTop: Int = 0, + @param:Px @field:Px private val paddingBottom: Int = 0, + private val orientation: Int = RecyclerView.HORIZONTAL +) : RecyclerView.ItemDecoration() { + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val spanCount = when (val layoutManager = parent.layoutManager) { + is StaggeredGridLayoutManager -> layoutManager.spanCount + is LinearLayoutManager -> 1 + else -> { + KAssert.fail { "Unsupported layoutManger: $layoutManager" } + + 1 + } + } + + if (spanCount == 1) { + val itemCount = parent.adapter?.itemCount ?: return + val position = parent.layoutManager?.getPosition(view) ?: return + + val isFirst = position == 0 + val isLast = position == itemCount - 1 + + when (orientation) { + RecyclerView.HORIZONTAL -> outRect.set( + if (isFirst) paddingLeft else 0, + paddingTop, + if (isLast) paddingRight else midItemPadding, + paddingBottom + ) + RecyclerView.VERTICAL -> outRect.set( + paddingLeft, + if (isFirst) paddingTop else 0, + paddingRight, + if (isLast) paddingBottom else midItemPadding + ) + else -> KAssert.fail { "Unsupported orientation: $orientation" } + } + } else { + val halfMidItemPadding = midItemPadding / 2 + + outRect.set( + halfMidItemPadding, + halfMidItemPadding, + halfMidItemPadding, + halfMidItemPadding + ) + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/PageItemDecoration.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/PageItemDecoration.kt new file mode 100644 index 000000000..5ab232637 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/PageItemDecoration.kt @@ -0,0 +1,75 @@ +package com.yandex.div.view + +import android.graphics.Rect +import android.view.View +import androidx.annotation.Px +import androidx.recyclerview.widget.RecyclerView +import com.yandex.div.core.util.KAssert +import kotlin.math.roundToInt + +class PageItemDecoration @JvmOverloads constructor( + @Px private val paddingLeft: Float = 0f, + @Px private val paddingRight: Float = 0f, + @Px private val paddingTop: Float = 0f, + @Px private val paddingBottom: Float = 0f, + @Px private var neighbourItemWidth: Float = 0f, + @Px private val itemSpacing: Float = 0f, + private val orientation: Int = RecyclerView.HORIZONTAL +) : RecyclerView.ItemDecoration() { + + private val paddingLeftInt = paddingLeft.roundToInt() + private val paddingRightInt = paddingRight.roundToInt() + private val paddingTopInt = paddingTop.roundToInt() + private val paddingBottomInt = paddingBottom.roundToInt() + + private val middlePadding = (neighbourItemWidth + itemSpacing).roundToInt() + private val paddingEndForFirstItem = when (orientation) { + RecyclerView.HORIZONTAL -> ((neighbourItemWidth + itemSpacing) * 2 - paddingLeft).roundToInt() + RecyclerView.VERTICAL -> ((neighbourItemWidth + itemSpacing) * 2 - paddingBottom).roundToInt() + else -> 0 + } + private val paddingStartForLastItem = when (orientation) { + RecyclerView.HORIZONTAL -> ((neighbourItemWidth + itemSpacing) * 2 - paddingRight).roundToInt() + RecyclerView.VERTICAL -> ((neighbourItemWidth + itemSpacing) * 2 - paddingTop).roundToInt() + else -> 0 + } + + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + + val isTwoPage = parent.adapter?.itemCount == 2 + val isFirst = parent.layoutManager?.getPosition(view) == 0 + val isLast = parent.layoutManager?.getPosition(view) == parent.adapter!!.itemCount - 1 + + when (orientation) { + RecyclerView.HORIZONTAL -> outRect.set( + when { + isFirst -> paddingLeftInt + isLast && !isTwoPage -> paddingStartForLastItem + else -> middlePadding + }, + paddingTopInt, + when { + isLast -> paddingRightInt + isFirst && !isTwoPage -> paddingEndForFirstItem + else -> middlePadding + }, + paddingBottomInt + ) + RecyclerView.VERTICAL -> outRect.set( + paddingLeftInt, + when { + isFirst -> paddingTopInt + isLast && !isTwoPage -> paddingStartForLastItem + else -> middlePadding + }, + paddingRightInt, + when { + isLast -> paddingBottomInt + isFirst && !isTwoPage -> paddingEndForFirstItem + else -> middlePadding + } + ) + else -> KAssert.fail { "Unsupported orientation: $orientation" } + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/SeparatorView.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/SeparatorView.kt new file mode 100644 index 000000000..5cd49af8d --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/SeparatorView.kt @@ -0,0 +1,154 @@ +package com.yandex.div.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import androidx.annotation.ColorRes +import androidx.annotation.DimenRes +import androidx.annotation.IntDef +import androidx.core.content.ContextCompat +import com.yandex.div.core.util.Assert + +open class SeparatorView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val dividerPaint = Paint().apply { color = Color.TRANSPARENT } + private val dividerRect = Rect() + private var isDividerRectChanged = false + + var isHorizontal: Boolean = true + set(value) { + if (field != value) { + field = value + isDividerRectChanged = true + requestLayout() + } + } + + var dividerThickness: Int = 0 + set(height) { + if (field != height) { + field = height + isDividerRectChanged = true + requestLayout() + } + } + + @DividerGravity + var dividerGravity = Gravity.CENTER + set(@DividerGravity dividerGravity) { + if (field != dividerGravity) { + field = dividerGravity + isDividerRectChanged = true + invalidate() + } + } + + var dividerColor: Int + get() = dividerPaint.color + set(color) { + if (dividerPaint.color != color) { + dividerPaint.color = color + invalidate() + } + } + + private val isDividerVisible: Boolean + get() = Color.alpha(dividerPaint.color) > 0 + + @IntDef(Gravity.START, Gravity.CENTER, Gravity.END) + annotation class DividerGravity + + fun setDividerColorResource(@ColorRes resId: Int) { + dividerColor = ContextCompat.getColor(context, resId) + } + + fun setDividerHeightResource(@DimenRes resId: Int) { + dividerThickness = resources.getDimensionPixelSize(resId) + } + + override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { + super.setPadding(left, top, right, bottom) + isDividerRectChanged = true + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + var contentWidth = paddingLeft + paddingRight + var contentHeight = paddingTop + paddingBottom + if (isHorizontal) { + contentHeight += dividerThickness + } else { + contentWidth += dividerThickness + } + setMeasuredDimension( + calcSize(Math.max(contentWidth, suggestedMinimumWidth), widthMeasureSpec), + calcSize(Math.max(contentHeight, suggestedMinimumHeight), heightMeasureSpec) + ) + } + + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + isDividerRectChanged = true + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (isDividerVisible) { + updateDividerRect() + canvas.drawRect(dividerRect, dividerPaint) + } + } + + private fun updateDividerRect() { + if (!isDividerRectChanged) return + + val paddingStart = if (isHorizontal) paddingTop else paddingLeft + val paddingEnd = if (isHorizontal) paddingBottom else paddingRight + val size = if (isHorizontal) height else width + val contentSize = size - paddingStart - paddingEnd + + + val rectStart = when (dividerGravity) { + Gravity.START -> paddingStart + Gravity.END -> size - paddingEnd - dividerThickness + Gravity.CENTER -> paddingStart + (contentSize - dividerThickness) / 2 + else -> { + Assert.fail("Unknown divider gravity value") + 0 + } + } + if (isHorizontal) { + dividerRect.top = rectStart + dividerRect.bottom = rectStart + Math.min(contentSize, dividerThickness) + dividerRect.left = paddingLeft + dividerRect.right = width - paddingRight + } else { + dividerRect.left = rectStart + dividerRect.right = rectStart + Math.min(contentSize, dividerThickness) + dividerRect.top = paddingTop + dividerRect.bottom = height - paddingBottom + } + + isDividerRectChanged = false + } + + private fun calcSize(size: Int, measureSpec: Int): Int { + val specMode = View.MeasureSpec.getMode(measureSpec) + val specSize = View.MeasureSpec.getSize(measureSpec) + + return when (specMode) { + View.MeasureSpec.UNSPECIFIED -> size + View.MeasureSpec.AT_MOST -> Math.min(size, specSize) + View.MeasureSpec.EXACTLY -> specSize + else -> size + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/SnappyRecyclerView.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/SnappyRecyclerView.kt new file mode 100644 index 000000000..94343d74a --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/SnappyRecyclerView.kt @@ -0,0 +1,253 @@ +package com.yandex.div.view + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.core.view.doOnNextLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.yandex.div.core.util.Assert + +// From: http://stackoverflow.com/a/37816976 +open class SnappyRecyclerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RecyclerView(context, attrs, defStyleAttr) { + + val savedItemPosition: Int + get() = _savedItemPosition + private var _savedItemPosition = DEFAULT_ITEM_POSITION + private val itemCount: Int + get() = adapter?.itemCount ?: 0 + public var orientation = HORIZONTAL + + + override fun fling(velocityX: Int, velocityY: Int): Boolean { + Assert.assertTrue(layoutManager is androidx.recyclerview.widget.LinearLayoutManager) + + val linearLayoutManager = layoutManager as androidx.recyclerview.widget.LinearLayoutManager + + // views on the screen + var lastVisibleItemPosition = linearLayoutManager.findLastCompletelyVisibleItemPosition() + if (lastVisibleItemPosition == NO_POSITION) { + lastVisibleItemPosition = linearLayoutManager.findLastVisibleItemPosition() + } + val lastView = linearLayoutManager.findViewByPosition(lastVisibleItemPosition) + var firstVisibleItemPosition = linearLayoutManager.findFirstCompletelyVisibleItemPosition() + if (firstVisibleItemPosition == NO_POSITION) { + firstVisibleItemPosition = linearLayoutManager.findFirstVisibleItemPosition() + } + val firstView = linearLayoutManager.findViewByPosition(firstVisibleItemPosition) + + if (firstView == null || lastView == null) { + return false + } + + val screenSize = choosePropertyDependOnOrientation(width, height) + val params = ScrollParams(screenSize, firstView, lastView) + + var delta: Int + if (Math.abs(choosePropertyDependOnOrientation(velocityX, velocityY)) < FLING_VELOCITY_FOR_PAGE_CHANGE) { + // The fling is slow -> stay at the current page if we are less than half through, + // or go to the next page if more than half through + if (params.startEdge > screenSize / 2) { + // go to left page + delta = params.distanceEnd + } else if (params.endEdge < screenSize / 2) { + // go to right page + delta = params.distanceStart + } else { + // stay at current page + if (isRightSwipe(velocityX)) { + delta = params.distanceEnd + } else { + delta = params.distanceStart + } + } + } else { + // The fling is fast -> go to next page + if (isRightSwipe(velocityX)) { + delta = params.distanceStart + } else { + delta = params.distanceEnd + } + } + if (delta == 0) { + /* Hack for first and last items. + If we scroll last item to the right side, there is a scroll with 0px, as result there isn't SCROLL_STATE_IDLE. But our + onScrollStateChanges listeners work only with SCROLL_STATE_IDLE state (this state creates after scroll is finished). + We make fictive scroll with 1px to bound of RecyclerView. In fact RecyclerView will not be scrolled (because hasn't scroll out of bounds), + but we will have full correct scroll with SCROLL_STATE_IDLE. */ + if (isRightSwipe(velocityX)) { + delta = 1 + } else { + delta = -1 + } + } + if (orientation == HORIZONTAL) { + smoothScrollBy(delta, 0) + } else { + smoothScrollBy(0, delta) + } + return true + } + + override fun onScrollStateChanged(state: Int) { + super.onScrollStateChanged(state) + + // If you tap on the phone while the RecyclerView is scrolling it will stop in the middle. + // This code fixes this. This code is not strictly necessary but it improves the behaviour. + + if (state == SCROLL_STATE_IDLE) { + val linearLayoutManager = layoutManager as androidx.recyclerview.widget.LinearLayoutManager + // views on the screen + var firstVisibleItemPosition = linearLayoutManager.findFirstCompletelyVisibleItemPosition() + if (firstVisibleItemPosition == 0) { + return + } + var lastVisibleItemPosition = linearLayoutManager.findLastCompletelyVisibleItemPosition() + if (lastVisibleItemPosition == itemCount - 1) { + return + } + + if (firstVisibleItemPosition == NO_POSITION) { + firstVisibleItemPosition = linearLayoutManager.findFirstVisibleItemPosition() + } + if (lastVisibleItemPosition == NO_POSITION) { + lastVisibleItemPosition = linearLayoutManager.findLastVisibleItemPosition() + } + val firstView = linearLayoutManager.findViewByPosition(firstVisibleItemPosition) + val lastView = linearLayoutManager.findViewByPosition(lastVisibleItemPosition) + + if (firstView == null || lastView == null) { + return + } + + val screenSize = choosePropertyDependOnOrientation(width, height) + val params = ScrollParams(screenSize, firstView, lastView) + + if (firstVisibleItemPosition == lastVisibleItemPosition) { + // Special case: there is no difference between distanceStart and distanceEnd. + // We just need to perform smoothScrollBy(). + if (orientation == HORIZONTAL) { + smoothScrollBy(params.distanceStart, 0) + } else { + smoothScrollBy(0, params.distanceStart) + } + } else if (params.startEdge > screenSize / 4) { + if (orientation == HORIZONTAL) { + smoothScrollBy(params.distanceEnd, 0) + } else { + smoothScrollBy(0, params.distanceEnd) + } + } else if (params.endEdge < screenSize / 4) { + if (orientation == HORIZONTAL) { + smoothScrollBy(params.distanceStart, 0) + } else { + smoothScrollBy(0, params.distanceStart) + } + } + } + } + + override fun scrollToPosition(position: Int) { + if (isLayoutFrozen) { + return + } + stopScroll() + + if (layoutManager == null) { + return + } + + if (layoutManager !is LinearLayoutManager) { + layoutManager!!.scrollToPosition(position) + awakenScrollBars() + return + } + + if (itemCount <= 0) { + return + } + + val linearLayoutManager = layoutManager as LinearLayoutManager + var firstViewPosition = linearLayoutManager.findFirstCompletelyVisibleItemPosition() + if (firstViewPosition == NO_POSITION) { + firstViewPosition = linearLayoutManager.findFirstVisibleItemPosition() + } + + val firstView = linearLayoutManager.findViewByPosition(firstViewPosition) + if (firstView == null) { + linearLayoutManager.scrollToPositionWithOffset(position, 0) + doOnNextLayout { post { scrollToPosition(position) } } + return + } + + val screenSize = choosePropertyDependOnOrientation(width, height) + val viewSize = choosePropertyDependOnOrientation(firstView.width, firstView.height) + + linearLayoutManager.scrollToPositionWithOffset(position, (screenSize - viewSize) / 2) + + awakenScrollBars() + } + + @JvmOverloads + fun findSelectedItemPosition(direction: Int = DIRECTION_NONE): Int { + val linearLayoutManager = layoutManager as androidx.recyclerview.widget.LinearLayoutManager + + val firstCompletelyVisibleItemPosition = linearLayoutManager.findFirstCompletelyVisibleItemPosition() + if (firstCompletelyVisibleItemPosition != NO_POSITION) { + return firstCompletelyVisibleItemPosition + } + val lastVisibleItemPosition = linearLayoutManager.findLastVisibleItemPosition() + //workaround for first/last position + if (lastVisibleItemPosition == linearLayoutManager.findFirstVisibleItemPosition()) { + return if (lastVisibleItemPosition != NO_POSITION) lastVisibleItemPosition else DEFAULT_ITEM_POSITION + } + //have 2 items on screen and choose by direction + return if (direction >= 0) { + lastVisibleItemPosition + } else { + lastVisibleItemPosition - 1 + } + } + + fun savePosition(position: Int) { + _savedItemPosition = position + } + + private fun choosePropertyDependOnOrientation(horizontal: T, vertical: T) = if (orientation == HORIZONTAL) horizontal else vertical + + inner class ScrollParams internal constructor(screenSize: Int, firstView: View, lastView: View) { + + val distanceStart: Int + val distanceEnd: Int + val startEdge: Int + val endEdge: Int + + init { + val startMargin = (screenSize - choosePropertyDependOnOrientation(lastView.width, lastView.height)) / 2 + val endMargin = (screenSize - choosePropertyDependOnOrientation(firstView.width, firstView.height)) / 2 + + choosePropertyDependOnOrientation(firstView.width, firstView.height) + startEdge = choosePropertyDependOnOrientation(lastView.left, lastView.top) + endEdge = choosePropertyDependOnOrientation(firstView.right, firstView.bottom) + distanceStart = startEdge - startMargin + distanceEnd = endEdge - endMargin + } + } + + companion object { + + // Use it with a horizontal LinearLayoutManager + // Based on http://stackoverflow.com/a/29171652/4034572 + + private const val FLING_VELOCITY_FOR_PAGE_CHANGE = 1000 + private const val DEFAULT_ITEM_POSITION = 0 + private const val DIRECTION_NONE = 0 + + private fun isRightSwipe(velocityX: Int): Boolean { + return velocityX > 0 + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/SuperLineHeightEditText.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/SuperLineHeightEditText.kt new file mode 100644 index 000000000..7ca89bbbd --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/SuperLineHeightEditText.kt @@ -0,0 +1,71 @@ +package com.yandex.div.view + +import android.annotation.SuppressLint +import android.content.Context +import android.view.MotionEvent +import androidx.appcompat.widget.AppCompatEditText +import kotlin.math.roundToInt + +private const val UNDEFINED = -1 + +open class SuperLineHeightEditText constructor(context: Context) : AppCompatEditText(context) { + + private var fixedLineHeight = UNDEFINED + + private var extraPaddingTop = 0 + private var extraPaddingBottom = 0 + + private var verticallyScrolling = true + + private val visibleLineCount get() = when { + lineCount == 0 -> 1 + lineCount > maxLines -> maxLines + else -> lineCount + } + + fun setFixedLineHeight(lineHeight: Int?) { + fixedLineHeight = lineHeight ?: UNDEFINED + } + + override fun setLineSpacing(add: Float, mult: Float) { + extraPaddingTop = (add / 2).roundToInt() + extraPaddingBottom = (add / 2).toInt() + super.setLineSpacing(add, mult) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + if (fixedLineHeight == UNDEFINED || + MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) { + return + } + + val fixedHeightMeasureSpec = MeasureSpec.makeMeasureSpec( + fixedLineHeight * visibleLineCount + paddingTop + paddingBottom, + MeasureSpec.getMode(measuredHeightAndState) + ) + setMeasuredDimension(measuredWidthAndState, fixedHeightMeasureSpec) + } + + override fun getCompoundPaddingTop() = super.getCompoundPaddingTop() + extraPaddingTop + + override fun getCompoundPaddingBottom() = super.getCompoundPaddingBottom() + extraPaddingBottom + + override fun setHorizontallyScrolling(whether: Boolean) { + verticallyScrolling = !whether + super.setHorizontallyScrolling(whether) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!verticallyScrolling) { + return super.onTouchEvent(event) + } + + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN -> parent.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP -> parent.requestDisallowInterceptTouchEvent(false) + } + return super.onTouchEvent(event) + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/SuperLineHeightTextView.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/SuperLineHeightTextView.java new file mode 100644 index 000000000..12ac11b2b --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/SuperLineHeightTextView.java @@ -0,0 +1,106 @@ +package com.yandex.div.view; + +import android.content.Context; +import android.text.Layout; +import android.text.TextUtils; +import android.util.AttributeSet; + +/** + * For some reason line height is ignored on one-line TextViews in Android. + */ +public class SuperLineHeightTextView extends EllipsizedTextView { + private int mLineSpacingExtraTop = 0; + private int mLineSpacingExtraBottom = 0; + private boolean mShouldAddExtraSpacing = true; + + public SuperLineHeightTextView(Context context) { + this(context, null, 0); + } + + public SuperLineHeightTextView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public SuperLineHeightTextView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onTextChanged(CharSequence text, int start, int before, int after) { + super.onTextChanged(text, start, before, after); + if (!isInternalTextChange()) { + invalidateTextPadding(); + } + } + + private void invalidateTextPadding() { + mShouldAddExtraSpacing = true; + mLineSpacingExtraTop = 0; + mLineSpacingExtraBottom = 0; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (shouldReapplyExtraSpacing()) { + invalidateTextPadding(); + } + if (fixLineHeight()) { + int measuredHeightAndState = getMeasuredHeightAndState(); + measuredHeightAndState = MeasureSpec.makeMeasureSpec( + MeasureSpec.getSize(measuredHeightAndState) + mLineSpacingExtraTop + mLineSpacingExtraBottom, + MeasureSpec.getMode(measuredHeightAndState) + ); + super.setMeasuredDimension(getMeasuredWidthAndState(), measuredHeightAndState); + } + setLastMeasuredHeight(getMeasuredHeight()); + } + + private boolean shouldReapplyExtraSpacing() { + final int lastMeasuredHeight = getLastMeasuredHeight(); + if (lastMeasuredHeight == NOT_SET) { + return false; + } + + if (mLineSpacingExtraTop == 0 && mLineSpacingExtraBottom == 0) { + return false; + } + + return lastMeasuredHeight - getMeasuredHeight() != 0; + } + + @Override + public int getCompoundPaddingTop() { + return super.getCompoundPaddingTop() + mLineSpacingExtraTop; + } + + @Override + public int getCompoundPaddingBottom() { + return super.getCompoundPaddingBottom() + mLineSpacingExtraBottom; + } + + /** + * For some reason line height is ignored on one-line TextViews in Android. + * + * Note: this method must be called after the width is measured + */ + private boolean fixLineHeight() { + final int availableWidth = availableWidth(); + final CharSequence text = getText(); + final Layout textLayout = getLayout(); + float lineSpacingExtra = getLineSpacingExtra(); + + if (textLayout == null) { + return false; + } + + if (mShouldAddExtraSpacing && availableWidth > 0 && lineSpacingExtra > 0 && !TextUtils.isEmpty(text) && + getLayout().getLineCount() == 1) { + mLineSpacingExtraTop = Math.round(lineSpacingExtra / 2f); + mLineSpacingExtraBottom = (int) lineSpacingExtra / 2; + mShouldAddExtraSpacing = false; + return true; + } + return false; + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/menu/OverflowMenuSubscriber.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/menu/OverflowMenuSubscriber.java new file mode 100644 index 000000000..311bd4920 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/menu/OverflowMenuSubscriber.java @@ -0,0 +1,19 @@ +package com.yandex.div.view.menu; + +import androidx.annotation.NonNull; + +public interface OverflowMenuSubscriber { + /** + * Subscribes given listener for further callbacks invocation. + * + * @param listener listener + */ + void subscribe(@NonNull Listener listener); + + interface Listener { + /** + * Dismisses {@link OverflowMenuWrapper overflow menu's} popup. + */ + void dismiss(); + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/menu/OverflowMenuWrapper.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/menu/OverflowMenuWrapper.java new file mode 100644 index 000000000..a910c6e16 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/menu/OverflowMenuWrapper.java @@ -0,0 +1,330 @@ +package com.yandex.div.view.menu; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import androidx.annotation.ColorInt; +import androidx.annotation.DimenRes; +import androidx.annotation.DrawableRes; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.PopupMenu; +import com.yandex.div.core.util.Assert; +import com.yandex.div.core.views.R; +import com.yandex.div.util.Views; +import com.yandex.div.view.NonScrollImageView; + +/** + * Wraps given {@link android.view.View} into {@link android.widget.FrameLayout} + * with overflow popup menu view item on it. + */ +public class OverflowMenuWrapper { + @NonNull + private final Context mContext; + @NonNull + private final View mWrappedView; + + @Nullable + private final ViewGroup mParentView; + + private int mOverflowGravity = Gravity.TOP | Gravity.LEFT; + @ColorInt + private int mOverflowColor = Color.WHITE; + @IntRange(from = 0, to = 255) + private int mOverflowAlpha = 0xFF; + private int mMenuGravity = Gravity.LEFT | Gravity.BOTTOM; + @DrawableRes + private int mButtonResourceId = R.drawable.ic_more_vert_white_24dp; + @Nullable + private Listener mListener; + @Nullable + private View[] mHorizontallyCompetingViews = null; + @Nullable + private View[] mVerticallyCompetingViews = null; + @Nullable + private View mResultView; + @Nullable + private ImageView mOverflowMenuImageView; + private boolean mValid = false; + + @DimenRes + private final int mMenuHorizontalMargin; + @DimenRes + private final int mMenuVerticalMargin; + + @Nullable + private PopupMenu mPopupMenu; + + public OverflowMenuWrapper(@NonNull Context context, @NonNull View wrapView, @Nullable ViewGroup parent) { + this(context, wrapView, parent, R.dimen.overflow_menu_margin_horizontal, R.dimen.overflow_menu_margin_vertical); + } + + public OverflowMenuWrapper(@NonNull Context context, @NonNull View wrapView, @Nullable ViewGroup parent, + @DimenRes int menuHorizontalMargin, @DimenRes int menuVerticalMargin) { + mContext = context; + mWrappedView = wrapView; + mParentView = parent; + mMenuHorizontalMargin = menuHorizontalMargin; + mMenuVerticalMargin = menuVerticalMargin; + } + + /** + * Sets gravity of overflow view. It must be combination of two flags: + * {@link Gravity#TOP} or {@link Gravity#BOTTOM} and {@link Gravity#LEFT} or {@link Gravity#RIGHT} + * + * @param gravity view gravity flags from {@link Gravity} class. + * @return {@code this} instance. + */ + @NonNull + public OverflowMenuWrapper overflowGravity(int gravity) { + mOverflowGravity = gravity; + return this; + } + + /** + * Sets gravity of popup menu. + * + * @param gravity view gravity flags from {@link Gravity} class. + * @return {@code this} instance. + */ + @NonNull + public OverflowMenuWrapper menuGravity(int gravity) { + mMenuGravity = gravity; + return this; + } + + /** + * Sets color of overflow view. + * + * @param color view color + * @return {@code this} instance. + */ + @NonNull + public OverflowMenuWrapper color(@ColorInt int color) { + mOverflowColor = color; + return this; + } + + /** + * Sets alpha of overflow view. + * + * @param alpha view alpha from [0 .. 255] range + * @return {@code this} instance. + */ + @NonNull + public OverflowMenuWrapper alpha(@IntRange(from = 0, to = 255) int alpha) { + mOverflowAlpha = alpha; + return this; + } + + /** + * Sets event listener. Look at {@link Listener this} for listenable events. + * + * @param listener event listener + * @return {@code this} instance. + * @see Listener + */ + @NonNull + public OverflowMenuWrapper listener(@NonNull Listener listener) { + mListener = listener; + return this; + } + + /** + * Sets competing views - views that may overlap overflow view horizontally. + * After {@link #getView()} call, additional padding will be supplied for these views to prevent + * this overlap. + * + * @param competingViews views that may overlap overflow view. + * @return {@code this} instance. + */ + @NonNull + public OverflowMenuWrapper horizontallyCompetingViews(@NonNull View... competingViews) { + mHorizontallyCompetingViews = competingViews; + return this; + } + + /** + * Sets competing views - views that may overlap overflow view vertically. + * After {@link #getView()} call, additional padding will be supplied for these views to prevent + * this overlap. + * + * @param competingViews views that may overlap overflow view. + * @return {@code this} instance. + */ + @NonNull + public OverflowMenuWrapper verticallyCompetingViews(@NonNull View... competingViews) { + mVerticallyCompetingViews = competingViews; + return this; + } + + /** + * Sets the resource id for menu button. + * + * @param buttonResourceId resource id. + * @return {@code this} instance. + */ + @NonNull + public OverflowMenuWrapper buttonResourceId(@DrawableRes int buttonResourceId) { + mButtonResourceId = buttonResourceId; + return this; + } + + /** + * Marks overflow wrapper as not valid, making next {@link #getView()} + * call reapply style attributes. + */ + public void invalidate() { + mValid = false; + } + + @NonNull + public View getView() { + if (mValid && mResultView != null) { + return mResultView; + } + + if (mResultView == null || mOverflowMenuImageView == null) { + mOverflowMenuImageView = createOverflowMenuImageView(); + mResultView = createWrapperView(mOverflowMenuImageView); + } + + Assert.assertFalse(mValid); + + mOverflowMenuImageView.setImageDrawable(createMenuDrawable(mOverflowMenuImageView)); + // Setup overflow menu + mOverflowMenuImageView.setOnClickListener(getOnMenuClickListener()); + + mValid = true; + return mResultView; + } + + public View.OnClickListener getOnMenuClickListener() { + return view -> { + final PopupMenu popupMenu = new PopupMenu(view.getContext(), view, mMenuGravity); + if (mListener != null) { + mListener.onMenuCreated(popupMenu); + } + popupMenu.show(); + if (mListener != null) { + mListener.onPopupShown(); + } + mPopupMenu = popupMenu; + }; + } + + public void setMenuVisibility(int visibility) { + if (mValid) { + Assert.assertNotNull("mResultView is null in setMenuVisibility", mResultView); + mOverflowMenuImageView.setVisibility(visibility); + } + } + + public void redrawMenuIcon() { + if (mValid) { + Assert.assertNotNull("mResultView is null in redrawMenuIcon", mResultView); + mOverflowMenuImageView.setImageDrawable(createMenuDrawable(mOverflowMenuImageView)); + } + } + + @NonNull + protected Bitmap getBitmapResource(@DrawableRes int resId, @NonNull View useOnView) { + final Resources resources = mContext.getResources(); + return BitmapFactory.decodeResource(resources, resId); + } + + @NonNull + private Drawable createMenuDrawable(View useOnView) { + final Resources resources = mContext.getResources(); + final BitmapDrawable bitmapDrawable = new BitmapDrawable(resources, getBitmapResource(mButtonResourceId, useOnView)); + final Drawable overflowDrawable = bitmapDrawable.mutate(); + overflowDrawable.setColorFilter(mOverflowColor, PorterDuff.Mode.SRC_IN); + overflowDrawable.setAlpha(mOverflowAlpha); + return overflowDrawable; + } + + @NonNull + private View createWrapperView(@NonNull ImageView overflowMenuImageView) { + final FrameLayout frameLayout = new FrameLayout(mContext); + frameLayout.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + frameLayout.addView(mWrappedView); + frameLayout.addView(overflowMenuImageView); + + // Setup competing views padding to prevent overlapping + if (mHorizontallyCompetingViews != null) { + final boolean isOnRight = (mOverflowGravity & Gravity.RIGHT) != 0; + + for (final View view : mHorizontallyCompetingViews) { + Views.setPadding(view, R.dimen.overflow_menu_size, isOnRight ? Views.VIEW_SIDE_RIGHT : Views.VIEW_SIDE_LEFT); + } + } + + if (mVerticallyCompetingViews != null) { + final boolean isOnTop = (mOverflowGravity & Gravity.TOP) != 0; + + for (final View view : mVerticallyCompetingViews) { + Views.setPadding(view, R.dimen.overflow_menu_size, isOnTop ? Views.VIEW_SIDE_TOP : Views.VIEW_SIDE_BOTTOM); + } + } + + return frameLayout; + } + + private ImageView createOverflowMenuImageView() { + final Resources resources = mContext.getResources(); + final NonScrollImageView overflowMenu = new NonScrollImageView(mContext); + final FrameLayout.LayoutParams overflowMenuParams = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + overflowMenuParams.gravity = mOverflowGravity; + overflowMenu.setLayoutParams(overflowMenuParams); + overflowMenu.setId(R.id.overflow_menu); + + final int horizontalMargin = resources.getDimensionPixelSize(mMenuHorizontalMargin); + final int verticalMargin = resources.getDimensionPixelSize(mMenuVerticalMargin); + overflowMenu.setPadding(horizontalMargin, verticalMargin, horizontalMargin, 0); + + return overflowMenu; + } + + public void dismiss() { + if (mPopupMenu != null) { + mPopupMenu.dismiss(); + mPopupMenu = null; + } + } + + public interface Listener { + + /** + * Called when the popup menu is created. Used to initialize menu. + */ + void onMenuCreated(@NonNull PopupMenu popupMenu); + + /** + * Called when popup menu was shown. + */ + void onPopupShown(); + + class Simple implements Listener { + + @Override + public void onMenuCreated(@NonNull PopupMenu popupMenu) { + } + + @Override + public void onPopupShown() { + } + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/AdvanceViewPool.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/AdvanceViewPool.kt new file mode 100644 index 000000000..405dce517 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/AdvanceViewPool.kt @@ -0,0 +1,132 @@ +package com.yandex.div.view.pooling + +import android.view.View +import androidx.annotation.AnyThread +import androidx.annotation.WorkerThread +import com.yandex.div.core.util.Assert +import com.yandex.div.util.getOrThrow +import com.yandex.div.util.removeOrThrow +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.BlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +class AdvanceViewPool( + private val profiler: ViewPoolProfiler?, + private val viewCreator: ViewCreator +) : ViewPool { + + private val channels: MutableMap> = androidx.collection.ArrayMap() + + @AnyThread + override fun register(tag: String, factory: ViewFactory, capacity: Int) { + synchronized(channels) { + if (tag in channels) { + Assert.fail("Factory is already registered") + return + } + channels[tag] = Channel(tag, profiler, factory, viewCreator, capacity) + } + } + + @AnyThread + override fun unregister(tag: String) { + val channel = synchronized(channels) { + if (tag !in channels) { + Assert.fail("Factory is not registered") + return + } + channels.removeOrThrow(tag) + } + + channel.stop() + } + + @AnyThread + override fun obtain(tag: String): T { + val channel = synchronized(channels) { + channels.getOrThrow(tag, "Factory is not registered") + } + + @Suppress("UNCHECKED_CAST") + return channel.extractView() as T + } + + internal class Channel(val viewName: String, + private val profiler: ViewPoolProfiler?, + private val viewFactory: ViewFactory, + private val viewCreator: ViewCreator, + capacity: Int) { + + private val viewQueue: BlockingQueue = ArrayBlockingQueue(capacity, false) + private val stopped = AtomicBoolean(false) + + val notEmpty: Boolean = viewQueue.isNotEmpty() + + init { + for (i in 0 until capacity) { + viewCreator.request(this, 0) + } + } + + @AnyThread + fun extractView(): T { + var view: T? = null + var duration = profile { view = viewQueue.poll() } + + if (view == null) { + duration = profile { view = extractViewBlocked() } + profiler?.onViewObtainedWithBlock(viewName, duration) + } else { + profiler?.onViewObtainedWithoutBlock(duration) + } + requestViewCreation() + return view!! // there is no any chance for null + } + + @AnyThread + private fun extractViewBlocked(): T { + return try { + viewCreator.promote(this) + viewQueue.poll(MAX_WAITING_TIME, TimeUnit.MILLISECONDS) ?: viewFactory.createView() + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + viewFactory.createView() + } + } + + private fun requestViewCreation() { + val duration = profile { + val priority = viewQueue.size + viewCreator.request(this, priority) + } + profiler?.onViewRequested(duration) + } + + @WorkerThread + fun createAndEnqueueView() { + val stopped = stopped.get() + if (!stopped) { + try { + val view = viewFactory.createView() + viewQueue.offer(view) + } catch (ignored: Exception) { } + } + } + + fun stop() { + stopped.set(true) + viewQueue.clear() + } + + private inline fun profile(crossinline section: () -> Unit): Long { + val start = System.nanoTime() + section() + return System.nanoTime() - start + } + + companion object { + private const val MAX_WAITING_TIME = 16L + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/BatchBlockingQueue.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/BatchBlockingQueue.kt new file mode 100644 index 000000000..00672c529 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/BatchBlockingQueue.kt @@ -0,0 +1,122 @@ +package com.yandex.div.view.pooling + +import com.yandex.div.util.removeFirstIf +import java.util.AbstractQueue +import java.util.Queue +import java.util.Spliterator +import java.util.concurrent.BlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock + +internal class BatchBlockingQueue(backingQueue: Queue) : AbstractQueue(), BlockingQueue { + + private val queue = backingQueue + private val lock = ReentrantLock() + private val notEmpty = lock.newCondition() + + override val size: Int + get() = locked { + queue.size + } + + override fun add(element: E): Boolean = offer(element) + + override fun offer(element: E): Boolean { + locked { + queue.offer(element) + notEmpty.signal() + } + return true + } + + override fun put(element: E) { + offer(element) + } + + override fun offer(element: E, timeout: Long, unit: TimeUnit) = offer(element) + + override fun poll(): E? { + locked { + return queue.poll() + } + } + + @Throws(InterruptedException::class) + override fun poll(timeout: Long, unit: TimeUnit): E? { + val result: E? = lockedInterruptibly { + var nanos = unit.toNanos(timeout) + while (queue.isEmpty() && nanos > 0) { + nanos = notEmpty.awaitNanos(nanos) + } + queue.poll() + } + + return result + } + + @Throws(InterruptedException::class) + override fun take(): E { + val result = lockedInterruptibly { + while (queue.isEmpty()) { + notEmpty.await() + } + queue.poll() + } + + return result + } + + override fun peek(): E { + locked { + return queue.peek() + } + } + + override fun remove(element: E): Boolean { + locked { + return queue.remove(element) + } + } + + override fun remainingCapacity() = Int.MAX_VALUE + + inline fun removeFirstIf(predicate: (E) -> Boolean): Boolean { + locked { + return queue.removeFirstIf(predicate) + } + } + + inline fun batch(batch: BatchBlockingQueue.() -> Unit) { + locked { + batch(this) + } + } + + override fun drainTo(c: MutableCollection?): Int { notSupported() } + + override fun drainTo(c: MutableCollection?, maxElements: Int): Int { notSupported() } + + override fun iterator(): MutableIterator { notSupported() } + + override fun spliterator(): Spliterator { notSupported() } + + private fun notSupported(): Nothing = throw UnsupportedOperationException() + + private inline fun locked(action: () -> R): R { + lock.lock() + try { + return action() + } finally { + lock.unlock() + } + } + + private inline fun lockedInterruptibly(action: () -> R): R { + lock.lockInterruptibly() + try { + return action() + } finally { + lock.unlock() + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ProfilingSession.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ProfilingSession.kt new file mode 100644 index 000000000..11c369a7b --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ProfilingSession.kt @@ -0,0 +1,129 @@ +package com.yandex.div.view.pooling + +import androidx.annotation.VisibleForTesting + +internal class ProfilingSession { + + private val mOverallTimeAccumulator = Accumulator() + private val mLongRequestAccumulator = Accumulator() + private val mBlockedViewAccumulators = androidx.collection.ArrayMap() + + companion object { + + private const val LONG_OPERATION_THRESHOLD_NS = 1_000_000L + + private fun toMicroseconds(timeNs: Long): Long = timeNs / 1000L + } + + fun viewObtainedWithoutBlock(durationNs: Long) { + mOverallTimeAccumulator.addAndIncrement(durationNs) + } + + fun viewObtainedWithBlock(viewName: String, durationNs: Long) { + mOverallTimeAccumulator.addAndIncrement(durationNs) + + val blockedViewAccumulator: Accumulator = mBlockedViewAccumulators.getOrPut(viewName) { Accumulator() } + blockedViewAccumulator.addAndIncrement(durationNs) + } + + fun viewRequested(durationNs: Long) { + mOverallTimeAccumulator.add(durationNs) + if (durationNs >= LONG_OPERATION_THRESHOLD_NS) mLongRequestAccumulator.addAndIncrement(durationNs) + } + + fun hasLongEvents(): Boolean { + if (mLongRequestAccumulator.count > 0) return true + + for ((_, acc) in mBlockedViewAccumulators) { + if (acc.count > 0) return true + } + + return false + } + + fun flush(): Map { + val result = HashMap() + + result["view obtaining - total count"] = mOverallTimeAccumulator.count + result["view obtaining - total time (µs)"] = toMicroseconds(mOverallTimeAccumulator.accumulated).roundRoughly() + + for ((name, acc) in mBlockedViewAccumulators) { + if (acc.count > 0) { + result["blocking view obtaining for $name - count"] = acc.count + result["blocking view obtaining for $name - avg time (µs)"] = toMicroseconds(acc.avg).roundRoughly() + } + } + + if (mLongRequestAccumulator.count > 0) { + result["long view requests - count"] = mLongRequestAccumulator.count + result["long view requests - avg time (µs)"] = toMicroseconds(mLongRequestAccumulator.avg).roundRoughly() + } + + return result + } + + fun clear() { + mOverallTimeAccumulator.reset() + mLongRequestAccumulator.reset() + mBlockedViewAccumulators.forEach { (_, acc) -> acc.reset() } + } + + private class Accumulator { + + var accumulated = 0L + private set + + var count = 0 + private set + + val avg: Long + get() = if (count == 0) 0 else accumulated / count + + fun add(value: Long) { + accumulated += value + } + + fun addAndIncrement(value: Long) { + add(value) + count++ + } + + fun reset() { + accumulated = 0L + count = 0 + } + } +} + +/** + * Returns the largest value that is less than or equal to the + * argument and is divisible by a some factor. Factor can be: + *

      + *
    1. 20 - value in [0, 100)
    2. + *
    3. 100 - value in [100, 1000)
    4. + *
    5. 200 - value in [1000, 2000)
    6. + *
    7. 500 - value in [2000, 5000)
    8. + *
    9. 1000 - value in [5000, 10000)
    10. + *
    11. 2000 - value in [10000, 20000)
    12. + *
    13. 5000 - value in [20000, 50000)
    14. + *
    + * + * Special cases: it always returns 0 for negative values and 50000 for values greater than 50000. + */ +@VisibleForTesting +internal fun Long.roundRoughly(): Long { + val rounded = when { + this < 0L -> 0L + this < 100L -> this floorTo 20L + this < 1000L -> this floorTo 100L + this < 2000L -> this floorTo 200L + this < 5000L -> this floorTo 500L + this < 10000L -> this floorTo 1000L + this < 20000L -> this floorTo 2000L + this < 50000L -> this floorTo 5000L + else -> 50000L + } + return rounded +} + +private infix fun Long.floorTo(e: Long) = (this / e) * e diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/PseudoViewPool.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/PseudoViewPool.kt new file mode 100644 index 000000000..fa528d3af --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/PseudoViewPool.kt @@ -0,0 +1,23 @@ +package com.yandex.div.view.pooling + +import android.view.View +import com.yandex.div.util.getOrThrow +import java.util.concurrent.ConcurrentHashMap + +class PseudoViewPool : ViewPool { + + private val factoryMap = ConcurrentHashMap>() + + override fun register(tag: String, factory: ViewFactory, capacity: Int) { + factoryMap[tag] = factory + } + + override fun unregister(tag: String) { + factoryMap.remove(tag) + } + + override fun obtain(tag: String): T { + @Suppress("UNCHECKED_CAST") + return factoryMap.getOrThrow(tag).createView() as T + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewCreator.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewCreator.kt new file mode 100644 index 000000000..c515cc210 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewCreator.kt @@ -0,0 +1,127 @@ +package com.yandex.div.view.pooling + +import com.yandex.div.core.histogram.CpuUsageHistogramReporter +import com.yandex.div.util.weak +import java.util.PriorityQueue + +class ViewCreator(cpuUsageHistogramReporter: CpuUsageHistogramReporter) { + + private val creatorThread = ViewCreatorThread("ViewPoolThread", cpuUsageHistogramReporter) + + init { + creatorThread.start() + } + + internal fun request(channel: AdvanceViewPool.Channel<*>, priority: Int) { + creatorThread.taskQueue.offer(CreateViewTask(channel, priority)) + } + + internal fun promote(channel: AdvanceViewPool.Channel<*>) { + if (channel.viewName == creatorThread.currentViewName || channel.notEmpty) return + + creatorThread.taskQueue.batch { + // double check + if (channel.viewName == creatorThread.currentViewName || channel.notEmpty) return + + creatorThread.taskQueue.removeFirstIf { task -> task.viewName == channel.viewName } + creatorThread.taskQueue.offer(CreateViewTask(channel, TASK_HIGHEST_PRIORITY)) + } + } + + private class ViewCreatorThread( + name: String, + private val cpuUsageHistogramReporter: CpuUsageHistogramReporter + ) : Thread(name) { + + val taskQueue = BatchBlockingQueue( + PriorityQueue(TASK_QUEUE_INITIAL_CAPACITY) + ) + + @Volatile + var currentViewName: String? = null + private set + + init { + priority = THREAD_DEFAULT_PRIORITY + } + + override fun run() { + val histogramReporting = + cpuUsageHistogramReporter.startReporting(CPU_USAGE_HISTOGRAM_NAME, + android.os.Process.myTid()) + try { + while (true) { + createView() + } + } catch (e: InterruptedException) { + currentThread().interrupt() + } finally { + histogramReporting.cancel() + } + } + + @Throws(InterruptedException::class) + private fun createView() { + val task: CreateViewTask = taskQueue.poll() ?: run { + // We lower the priority of the creator thread in order + // to reduce delays at inserts to the task queue. + try { + priority = THREAD_LOW_PRIORITY + taskQueue.take() + } finally { + priority = THREAD_DEFAULT_PRIORITY + } + } + + currentViewName = task.viewName + task.run() + currentViewName = null + } + } + + private class CreateViewTask(channel: AdvanceViewPool.Channel<*>, + private val priority: Int) : Runnable, Comparable { + + val viewName = channel.viewName + private val channelRef by weak(channel) + + override fun run() { + channelRef?.createAndEnqueueView() + } + + override fun compareTo(other: CreateViewTask): Int { + val priorityDiff = priority - other.priority + if (priorityDiff != 0) { + return priorityDiff + } + return if (viewName == other.viewName) 0 else 1 + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CreateViewTask + + if (viewName != other.viewName) return false + if (priority != other.priority) return false + + return true + } + + override fun hashCode(): Int { + var result = 223 + result = 31 * result + priority + result = 31 * result + viewName.hashCode() + return result + } + } + + private companion object { + private const val THREAD_DEFAULT_PRIORITY = Thread.NORM_PRIORITY + private const val THREAD_LOW_PRIORITY = 3 + private const val TASK_HIGHEST_PRIORITY = -1 + private const val TASK_QUEUE_INITIAL_CAPACITY = 32 + private const val CPU_USAGE_HISTOGRAM_NAME = "Div.ViewPool.CPU" + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewFactory.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewFactory.java new file mode 100644 index 000000000..bc400c37e --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewFactory.java @@ -0,0 +1,10 @@ +package com.yandex.div.view.pooling; + +import android.view.View; +import androidx.annotation.NonNull; + +public interface ViewFactory { + + @NonNull + T createView(); +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewPool.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewPool.java new file mode 100644 index 000000000..861e9a55f --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewPool.java @@ -0,0 +1,14 @@ +package com.yandex.div.view.pooling; + +import android.view.View; +import androidx.annotation.NonNull; + +public interface ViewPool { + + void register(@NonNull String tag, @NonNull ViewFactory factory, int capacity); + + void unregister(@NonNull String tag); + + @NonNull + T obtain(@NonNull String tag); +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewPoolProfiler.kt b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewPoolProfiler.kt new file mode 100644 index 000000000..d425e57d8 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/pooling/ViewPoolProfiler.kt @@ -0,0 +1,75 @@ +package com.yandex.div.view.pooling + +import android.os.Handler +import android.os.Looper +import androidx.annotation.AnyThread + +class ViewPoolProfiler(private val reporter: Reporter) { + + private val session = ProfilingSession() + private val frameWatcher = FrameWatcher() + private val handler = Handler(Looper.getMainLooper()) + + @AnyThread + fun onViewObtainedWithoutBlock(durationNs: Long) { + synchronized(session) { + session.viewObtainedWithoutBlock(durationNs) + frameWatcher.watch(handler) + } + } + + @AnyThread + fun onViewObtainedWithBlock(viewName: String, durationNs: Long) { + synchronized(session) { + session.viewObtainedWithBlock(viewName, durationNs) + frameWatcher.watch(handler) + } + } + + @AnyThread + fun onViewRequested(durationNs: Long) { + synchronized(session) { + session.viewRequested(durationNs) + frameWatcher.watch(handler) + } + } + + internal fun onFrameReady() { + synchronized(session) { + if (session.hasLongEvents()) { + val result = session.flush() + reporter.reportEvent("view pool profiling", result) + } + session.clear() + } + } + + private inner class FrameWatcher : Runnable { + + private var watching: Boolean = false + + override fun run() { + onFrameReady() + watching = false + } + + fun watch(handler: Handler) { + if (!watching) { + handler.post(this) + watching = true + } + } + } + + interface Reporter { + fun reportEvent(message: String, result: Map) + + companion object { + + @JvmField + val NO_OP = object : Reporter { + override fun reportEvent(message: String, result: Map) = Unit + } + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/BaseCardHeightCalculator.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/BaseCardHeightCalculator.java new file mode 100644 index 000000000..fb171a4e0 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/BaseCardHeightCalculator.java @@ -0,0 +1,128 @@ +package com.yandex.div.view.tabs; + +import android.os.Bundle; +import android.os.Parcelable; +import android.util.SparseArray; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.CallSuper; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.div.core.util.Log; +import com.yandex.div.core.views.R; +import com.yandex.div.util.Safe; + +@MainThread +public abstract class BaseCardHeightCalculator implements ViewPagerFixedSizeLayout.HeightCalculator { + + private static final String TAG = "[Y:BaseCardHeightCalculator]"; + + private static final String KEY_FONT_SCALE = "FONT_SCALE"; + + @NonNull + private final ViewGroup mChannelGroup; + + @NonNull + private final HeightCalculatorFactory.MeasureTabHeightFn mMeasureTabHeightFn; + + @NonNull + private final HeightCalculatorFactory.GetTabCountFn mGetTabCountFn; + + @NonNull + protected final SparseArray mTabsHeightCache = new SparseArray<>(); + @Nullable + private Bundle mPendingState; + + private int mPosition = 0; + + private float mPositionOffset = 0; + + protected BaseCardHeightCalculator(@NonNull ViewGroup channelGroup, + @NonNull HeightCalculatorFactory.MeasureTabHeightFn measureTabHeightFn, + @NonNull HeightCalculatorFactory.GetTabCountFn getTabCountFn) { + mChannelGroup = channelGroup; + mMeasureTabHeightFn = measureTabHeightFn; + mGetTabCountFn = getTabCountFn; + } + + private float getFontScale() { + return mChannelGroup.getContext().getResources().getConfiguration().fontScale; + } + + @Override + @CallSuper + public void saveInstanceState(@NonNull SparseArray container) { + Bundle bundle = new Bundle(); + for (int i = 0, n = mTabsHeightCache.size(); i < n; ++i) { + mTabsHeightCache.valueAt(i).saveState(bundle, mTabsHeightCache.keyAt(i)); + } + bundle.putFloat(KEY_FONT_SCALE, getFontScale()); + container.put(R.id.tab_height_cache, bundle); + } + + @Override + @CallSuper + public void restoreInstanceState(@NonNull SparseArray container) { + mTabsHeightCache.clear(); + mPendingState = (Bundle) container.get(R.id.tab_height_cache); + if (!Safe.equal(getFontScale(), mPendingState == null ? null : mPendingState.getFloat(KEY_FONT_SCALE))) { + mPendingState = null; + } + } + + @Override + public int measureHeight(int widthMeasureSpec, int heightMeasureSpec) { + TabMeasurement measurement = mTabsHeightCache.get(widthMeasureSpec); + if (measurement == null) { + int tabCount = mGetTabCountFn.apply(); + + if (tabCount == 0) { + return 0; + } + + int width = View.MeasureSpec.getSize(widthMeasureSpec); + measurement = new TabMeasurement(tabCount, tabIndex -> mMeasureTabHeightFn.apply(mChannelGroup, width, tabIndex)); + if (mPendingState != null) { + measurement.restoreState(mPendingState, widthMeasureSpec); + measurement.removeState(mPendingState, widthMeasureSpec); + if (mPendingState.isEmpty()) { + mPendingState = null; + } + } + + mTabsHeightCache.put(widthMeasureSpec, measurement); + } + + return logAndReturnHeight(getOptimalHeight(measurement, mPosition, mPositionOffset), + mPosition, mPositionOffset); + } + + @Override + public void setPositionAndOffsetForMeasure(int position, float positionOffset) { + Log.d(TAG, "request layout for tab " + position + " with position offset " + positionOffset); + mPosition = position; + mPositionOffset = positionOffset; + } + + @Override + public void dropMeasureCache() { + Log.d(TAG, "reseting layout..."); + mPendingState = null; + mTabsHeightCache.clear(); + } + + protected boolean isTabsHeightsIsUnknown() { + return mTabsHeightCache.size() == 0; + } + + protected abstract int getOptimalHeight(@NonNull TabMeasurement measurement, + int position, + float positionOffset); + + private static int logAndReturnHeight(int height, int position, float positionOffset) { + Log.d(TAG, "New optimal height for tab " + position + " with position offset " + + positionOffset + " is " + height); + return height; + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/BaseIndicatorTabLayout.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/BaseIndicatorTabLayout.java new file mode 100644 index 000000000..ec53a3e58 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/BaseIndicatorTabLayout.java @@ -0,0 +1,1856 @@ +package com.yandex.div.view.tabs; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.database.DataSetObserver; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; +import androidx.annotation.ColorInt; +import androidx.annotation.IntDef; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.util.Pools; +import androidx.core.view.GravityCompat; +import androidx.core.view.ViewCompat; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; +import com.yandex.div.core.util.Log; +import com.yandex.div.core.views.R; +import com.yandex.div.font.DivTypefaceProvider; +import com.yandex.div.util.AnimationUtils; +import com.yandex.div.util.NestedHorizontalScrollCompanion; +import com.yandex.div.util.SizeKt; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; + +import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_DRAGGING; +import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_IDLE; +import static androidx.viewpager.widget.ViewPager.SCROLL_STATE_SETTLING; + +/** + * TabLayout provides a horizontal layout to display tabs. + * + *

    Population of the tabs to display is + * done through {@link Tab} instances. You create tabs via {@link #newTab()}. From there you can + * change the tab's label via {@link Tab#setText(int)} + * respectively. To display the tab, you need to add it to the layout via one of the + * {@link #addTab(Tab)} methods. For example: + *

    + * TabLayout tabLayout = ...;
    + * tabLayout.addTab(tabLayout.newTab().setText("Tab 1"));
    + * tabLayout.addTab(tabLayout.newTab().setText("Tab 2"));
    + * tabLayout.addTab(tabLayout.newTab().setText("Tab 3"));
    + * 
    + * You should set a listener via {@link #setOnTabSelectedListener(OnTabSelectedListener)} to be + * notified when any tab's selection state has been changed. + *

    + * If you're using a {@link ViewPager} together + * with this layout, you can use {@link #setupWithViewPager(ViewPager)} to link the two together. + * This layout will be automatically populated from the {@link PagerAdapter}'s page titles.

    + * + *

    You can also add items to TabLayout in your layout through the use of {@link TabItem}. + * An example usage is like so:

    + * + *
    + * <android.support.design.widget.TabLayout
    + *         android:layout_height="wrap_content"
    + *         android:layout_width="match_parent">
    + *
    + *     <android.support.design.widget.TabItem
    + *             android:text="@string/tab_text"/>
    + *
    + * </android.support.design.widget.TabLayout>
    + * 
    + * + * + * @see Tabs + * + * @attr ref android.support.design.R.styleable#TabLayout_tabPadding + * @attr ref android.support.design.R.styleable#TabLayout_tabPaddingStart + * @attr ref android.support.design.R.styleable#TabLayout_tabPaddingTop + * @attr ref android.support.design.R.styleable#TabLayout_tabPaddingEnd + * @attr ref android.support.design.R.styleable#TabLayout_tabPaddingBottom + * @attr ref android.support.design.R.styleable#TabLayout_tabContentStart + * @attr ref android.support.design.R.styleable#TabLayout_tabMinWidth + * @attr ref android.support.design.R.styleable#TabLayout_tabMaxWidth + * @attr ref android.support.design.R.styleable#TabLayout_tabTextAppearance + */ +@SuppressWarnings("checkstyle:all") +public class BaseIndicatorTabLayout extends HorizontalScrollView { + private static final int INVALID_WIDTH = -1; + private static final int DEFAULT_HEIGHT = 44; // dps + private static final int TAB_MIN_WIDTH_MARGIN = 56; //dps + + private static final int ANIMATION_DURATION = 300; + + private static final Pools.Pool sTabPool = new Pools.SynchronizedPool<>(16); + + public static final int UNDEFINED_COLOR = -1; + + /** + * Scrollable tabs display a subset of tabs at any given moment, and can contain longer tab + * labels and a larger number of tabs. They are best used for browsing contexts in touch + * interfaces when users don’t need to directly compare the tab labels. + * + * @see #setTabMode(int) + * @see #getTabMode() + */ + public static final int MODE_SCROLLABLE = 0; + + /** + * Fixed tabs display all tabs concurrently and are best used with content that benefits from + * quick pivots between tabs. The maximum number of tabs is limited by the view’s width. + * Fixed tabs have equal width, based on the widest tab label. + * + * @see #setTabMode(int) + * @see #getTabMode() + */ + public static final int MODE_FIXED = 1; + + @IntDef(value = {MODE_SCROLLABLE, MODE_FIXED}) + @Retention(RetentionPolicy.SOURCE) + public @interface Mode {} + + /** + * Callback interface invoked when a tab's selection state changes. + */ + public interface OnTabSelectedListener { + + /** + * Called when a tab enters the selected state. + * + * @param tab The tab that was selected + */ + void onTabSelected(Tab tab); + + /** + * Called when a tab exits the selected state. + * + * @param tab The tab that was unselected + */ + void onTabUnselected(Tab tab); + + /** + * Called when a tab that is already selected is chosen again by the user. Some applications + * may use this action to return to the top level of a category. + * + * @param tab The tab that was reselected. + */ + void onTabReselected(Tab tab); + } + + private final ArrayList mTabs = new ArrayList<>(); + private Tab mSelectedTab; + + private final OvalIndicators mTabIndicators; + + private int mTabPaddingStart; + private int mTabPaddingTop; + private int mTabPaddingEnd; + private int mTabPaddingBottom; + + private int mAnimationDuration = ANIMATION_DURATION; + + private int mTabTextAppearance; + private DivTypefaceProvider mTypefaceProvider = DivTypefaceProvider.DEFAULT; + private ColorStateList mTabTextColors; + private boolean mTabTextBoldOnSelection; + + private int mTabMaxWidth = Integer.MAX_VALUE; + private final int mRequestedTabMinWidth; + private final int mRequestedTabMaxWidth; + private final int mScrollableTabMinWidth; + + private final boolean mIsTabEllipsizeEnabled; + private final boolean mTabScrollPaddingEnabled; + private final int mTabScrollPadding; + private final NestedHorizontalScrollCompanion mNestedScrollCompanion = new NestedHorizontalScrollCompanion(this); + + private int mContentInsetStart; + private int mContentInsetEnd; + + private int mMode; + + private OnTabSelectedListener mOnTabSelectedListener; + + private ValueAnimator mScrollAnimator; + + private ViewPager mViewPager; + private PagerAdapter mPagerAdapter; + private DataSetObserver mPagerAdapterObserver; + private TabLayoutOnPageChangeListener mPageChangeListener; + + // Pool we use as a simple RecyclerBin + @NonNull + private final Pools.Pool mTabViewPool = new Pools.SimplePool<>(12); + + public BaseIndicatorTabLayout(Context context) { + this(context, null); + } + + public BaseIndicatorTabLayout(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + @SuppressLint("PrivateResource") + public BaseIndicatorTabLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + // Disable the Scroll Bar + setHorizontalScrollBarEnabled(false); + + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TabLayout, + defStyleAttr, R.style.Widget_Div_BaseIndicatorTabLayout); + + TypedArray b = context.obtainStyledAttributes(attrs, R.styleable.BaseIndicatorTabLayout, 0, 0); + int indicatorPaddingTop = b.getDimensionPixelSize(R.styleable.BaseIndicatorTabLayout_tabIndicatorPaddingTop, 0); + int indicatorPaddingBottom = b.getDimensionPixelSize(R.styleable.BaseIndicatorTabLayout_tabIndicatorPaddingBottom, 0); + mTabTextBoldOnSelection = b.getBoolean(R.styleable.BaseIndicatorTabLayout_tabTextBoldOnSelection, false); + mContentInsetEnd = b.getDimensionPixelSize(R.styleable.BaseIndicatorTabLayout_tabContentEnd, 0); + mIsTabEllipsizeEnabled = b.getBoolean(R.styleable.BaseIndicatorTabLayout_tabEllipsizeEnabled, true); + mTabScrollPaddingEnabled = b.getBoolean(R.styleable.BaseIndicatorTabLayout_tabScrollPaddingEnabled, false); + mTabScrollPadding = b.getDimensionPixelSize(R.styleable.BaseIndicatorTabLayout_tabScrollPadding, 0); + b.recycle(); + + mTabIndicators = new OvalIndicators(context, indicatorPaddingTop, indicatorPaddingBottom); + super.addView(mTabIndicators, 0, new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)); + + //todo Indicator height is currently not supported + mTabIndicators.setIndicatorHeight( + a.getDimensionPixelSize(R.styleable.TabLayout_tabIndicatorHeight, 0)); + mTabIndicators.setSelectedIndicatorColor(a.getColor(R.styleable.TabLayout_tabIndicatorColor, 0)); + mTabIndicators.setIndicatorColor(a.getColor(R.styleable.TabLayout_tabBackground, 0)); + + mTabPaddingStart = mTabPaddingTop = mTabPaddingEnd = mTabPaddingBottom = a + .getDimensionPixelSize(R.styleable.TabLayout_tabPadding, 0); + mTabPaddingStart = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingStart, + mTabPaddingStart); + mTabPaddingTop = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingTop, + mTabPaddingTop); + mTabPaddingEnd = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingEnd, + mTabPaddingEnd); + mTabPaddingBottom = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingBottom, + mTabPaddingBottom); + + mTabTextAppearance = a.getResourceId(R.styleable.TabLayout_tabTextAppearance, + R.style.TextAppearance_Div_Tab); + + // Text colors/sizes come from the text appearance first + final TypedArray ta = context.obtainStyledAttributes(mTabTextAppearance, + R.styleable.TextAppearance); + try { + mTabTextColors = ta.getColorStateList(R.styleable.TextAppearance_android_textColor); + } finally { + ta.recycle(); + } + + if (a.hasValue(R.styleable.TabLayout_tabTextColor)) { + // If we have an explicit text color set, use it instead + mTabTextColors = a.getColorStateList(R.styleable.TabLayout_tabTextColor); + } + + if (a.hasValue(R.styleable.TabLayout_tabSelectedTextColor)) { + // We have an explicit selected text color set, so we need to make merge it with the + // current colors. This is exposed so that developers can use theme attributes to set + // this (theme attrs in ColorStateLists are Lollipop+) + final int selected = a.getColor(R.styleable.TabLayout_tabSelectedTextColor, 0); + mTabTextColors = createColorStateList(mTabTextColors.getDefaultColor(), selected); + } + + mRequestedTabMinWidth = a.getDimensionPixelSize(R.styleable.TabLayout_tabMinWidth, + INVALID_WIDTH); + mRequestedTabMaxWidth = a.getDimensionPixelSize(R.styleable.TabLayout_tabMaxWidth, + INVALID_WIDTH); + mContentInsetStart = a.getDimensionPixelSize(R.styleable.TabLayout_tabContentStart, 0); + mMode = a.getInt(R.styleable.TabLayout_tabMode, MODE_FIXED); + a.recycle(); + + // TODO add attr for these + final Resources res = getResources(); + mScrollableTabMinWidth = res.getDimensionPixelSize(R.dimen.tab_scrollable_min_width); + + // Now apply the tab mode and gravity + applyModeAndGravity(); + } + + /** + * Sets the tab indicator's color for the currently selected tab. + * + * @param color color to use for the indicator + * @attr ref android.support.design.R.styleable#TabLayout_tabIndicatorColor + */ + public void setSelectedTabIndicatorColor(@ColorInt int color) { + mTabIndicators.setSelectedIndicatorColor(color); + } + + /** + * Sets the tab background color. + * + * @param backgroundColor background color for the tab + * @attr ref android.support.design.R.styleable#TabLayout_tabBackgroundColor + */ + public void setTabBackgroundColor(@ColorInt int backgroundColor) { + mTabIndicators.setIndicatorColor(backgroundColor); + } + + /** + * Sets the tab indicator's corners. + * Each corner receives two radius values [X, Y]. + * The corners are ordered top-left, top-right, bottom-right, bottom-left. + * + * @param radii Array of 8 values, 4 pairs of [X,Y] radii + */ + public void setTabIndicatorCornersRadii(@NonNull float[] radii) { + mTabIndicators.setIndicatorCornersRadii(radii); + } + + /** + * Sets the item spacing between tabs. + * + * @param itemSpacing value of item spacing between tabs + */ + public void setTabItemSpacing(int itemSpacing) { + mTabIndicators.setItemSpacing(itemSpacing); + } + + public void setAnimationType(AnimationType animationType) { + mTabIndicators.setAnimationType(animationType); + } + + public void setAnimationDuration(int duration) { + mAnimationDuration = duration; + } + + /** + * Sets the tab indicator's height for the currently selected tab. + * todo Indicator height is currently not supported. + * + * @param height height to use for the indicator in pixels + * @attr ref android.support.design.R.styleable#TabLayout_tabIndicatorHeight + */ + public void setTabIndicatorHeight(int height) { + mTabIndicators.setIndicatorHeight(height); + } + + /** + * Set the scroll position of the tabs. This is useful for when the tabs are being displayed as + * part of a scrolling container such as {@link ViewPager}. + *

    + * Calling this method does not update the selected tab, it is only used for drawing purposes. + * + * @param position current scroll position + * @param positionOffset Value from [0, 1) indicating the offset from {@code position}. + * @param updateSelectedText Whether to update the text's selected state. + */ + public void setScrollPosition(int position, float positionOffset, boolean updateSelectedText) { + setScrollPosition(position, positionOffset, updateSelectedText, true); + } + + private void setScrollPosition(int position, float positionOffset, boolean updateSelectedText, + boolean updateIndicatorPosition) { + final int roundedPosition = Math.round(position + positionOffset); + if (roundedPosition < 0 || roundedPosition >= mTabIndicators.getChildCount()) { + return; + } + + // Set the indicator position, if enabled + if (updateIndicatorPosition) { + mTabIndicators.setSelectedIndicatorPositionFromTabPosition(position, positionOffset); + } + + // Now update the scroll position, canceling any running animation + if (mScrollAnimator != null && mScrollAnimator.isRunning()) { + mScrollAnimator.cancel(); + } + scrollTo(calculateScrollXForTab(position, positionOffset), 0); + + if (updateSelectedText) { + setSelectedTabView(roundedPosition); + } + } + + /** + * Set tab title paddings + * @param start + * @param top + * @param end + * @param bottom + */ + public void setTabPaddings(int start, int top, int end, int bottom) { + mTabPaddingStart = start; + mTabPaddingTop = top; + mTabPaddingEnd = end; + mTabPaddingBottom = bottom; + requestLayout(); + } + + /** + * Add a tab to this layout. The tab will be added at the end of the list. + * If this is the first tab to be added it will become the selected tab. + * + * @param tab Tab to add + */ + public void addTab(@NonNull Tab tab) { + addTab(tab, mTabs.isEmpty()); + } + + /** + * Add a tab to this layout. The tab will be inserted at position. + * If this is the first tab to be added it will become the selected tab. + * + * @param tab The tab to add + * @param position The new position of the tab + */ + @SuppressWarnings("unused") + public void addTab(@NonNull Tab tab, int position) { + addTab(tab, position, mTabs.isEmpty()); + } + + /** + * Add a tab to this layout. The tab will be added at the end of the list. + * + * @param tab Tab to add + * @param setSelected True if the added tab should become the selected tab. + */ + public void addTab(@NonNull Tab tab, boolean setSelected) { + if (tab.mParent != this) { + throw new IllegalArgumentException("Tab belongs to a different TabLayout."); + } + + addTabView(tab, setSelected); + configureTab(tab, mTabs.size()); + if (setSelected) { + tab.select(); + } + } + + /** + * Add a tab to this layout. The tab will be inserted at position. + * + * @param tab The tab to add + * @param position The new position of the tab + * @param setSelected True if the added tab should become the selected tab. + */ + public void addTab(@NonNull Tab tab, int position, boolean setSelected) { + if (tab.mParent != this) { + throw new IllegalArgumentException("Tab belongs to a different TabLayout."); + } + + addTabView(tab, position, setSelected); + configureTab(tab, position); + if (setSelected) { + tab.select(); + } + } + + private void addTabFromItemView(@NonNull TabItem item) { + final Tab tab = newTab(); + if (item.text != null) { + tab.setText(item.text); + } + addTab(tab); + } + + /** + * Set the {@link OnTabSelectedListener} that will + * handle switching to and from tabs. + * + * @param onTabSelectedListener Listener to handle tab selection events + */ + public void setOnTabSelectedListener(OnTabSelectedListener onTabSelectedListener) { + mOnTabSelectedListener = onTabSelectedListener; + } + + /** + * Create and return a new {@link Tab}. You need to manually add this using + * {@link #addTab(Tab)} or a related method. + * + * @return A new Tab + * @see #addTab(Tab) + */ + @NonNull + public Tab newTab() { + Tab tab = sTabPool.acquire(); + if (tab == null) { + tab = new Tab(); + } + tab.mParent = this; + tab.mView = getTabView(tab); + return tab; + } + + /** + * Returns the number of tabs currently registered with the action bar. + * + * @return Tab count + */ + public int getTabCount() { + return mTabs.size(); + } + + /** + * Returns the tab at the specified index. + */ + @Nullable + public Tab getTabAt(int index) { + return mTabs.get(index); + } + + /** + * Returns the position of the current selected tab. + * + * @return selected tab position, or {@code -1} if there isn't a selected tab. + */ + public int getSelectedTabPosition() { + return mSelectedTab != null ? mSelectedTab.getPosition() : -1; + } + + /** + * Remove a tab from the layout. If the removed tab was selected it will be deselected + * and another tab will be selected if present. + * + * @param tab The tab to remove + */ + @SuppressWarnings("unused") + public void removeTab(Tab tab) { + if (tab.mParent != this) { + throw new IllegalArgumentException("Tab does not belong to this TabLayout."); + } + + removeTabAt(tab.getPosition()); + } + + /** + * Remove a tab from the layout. If the removed tab was selected it will be deselected + * and another tab will be selected if present. + * + * @param position Position of the tab to remove + */ + public void removeTabAt(int position) { + final int selectedTabPosition = mSelectedTab != null ? mSelectedTab.getPosition() : 0; + removeTabViewAt(position); + + final Tab removedTab = mTabs.remove(position); + if (removedTab != null) { + removedTab.reset(); + sTabPool.release(removedTab); + } + + final int newTabCount = mTabs.size(); + for (int i = position; i < newTabCount; i++) { + mTabs.get(i).setPosition(i); + } + + if (selectedTabPosition == position) { + selectTab(mTabs.isEmpty() ? null : mTabs.get(Math.max(0, position - 1))); + } + } + + /** + * Remove all tabs from the action bar and deselect the current tab. + */ + public void removeAllTabs() { + // Remove all the views + for (int i = mTabIndicators.getChildCount() - 1; i >= 0; i--) { + removeTabViewAt(i); + } + + for (final Iterator i = mTabs.iterator(); i.hasNext();) { + final Tab tab = i.next(); + i.remove(); + tab.reset(); + sTabPool.release(tab); + } + + mSelectedTab = null; + } + + /** + * Set the behavior mode for the Tabs in this layout. The valid input options are: + *

      + *
    • {@link #MODE_FIXED}: Fixed tabs display all tabs concurrently and are best used + * with content that benefits from quick pivots between tabs.
    • + *
    • {@link #MODE_SCROLLABLE}: Scrollable tabs display a subset of tabs at any given moment, + * and can contain longer tab labels and a larger number of tabs. They are best used for + * browsing contexts in touch interfaces when users don’t need to directly compare the tab + * labels. This mode is commonly used with a {@link ViewPager}.
    • + *
    + * + * @param mode one of {@link #MODE_FIXED} or {@link #MODE_SCROLLABLE}. + * + * @attr ref android.support.design.R.styleable#TabLayout_tabMode + */ + public void setTabMode(@Mode int mode) { + if (mode != mMode) { + mMode = mode; + applyModeAndGravity(); + } + } + + /** + * Returns the current mode used by this {@link BaseIndicatorTabLayout}. + * + * @see #setTabMode(int) + */ + @Mode + public int getTabMode() { + return mMode; + } + + /** + * Sets the text colors for the different states (normal, selected) used for the tabs. + * + * @see #getTabTextColors() + */ + public void setTabTextColors(@Nullable ColorStateList textColor) { + if (mTabTextColors != textColor) { + mTabTextColors = textColor; + + //noinspection ForLoopReplaceableByForEach + for (int i = 0, n = mTabs.size(); i < n; i++) { + TabView tabView = mTabs.get(i).getTabView(); + if (tabView != null) { + tabView.setTextColorList(mTabTextColors); + } + } + } + } + + /** + * Gets the text colors for the different states (normal, selected) used for the tabs. + */ + @SuppressWarnings("unused") + @Nullable + public ColorStateList getTabTextColors() { + return mTabTextColors; + } + + @ColorInt + public int getSelectedTabTextColor() { + return mTabTextColors.getColorForState(SELECTED_STATE_SET, UNDEFINED_COLOR); + } + + /** + * Sets the text colors for the different states (normal, selected) used for the tabs. + * + * @attr ref android.support.design.R.styleable#TabLayout_tabTextColor + * @attr ref android.support.design.R.styleable#TabLayout_tabSelectedTextColor + */ + @SuppressWarnings("unused") + public void setTabTextColors(int normalColor, int selectedColor) { + setTabTextColors(createColorStateList(normalColor, selectedColor)); + } + + /** + * The one-stop shop for setting up this {@link BaseIndicatorTabLayout} with a {@link ViewPager}. + * + *

    This method will link the given ViewPager and this TabLayout together so that any + * changes in one are automatically reflected in the other. This includes adapter changes, + * scroll state changes, and clicks. The tabs displayed in this layout will be populated + * from the ViewPager adapter's page titles.

    + * + *

    After this method is called, you will not need this method again unless you want + * to change the linked ViewPager.

    + * + *

    If the given ViewPager is non-null, it needs to already have a + * {@link PagerAdapter} set.

    + * + * @param viewPager The ViewPager to link, or {@code null} to clear any previous link. + */ + @SuppressWarnings("unused") + public void setupWithViewPager(@Nullable final ViewPager viewPager) { + if (mViewPager != null && mPageChangeListener != null) { + // If we've already been setup with a ViewPager, remove us from it + mViewPager.removeOnPageChangeListener(mPageChangeListener); + } + + if (viewPager != null) { + final PagerAdapter adapter = viewPager.getAdapter(); + if (adapter == null) { + throw new IllegalArgumentException("ViewPager does not have a PagerAdapter set"); + } + + mViewPager = viewPager; + + // Add our custom OnPageChangeListener to the ViewPager + if (mPageChangeListener == null) { + mPageChangeListener = new TabLayoutOnPageChangeListener(this); + } + mPageChangeListener.reset(); + viewPager.addOnPageChangeListener(mPageChangeListener); + + // Now we'll add a tab selected listener to set ViewPager's current item + setOnTabSelectedListener(new ViewPagerOnTabSelectedListener(viewPager)); + + // Now we'll populate ourselves from the pager adapter + setPagerAdapter(adapter, true); + } else { + // We've been given a null ViewPager so we need to clear out the internal state, + // listeners and observers + mViewPager = null; + setOnTabSelectedListener(null); + setPagerAdapter(null, true); + } + } + + @NonNull + public TabLayoutOnPageChangeListener getPageChangeListener() { + if (mPageChangeListener == null) { + mPageChangeListener = new TabLayoutOnPageChangeListener(this); + } + return mPageChangeListener; + } + + @Override + public boolean shouldDelayChildPressedState() { + // Only delay the pressed state if the tabs can scroll + return getTabScrollRange() > 0; + } + + protected void onTabViewCreated(@NonNull TextView view) { + } + + protected void onTabViewUpdated(@NonNull TextView view) { + } + + public void setTabsEnabled(boolean enabled) { + for (int i = 0; i < mTabs.size(); i++) { + mTabs.get(i).mView.setEnabled(enabled); + } + } + + private int getTabScrollRange() { + return Math.max(0, mTabIndicators.getWidth() - getWidth() - getPaddingLeft() + - getPaddingRight()); + } + + private void setPagerAdapter(@Nullable final PagerAdapter adapter, final boolean addObserver) { + if (mPagerAdapter != null && mPagerAdapterObserver != null) { + // If we already have a PagerAdapter, unregister our observer + mPagerAdapter.unregisterDataSetObserver(mPagerAdapterObserver); + } + + mPagerAdapter = adapter; + + if (addObserver && adapter != null) { + // Register our observer on the new adapter + if (mPagerAdapterObserver == null) { + mPagerAdapterObserver = new PagerAdapterObserver(); + } + adapter.registerDataSetObserver(mPagerAdapterObserver); + } + + // Finally make sure we reflect the new adapter + populateFromPagerAdapter(); + } + + private void populateFromPagerAdapter() { + removeAllTabs(); + + if (mPagerAdapter != null) { + final int adapterCount = mPagerAdapter.getCount(); + for (int i = 0; i < adapterCount; i++) { + addTab(newTab().setText(mPagerAdapter.getPageTitle(i)), false); + } + + // Make sure we reflect the currently set ViewPager item + if (mViewPager != null && adapterCount > 0) { + final int curItem = mViewPager.getCurrentItem(); + if (curItem != getSelectedTabPosition() && curItem < getTabCount()) { + selectTab(getTabAt(curItem)); + } + } + } else { + removeAllTabs(); + } + } + + private TabView getTabView(@NonNull final Tab tab) { + TabView tabView = mTabViewPool.acquire(); + if (tabView == null) { + tabView = createTabView(getContext()); + configureTabView(tabView); + onTabViewCreated(tabView); + } + tabView.setTab(tab); + tabView.setFocusable(true); + tabView.setMinimumWidth(getTabMinWidth()); + return tabView; + } + + protected TabView createTabView(@NonNull Context context) { + return new TabView(context); + } + + private void configureTabView(@NonNull TabView tabView) { + tabView.setTabPadding(mTabPaddingStart, mTabPaddingTop, mTabPaddingEnd, mTabPaddingBottom); + tabView.setTextTypeface(mTypefaceProvider, mTabTextAppearance); + tabView.setTextColorList(mTabTextColors); + tabView.setBoldTextOnSelection(mTabTextBoldOnSelection); + tabView.setEllipsizeEnabled(mIsTabEllipsizeEnabled); + tabView.setMaxWidthProvider(this::getTabMaxWidth); + tabView.setOnUpdateListener(this::onTabViewUpdated); + } + + private void configureTab(Tab tab, int position) { + tab.setPosition(position); + mTabs.add(position, tab); + + final int count = mTabs.size(); + for (int i = position + 1; i < count; i++) { + mTabs.get(i).setPosition(i); + } + } + + private void addTabView(Tab tab, boolean setSelected) { + final TabView tabView = tab.mView; + mTabIndicators.addView(tabView, createLayoutParamsForTabs()); + if (setSelected) { + tabView.setSelected(true); + } + } + + private void addTabView(Tab tab, int position, boolean setSelected) { + final TabView tabView = tab.mView; + mTabIndicators.addView(tabView, position, createLayoutParamsForTabs()); + if (setSelected) { + tabView.setSelected(true); + } + } + + @Override + public void addView(View child) { + addViewInternal(child); + } + + @Override + public void addView(View child, int index) { + addViewInternal(child); + } + + @Override + public void addView(View child, ViewGroup.LayoutParams params) { + addViewInternal(child); + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + addViewInternal(child); + } + + private void addViewInternal(final View child) { + if (child instanceof TabItem) { + addTabFromItemView((TabItem) child); + } else { + throw new IllegalArgumentException("Only TabItem instances can be added to TabLayout"); + } + } + + private LinearLayout.LayoutParams createLayoutParamsForTabs() { + final LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); + updateTabViewLayoutParams(lp); + return lp; + } + + private void updateTabViewLayoutParams(LinearLayout.LayoutParams lp) { + lp.width = LinearLayout.LayoutParams.WRAP_CONTENT; + lp.weight = 0; + } + + + @SuppressLint("SwitchIntDef") + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // If we have a MeasureSpec which allows us to decide our height, try and use the default + // height + final int idealHeight = SizeKt.dp(DEFAULT_HEIGHT) + getPaddingTop() + getPaddingBottom(); + switch (MeasureSpec.getMode(heightMeasureSpec)) { + case MeasureSpec.AT_MOST: + heightMeasureSpec = MeasureSpec.makeMeasureSpec( + Math.min(idealHeight, MeasureSpec.getSize(heightMeasureSpec)), + MeasureSpec.EXACTLY); + break; + case MeasureSpec.UNSPECIFIED: + heightMeasureSpec = MeasureSpec.makeMeasureSpec(idealHeight, MeasureSpec.EXACTLY); + break; + + case MeasureSpec.EXACTLY: + default: + // do not modify height; + break; + } + + final int specWidth = MeasureSpec.getSize(widthMeasureSpec); + if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED) { + // If we don't have an unspecified width spec, use the given size to calculate + // the max tab width + mTabMaxWidth = mRequestedTabMaxWidth > 0 + ? mRequestedTabMaxWidth + : specWidth - SizeKt.dp(TAB_MIN_WIDTH_MARGIN); + } + + // Now super measure itself using the (possibly) modified height spec + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (getChildCount() == 1) { + // If we're in fixed mode then we need to make the tab strip is the same width as us + // so we don't scroll + final View child = getChildAt(0); + boolean remeasure; + + switch (mMode) { + case MODE_FIXED: + // Resize the child so that it doesn't scroll + remeasure = child.getMeasuredWidth() != getMeasuredWidth(); + break; + case MODE_SCROLLABLE: + default: + // We only need to resize the child if it's smaller than us. This is similar + // to fillViewport + remeasure = child.getMeasuredWidth() < getMeasuredWidth(); + break; + } + + if (remeasure) { + // Re-measure the child with a widthSpec set to be exactly our measure width + int childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec, getPaddingTop() + + getPaddingBottom(), child.getLayoutParams().height); + int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec( + getMeasuredWidth(), MeasureSpec.EXACTLY); + child.measure(childWidthMeasureSpec, childHeightMeasureSpec); + } + } + } + + @Override + protected void onSizeChanged(int newWidth, int newHeight, int oldWidth, int oldHeight) { + super.onSizeChanged(newWidth, newHeight, oldWidth, oldHeight); + if (oldWidth != 0 && oldWidth != newWidth) { + setScrollToSelectedTab(); + } + } + + private void removeTabViewAt(int position) { + final TabView view = (TabView) mTabIndicators.getChildAt(position); + mTabIndicators.removeViewAt(position); + if (view != null) { + view.reset(); + mTabViewPool.release(view); + } + requestLayout(); + } + + private void animateToTab(int newPosition) { + if (newPosition == Tab.INVALID_POSITION) { + return; + } + + if (getWindowToken() == null || !ViewCompat.isLaidOut(this) + || mTabIndicators.childrenNeedLayout()) { + // If we don't have a window token, or we haven't been laid out yet just draw the new + // position now + setScrollPosition(newPosition, 0f, true); + return; + } + + final int startScrollX = getScrollX(); + final int targetScrollX = calculateScrollXForTab(newPosition, 0); + + if (startScrollX != targetScrollX) { + if (mScrollAnimator == null) { + mScrollAnimator = ValueAnimator.ofInt(); + mScrollAnimator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR); + mScrollAnimator.setDuration(mAnimationDuration); + mScrollAnimator.addUpdateListener(animator -> scrollTo((Integer) animator.getAnimatedValue(), 0)); + } + + mScrollAnimator.setIntValues(startScrollX, targetScrollX); + mScrollAnimator.start(); + } + + // Now animate the indicator + mTabIndicators.animateSelectedIndicatorToPosition(newPosition, mAnimationDuration); + } + + private void setSelectedTabView(int position) { + final int tabCount = mTabIndicators.getChildCount(); + if (position < tabCount && !mTabIndicators.getChildAt(position).isSelected()) { + for (int i = 0; i < tabCount; i++) { + final View child = mTabIndicators.getChildAt(i); + child.setSelected(i == position); + } + } + } + + void selectTab(Tab tab) { + selectTab(tab, true); + } + + public void selectTab(int pos) { + if (getSelectedTabPosition() != pos) { + Tab tab = getTabAt(pos); + if (tab != null) { + tab.select(); + } + } + } + + void selectTab(Tab tab, boolean updateIndicator) { + if (mSelectedTab == tab) { + if (mSelectedTab != null) { + if (mOnTabSelectedListener != null) { + mOnTabSelectedListener.onTabReselected(mSelectedTab); + } + animateToTab(tab.getPosition()); + } + } else { + if (updateIndicator) { + final int newPosition = tab != null ? tab.getPosition() : Tab.INVALID_POSITION; + if (newPosition != Tab.INVALID_POSITION) { + setSelectedTabView(newPosition); + } + if ((mSelectedTab == null || mSelectedTab.getPosition() == Tab.INVALID_POSITION) + && newPosition != Tab.INVALID_POSITION) { + // If we don't currently have a tab, just draw the indicator + setScrollPosition(newPosition, 0f, true); + } else { + animateToTab(newPosition); + } + } + if (mSelectedTab != null && mOnTabSelectedListener != null) { + mOnTabSelectedListener.onTabUnselected(mSelectedTab); + } + mSelectedTab = tab; + if (mSelectedTab != null && mOnTabSelectedListener != null) { + mOnTabSelectedListener.onTabSelected(mSelectedTab); + } + } + } + + private void setScrollToSelectedTab() { + if (mSelectedTab == null) { + return; + } + + final int position = mSelectedTab.getPosition(); + if (position == Tab.INVALID_POSITION) { + return; + } + + setScrollPosition(position, 0f, true); + } + + private int calculateScrollXForTab(int position, float positionOffset) { + if (mMode == MODE_SCROLLABLE) { + final View selectedChild = mTabIndicators.getChildAt(position); + + if (selectedChild == null) { + return 0; + } + + final int selectedWidth = selectedChild.getWidth(); + + if (mTabScrollPaddingEnabled) { + return selectedChild.getLeft() - mTabScrollPadding; + } else { + final View nextChild = position + 1 < mTabIndicators.getChildCount() + ? mTabIndicators.getChildAt(position + 1) + : null; + + final int nextWidth = nextChild != null ? nextChild.getWidth() : 0; + + return selectedChild.getLeft() + + ((int) ((selectedWidth + nextWidth) * positionOffset * 0.5f)) + + (selectedChild.getWidth() / 2) + - (getWidth() / 2); + } + } + return 0; + } + + private void applyModeAndGravity() { + int paddingStart = 0; + int paddingEnd = 0; + if (mMode == MODE_SCROLLABLE) { + // If we're scrollable, or fixed at start, inset using padding + paddingStart = Math.max(0, mContentInsetStart - mTabPaddingStart); + paddingEnd = Math.max(0, mContentInsetEnd - mTabPaddingEnd); + } + ViewCompat.setPaddingRelative(mTabIndicators, paddingStart, 0, paddingEnd, 0); + + switch (mMode) { + case MODE_FIXED: + mTabIndicators.setGravity(Gravity.CENTER_HORIZONTAL); + break; + case MODE_SCROLLABLE: + default: + mTabIndicators.setGravity(GravityCompat.START); + break; + } + + updateTabViews(true); + } + + private void updateTabViews(final boolean requestLayout) { + for (int i = 0; i < mTabIndicators.getChildCount(); i++) { + View child = mTabIndicators.getChildAt(i); + child.setMinimumWidth(getTabMinWidth()); + updateTabViewLayoutParams((LinearLayout.LayoutParams) child.getLayoutParams()); + if (requestLayout) { + child.requestLayout(); + } + } + } + + @NonNull + @MainThread + public void bindTypefaceProvider(@NonNull DivTypefaceProvider typefaceProvider) { + mTypefaceProvider = typefaceProvider; + } + + /** + * A tab in this layout. Instances can be created via {@link #newTab()}. + */ + public static final class Tab { + + /** + * An invalid position for a tab. + * + * @see #getPosition() + */ + static final int INVALID_POSITION = -1; + + @Nullable + private CharSequence mText; + private int mPosition = INVALID_POSITION; + + private BaseIndicatorTabLayout mParent; + private TabView mView; + + private Tab() { + } + + /** + * Return the current position of this tab in the action bar. + * + * @return Current position, or {@link #INVALID_POSITION} if this tab is not currently in + * the action bar. + */ + public int getPosition() { + return mPosition; + } + + void setPosition(int position) { + mPosition = position; + } + + /** + * Return the text of this tab. + * + * @return The tab's text + */ + @Nullable + public CharSequence getText() { + return mText; + } + + /** + * Set the text displayed on this tab. Text may be truncated if there is not room to display + * the entire string. + * + * @param text The text to display + * @return The current instance for call chaining + */ + @NonNull + public Tab setText(@Nullable CharSequence text) { + mText = text; + updateView(); + return this; + } + + /** + * Set the text displayed on this tab. Text may be truncated if there is not room to display + * the entire string. + * + * @param resId A resource ID referring to the text that should be displayed + * @return The current instance for call chaining + */ + @NonNull + public Tab setText(@StringRes int resId) { + if (mParent == null) { + throw new IllegalArgumentException("Tab not attached to a TabLayout"); + } + return setText(mParent.getResources().getText(resId)); + } + + /** + * Select this tab. Only valid if the tab has been added to the action bar. + */ + public void select() { + if (mParent == null) { + throw new IllegalArgumentException("Tab not attached to a TabLayout"); + } + mParent.selectTab(this); + } + + @Nullable + public TabView getTabView() { + return mView; + } + + /** + * Returns true if this tab is currently selected. + */ + public boolean isSelected() { + if (mParent == null) { + throw new IllegalArgumentException("Tab not attached to a TabLayout"); + } + return mParent.getSelectedTabPosition() == mPosition; + } + + private void updateView() { + if (mView != null) { + mView.update(); + } + } + + private void reset() { + mParent = null; + mView = null; + mText = null; + mPosition = INVALID_POSITION; + } + } + + public static final float UNDEFINED_RADIUS = -1f; + + public enum AnimationType { + SLIDE, + FADE, + NONE + } + + private static class OvalIndicators extends LinearLayout { + protected int mIndicatorHeight; + + private static final int UNDEFINED_COLOR = -1; + + protected int mSelectedColor = UNDEFINED_COLOR; + protected int mUnselectedColor = UNDEFINED_COLOR; + + protected int mSelectedPosition = -1; + + protected float mOffset; + + protected int mItemSpacing = 0; + + protected int[] mIndicatorsLeft; + protected int[] mIndicatorsRight; + + protected float[] mCornerRadii; + + protected int mSelectedIndicatorLeft = -1; + protected int mSelectedIndicatorRight = -1; + + private int mSize; + + protected ValueAnimator mSelectedIndicatorAnimator; + + private final Paint mIndicatorPaint; + private final Path mClipPath; + private final RectF mIndicatorRect; + + private final int mPaddingTop; + private final int mPaddingBottom; + + private float mOpacity = 1f; + private int mFutureSelectedPosition = -1; + + private AnimationType mAnimationType = AnimationType.SLIDE; + + OvalIndicators(Context context, int paddingTop, int paddingBottom) { + super(context); + + setId(R.id.tab_sliding_oval_indicator); + + setWillNotDraw(false); + + mSize = getChildCount(); + initIndicatorArrays(mSize); + + mIndicatorPaint = new Paint(); + mIndicatorPaint.setAntiAlias(true); + + mIndicatorRect = new RectF(); + mPaddingTop = paddingTop; + mPaddingBottom = paddingBottom; + + mClipPath = new Path(); + mCornerRadii = new float[8]; + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + updateIndicatorsPosition(); + if (mSelectedIndicatorAnimator != null && mSelectedIndicatorAnimator.isRunning()) { + // If we're currently running an animation, lets cancel it and start a + // new animation with the remaining duration + mSelectedIndicatorAnimator.cancel(); + final float fraction = mSelectedIndicatorAnimator.getAnimatedFraction(); + final int duration = Math.round((1f - fraction) * mSelectedIndicatorAnimator.getDuration()); + animateSelectedIndicatorToPosition(mFutureSelectedPosition, duration); + } + } + + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + int childCount = getChildCount(); + if (index < 0) { + //See ViewGroup.addViewInner() + index = childCount; + } + if (index == 0) { + //If the child is added to the beginning + if (childCount != 0) { + //If view group has some first child, adding to him left margin + View prevFirstChild = getChildAt(0); + ViewGroup.LayoutParams prevFirstChildParams = prevFirstChild.getLayoutParams(); + updateViewLayout(prevFirstChild, setLeftMargin(prevFirstChildParams, mItemSpacing)); + } + //Add new first child + super.addView(child, index, setLeftMargin(params, 0)); + } else { + //Add new child with left margin + super.addView(child, index, setLeftMargin(params, mItemSpacing)); + } + } + + private MarginLayoutParams setLeftMargin(ViewGroup.LayoutParams params, int leftMargin) { + MarginLayoutParams lp = (MarginLayoutParams) params; + lp.leftMargin = leftMargin; + return lp; + } + + @Override + public void draw(Canvas canvas) { + float height = getHeight(); + if (mUnselectedColor != UNDEFINED_COLOR) { + // Draw unselected indicators + for (int i = 0, z = getChildCount(); i < z; i++) { + drawRoundRect(canvas, + mIndicatorsLeft[i], + mIndicatorsRight[i], + height, + mUnselectedColor, 1f); + } + } + if (mSelectedColor != UNDEFINED_COLOR) { + // Draw selected indicator + switch (mAnimationType) { + case FADE: + drawRoundRect(canvas, + mIndicatorsLeft[mSelectedPosition], + mIndicatorsRight[mSelectedPosition], + height, + mSelectedColor, mOpacity); + if (mFutureSelectedPosition != -1) { + drawRoundRect(canvas, + mIndicatorsLeft[mFutureSelectedPosition], + mIndicatorsRight[mFutureSelectedPosition], + height, + mSelectedColor, 1f - mOpacity); + } + break; + case SLIDE: + drawRoundRect(canvas, + mSelectedIndicatorLeft, + mSelectedIndicatorRight, + height, + mSelectedColor, 1f); + break; + default: + drawRoundRect(canvas, + mIndicatorsLeft[mSelectedPosition], + mIndicatorsRight[mSelectedPosition], + height, + mSelectedColor, 1f); + break; + } + } + super.draw(canvas); + } + + private void drawRoundRect(Canvas canvas, int left, int right, float height, int color, float opacity) { + if (left >= 0 && right > left) { + mIndicatorRect.set(left, mPaddingTop, right, height - mPaddingBottom); + + float indicatorWidth = mIndicatorRect.width(); + float indicatorHeight = mIndicatorRect.height(); + float[] radii = new float[8]; + for (int i = 0; i < 8; i++) { + radii[i] = clampCornerRadius(mCornerRadii[i], indicatorWidth, indicatorHeight); + } + mClipPath.reset(); + mClipPath.addRoundRect(mIndicatorRect, radii, Path.Direction.CW); + mClipPath.close(); + + mIndicatorPaint.setColor(color); + int alpha = Math.round(mIndicatorPaint.getAlpha() * opacity); + mIndicatorPaint.setAlpha(alpha); + canvas.drawPath(mClipPath, mIndicatorPaint); + } + } + + boolean childrenNeedLayout() { + for (int i = 0, z = getChildCount(); i < z; i++) { + final View child = getChildAt(i); + if (child.getWidth() <= 0) { + return true; + } + } + return false; + } + + void setSelectedIndicatorColor(@ColorInt int color) { + if (mSelectedColor != color) { + if (isTransparentColor(color)) { + mSelectedColor = UNDEFINED_COLOR; + } else { + mSelectedColor = color; + } + ViewCompat.postInvalidateOnAnimation(this); + } + } + + void setAnimationType(AnimationType animationType) { + if (mAnimationType != animationType) { + mAnimationType = animationType; + if (mSelectedIndicatorAnimator != null && mSelectedIndicatorAnimator.isRunning()) { + mSelectedIndicatorAnimator.cancel(); + } + } + } + + void setIndicatorColor(@ColorInt int color) { + if (mUnselectedColor != color) { + if (isTransparentColor(color)) { + mUnselectedColor = UNDEFINED_COLOR; + } else { + mUnselectedColor = color; + } + ViewCompat.postInvalidateOnAnimation(this); + } + } + + void setIndicatorHeight(int height) { + if (mIndicatorHeight != height) { + mIndicatorHeight = height; + ViewCompat.postInvalidateOnAnimation(this); + } + } + + void setIndicatorCornersRadii(@NonNull float[] radii) { + if (!Arrays.equals(mCornerRadii, radii)) { + mCornerRadii = radii; + ViewCompat.postInvalidateOnAnimation(this); + } + } + + void setItemSpacing(int itemSpacing) { + if (itemSpacing != mItemSpacing) { + mItemSpacing = itemSpacing; + for (int i = 1, z = getChildCount(); i < z; i++) { + final View child = getChildAt(i); + updateViewLayout(child, setLeftMargin(child.getLayoutParams(), mItemSpacing)); + } + } + } + + void animateSelectedIndicatorToPosition(int position, int duration) { + if (mSelectedIndicatorAnimator != null && mSelectedIndicatorAnimator.isRunning()) { + mSelectedIndicatorAnimator.cancel(); + float fraction = mSelectedIndicatorAnimator.getAnimatedFraction(); + duration = Math.round((1f - fraction) * mSelectedIndicatorAnimator.getDuration()); + } + final View targetView = getChildAt(position); + if (targetView == null) { + // If we don't have a view, just update the position now and return + updateIndicatorsPosition(); + return; + } + switch (mAnimationType) { + case SLIDE: + startSelectedIndicatorSlideAnimation(position, + duration, + mSelectedIndicatorLeft, + mSelectedIndicatorRight, + targetView.getLeft(), + targetView.getRight()); + break; + case FADE: + startSelectedIndicatorFadeAnimation(position, duration); + break; + default: + setSelectedIndicatorPositionFromTabPosition(position, 0f); + break; + } + } + + void setSelectedIndicatorPositionFromTabPosition(int position, float positionOffset) { + if (mSelectedIndicatorAnimator != null && mSelectedIndicatorAnimator.isRunning()) { + mSelectedIndicatorAnimator.cancel(); + } + + mSelectedPosition = position; + mOffset = positionOffset; + updateIndicatorsPosition(); + updateOpacity(); + } + + protected void updateIndicatorsPosition() { + View title; + int left, right; + int selectedLeft, selectedRight; + int childCount = getChildCount(); + if (childCount != mSize) { + initIndicatorArrays(childCount); + } + + for (int titleIndex = 0; titleIndex < childCount; titleIndex++) { + title = getChildAt(titleIndex); + left = -1; + right = -1; + selectedLeft = -1; + selectedRight = -1; + if (title != null && title.getWidth() > 0) { + left = title.getLeft(); + right = title.getRight(); + selectedLeft = left; + selectedRight = right; + if (mAnimationType == AnimationType.SLIDE) { + if (titleIndex == mSelectedPosition && mOffset > 0f && titleIndex < childCount - 1) { + // Draw the selection partway between the tabs + View nextTitle = getChildAt(titleIndex + 1); + selectedLeft = (int) (mOffset * nextTitle.getLeft() + (1.0f - mOffset) * left); + selectedRight = (int) (mOffset * nextTitle.getRight() + (1.0f - mOffset) * right); + } + } + } + setUnselectedIndicatorPosition(titleIndex, left, right); + if (titleIndex == mSelectedPosition) { + setSelectedIndicatorPosition(selectedLeft, selectedRight); + } + } + } + + protected void updateOpacity() { + float newOpacity = 1.0f - mOffset; + if (newOpacity != mOpacity) { + mOpacity = newOpacity; + int nextPosition = mSelectedPosition + 1; + mFutureSelectedPosition = nextPosition < mSize ? nextPosition : -1; + ViewCompat.postInvalidateOnAnimation(this); + } + } + + protected void setUnselectedIndicatorPosition(int index, int left, int right) { + int currentLeft = mIndicatorsLeft[index]; + int currentRight = mIndicatorsRight[index]; + if (left != currentLeft || right != currentRight) { + // If the indicator's left/right has changed, invalidate + mIndicatorsLeft[index] = left; + mIndicatorsRight[index] = right; + ViewCompat.postInvalidateOnAnimation(this); + } + } + + protected void setSelectedIndicatorPosition(int left, int right) { + if (left != mSelectedIndicatorLeft || right != mSelectedIndicatorRight) { + // If the indicator's left/right has changed, invalidate + mSelectedIndicatorLeft = left; + mSelectedIndicatorRight = right; + ViewCompat.postInvalidateOnAnimation(this); + } + } + + protected void startSelectedIndicatorSlideAnimation(int position, int duration, + int startLeft, int startRight, + int targetLeft, int targetRight) { + if (startLeft != targetLeft || startRight != targetRight) { + ValueAnimator animator = ValueAnimator.ofFloat(0, 1); + animator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR); + animator.setDuration(duration); + animator.addUpdateListener(animator1 -> { + final float fraction = animator1.getAnimatedFraction(); + setSelectedIndicatorPosition( + lerp(startLeft, targetLeft, fraction), + lerp(startRight, targetRight, fraction) + ); + ViewCompat.postInvalidateOnAnimation(this); + }); + animator.addListener(new AnimatorListenerAdapter() { + private boolean mHasCancel = false; + + @Override + public void onAnimationCancel(Animator animation) { + mHasCancel = true; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (!mHasCancel) { + mSelectedPosition = mFutureSelectedPosition; + mOffset = 0f; + } + } + }); + mFutureSelectedPosition = position; + mSelectedIndicatorAnimator = animator; + mSelectedIndicatorAnimator.start(); + } + } + + protected void startSelectedIndicatorFadeAnimation(int position, int duration) { + if (position != mSelectedPosition) { + ValueAnimator animator = ValueAnimator.ofFloat(0, 1); + animator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR); + animator.setDuration(duration); + animator.addUpdateListener(animator1 -> { + final float fraction = animator1.getAnimatedFraction(); + mOpacity = 1f - fraction; + ViewCompat.postInvalidateOnAnimation(this); + }); + animator.addListener(new AnimatorListenerAdapter() { + private boolean mHasCancel = false; + + @Override + public void onAnimationCancel(Animator animation) { + mHasCancel = true; + mOpacity = 1f; + } + + @Override + public void onAnimationEnd(Animator animation) { + if (!mHasCancel) { + mSelectedPosition = mFutureSelectedPosition; + mOffset = 0; + } + } + }); + mFutureSelectedPosition = position; + mSelectedIndicatorAnimator = animator; + mSelectedIndicatorAnimator.start(); + } + } + + private void initIndicatorArrays(int size) { + mSize = size; + mIndicatorsLeft = new int[mSize]; + mIndicatorsRight = new int[mSize]; + for (int i = 0; i < mSize; i++) { + mIndicatorsLeft[i] = -1; + mIndicatorsRight[i] = -1; + } + } + + private static int lerp(int startValue, int endValue, float fraction) { + return startValue + Math.round(fraction * (float) (endValue - startValue)); + } + + private static float clampCornerRadius(float cornerRadius, float width, float height) { + if (height <= 0 || width <= 0) { + return 0.0f; + } + float maxCornerRadius = Math.min(height, width) / 2; + if (cornerRadius == UNDEFINED_RADIUS) { + return maxCornerRadius; + } + if (cornerRadius > maxCornerRadius) { + Log.e("BaseIndicatorTabLayout", "Corner radius is too big"); + } + return Math.min(cornerRadius, maxCornerRadius); + } + + private static boolean isTransparentColor(@ColorInt int color) { + //Channel 8 bit, alpha channel [24, 31] bits + return (color >> 24) == 0; + } + } + + private static ColorStateList createColorStateList(int defaultColor, int selectedColor) { + final int[][] states = new int[2][]; + final int[] colors = new int[2]; + int i = 0; + + states[i] = SELECTED_STATE_SET; + colors[i] = selectedColor; + i++; + + // Default enabled state + states[i] = EMPTY_STATE_SET; + colors[i] = defaultColor; + + return new ColorStateList(states, colors); + } + + private int getTabMinWidth() { + if (mRequestedTabMinWidth != INVALID_WIDTH) { + // If we have been given a min width, use it + return mRequestedTabMinWidth; + } + // Else, we'll use the default value + return mMode == MODE_SCROLLABLE ? mScrollableTabMinWidth : 0; + } + + @Override + public LayoutParams generateLayoutParams(AttributeSet attrs) { + // We don't care about the layout params of any views added to us, since we don't actually + // add them. The only view we add is the SlidingTabStrip, which is done manually. + // We return the default layout params so that we don't blow up if we're given a TabItem + // without android:layout_* values. + return generateDefaultLayoutParams(); + } + + private int getTabMaxWidth() { + return mTabMaxWidth; + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + mNestedScrollCompanion.dispatchOnScrollChanged(); + } + + @Override + protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) { + super.onOverScrolled(scrollX, scrollY, clampedX, clampedY); + mNestedScrollCompanion.dispatchOnOverScrolled(clampedX); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + boolean result = super.dispatchTouchEvent(ev); + mNestedScrollCompanion.dispatchTouchEventAfterSuperCall(ev); + return result; + } + + /** + * A {@link ViewPager.OnPageChangeListener} class which contains the + * necessary calls back to the provided {@link BaseIndicatorTabLayout} so that the tab position is + * kept in sync. + * + *

    This class stores the provided TabLayout weakly, meaning that you can use + * {@link ViewPager#addOnPageChangeListener(ViewPager.OnPageChangeListener) + * addOnPageChangeListener(OnPageChangeListener)} without removing the listener and + * not cause a leak. + */ + public static class TabLayoutOnPageChangeListener implements ViewPager.OnPageChangeListener { + private final WeakReference mTabLayoutRef; + private int mPreviousScrollState; + private int mScrollState; + + TabLayoutOnPageChangeListener(BaseIndicatorTabLayout tabLayout) { + mTabLayoutRef = new WeakReference<>(tabLayout); + } + + @Override + public void onPageScrollStateChanged(int state) { + mPreviousScrollState = mScrollState; + mScrollState = state; + } + + @Override + public void onPageScrolled(int position, float positionOffset, + int positionOffsetPixels) { + final BaseIndicatorTabLayout tabLayout = mTabLayoutRef.get(); + if (tabLayout != null) { + // Only update scroll if we're not settling, or we are settling after + // being dragged + final boolean updateScroll = mScrollState != SCROLL_STATE_SETTLING || mPreviousScrollState == SCROLL_STATE_DRAGGING; + if (updateScroll) { + tabLayout.setScrollPosition(position, positionOffset, true, true); + } + } + } + + @Override + public void onPageSelected(int position) { + final BaseIndicatorTabLayout tabLayout = mTabLayoutRef.get(); + if (tabLayout != null && tabLayout.getSelectedTabPosition() != position) { + // Select the tab, only updating the indicator if we're not being dragged/settled + // (since onPageScrolled will handle that). + final boolean updateIndicator = mScrollState == SCROLL_STATE_IDLE + || (mScrollState == SCROLL_STATE_SETTLING + && mPreviousScrollState == SCROLL_STATE_IDLE); + tabLayout.selectTab(tabLayout.getTabAt(position), updateIndicator); + } + } + + public void reset() { + mPreviousScrollState = mScrollState = SCROLL_STATE_IDLE; + } + } + + /** + * A {@link OnTabSelectedListener} class which contains the necessary calls back + * to the provided {@link ViewPager} so that the tab position is kept in sync. + */ + public static class ViewPagerOnTabSelectedListener implements OnTabSelectedListener { + private final ViewPager mViewPager; + + ViewPagerOnTabSelectedListener(ViewPager viewPager) { + mViewPager = viewPager; + } + + @Override + public void onTabSelected(Tab tab) { + mViewPager.setCurrentItem(tab.getPosition()); + } + + @Override + public void onTabUnselected(Tab tab) { + // No-op + } + + @Override + public void onTabReselected(Tab tab) { + // No-op + } + } + + private class PagerAdapterObserver extends DataSetObserver { + @Override + public void onChanged() { + populateFromPagerAdapter(); + } + + @Override + public void onInvalidated() { + populateFromPagerAdapter(); + } + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/DynamicCardHeightCalculator.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/DynamicCardHeightCalculator.java new file mode 100644 index 000000000..ca94f3e89 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/DynamicCardHeightCalculator.java @@ -0,0 +1,39 @@ +package com.yandex.div.view.tabs; + +import android.view.ViewGroup; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; + +@MainThread +public class DynamicCardHeightCalculator extends BaseCardHeightCalculator { + + public DynamicCardHeightCalculator(@NonNull ViewGroup channelGroup, + @NonNull HeightCalculatorFactory.MeasureTabHeightFn measureTabHeights, + @NonNull HeightCalculatorFactory.GetTabCountFn getTabCountFn) { + super(channelGroup, measureTabHeights, getTabCountFn); + } + + @Override + public boolean shouldRequestLayoutOnScroll(int position, float positionOffset) { + return true; + } + + @Override + public int measureHeight(int widthMeasureSpec, int heightMeasureSpec) { + dropMeasureCache(); + return super.measureHeight(widthMeasureSpec, heightMeasureSpec); + } + + @Override + protected int getOptimalHeight(@NonNull TabMeasurement measurement, + int position, + float positionOffset) { + if (positionOffset < 0.01f) { + return measurement.getTabHeight(position); + } + int firstHeight = measurement.getTabHeight(position); + int secondHeight = measurement.getTabHeight(position + 1); + + return Math.round(firstHeight + (secondHeight - firstHeight) * positionOffset); + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/FixedHeightCalculator.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/FixedHeightCalculator.java new file mode 100644 index 000000000..05605a2e6 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/FixedHeightCalculator.java @@ -0,0 +1,41 @@ +package com.yandex.div.view.tabs; + +import android.os.Parcelable; +import android.util.SparseArray; +import androidx.annotation.Dimension; +import androidx.annotation.NonNull; + +/** + * Noop calculator with fixed tab height + */ +public class FixedHeightCalculator implements ViewPagerFixedSizeLayout.HeightCalculator { + + @Dimension + private final int mTabHeight; + + public FixedHeightCalculator(@Dimension int height) { + mTabHeight = height; + } + + @Override + public void setPositionAndOffsetForMeasure(int position, float positionOffset) { /* not used */ } + + @Override + public int measureHeight(int widthMeasureSpec, int heightMeasureSpec) { + return mTabHeight; + } + + @Override + public boolean shouldRequestLayoutOnScroll(int position, float positionOffset) { + return false; + } + + @Override + public void dropMeasureCache() { /* not used */ } + + @Override + public void saveInstanceState(@NonNull SparseArray container) { /* not used */ } + + @Override + public void restoreInstanceState(@NonNull SparseArray container) { /* not used */ } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/HeightCalculatorFactory.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/HeightCalculatorFactory.java new file mode 100644 index 000000000..cf619e2e9 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/HeightCalculatorFactory.java @@ -0,0 +1,18 @@ +package com.yandex.div.view.tabs; + +import android.view.ViewGroup; +import androidx.annotation.NonNull; + +public interface HeightCalculatorFactory { + ViewPagerFixedSizeLayout.HeightCalculator getCardHeightCalculator(@NonNull ViewGroup channelGroup, + @NonNull MeasureTabHeightFn measureTabHeights, + @NonNull GetTabCountFn getTabCountFn); + + interface MeasureTabHeightFn { + int apply(@NonNull ViewGroup channelGroup, int width, int tab); + } + + interface GetTabCountFn { + int apply(); + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/MaxCardHeightCalculator.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/MaxCardHeightCalculator.java new file mode 100644 index 000000000..52480b438 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/MaxCardHeightCalculator.java @@ -0,0 +1,45 @@ +package com.yandex.div.view.tabs; + +import android.view.ViewGroup; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import com.yandex.div.core.util.Assert; + +@MainThread +public class MaxCardHeightCalculator extends BaseCardHeightCalculator { + + public MaxCardHeightCalculator(@NonNull ViewGroup channelGroup, + @NonNull HeightCalculatorFactory.MeasureTabHeightFn measureTabHeights, + @NonNull HeightCalculatorFactory.GetTabCountFn getTabCountFn) { + super(channelGroup, measureTabHeights, getTabCountFn); + } + + @Override + public boolean shouldRequestLayoutOnScroll(int position, float positionOffset) { + return isTabsHeightsIsUnknown() || ((position == 0 || position == 1 && positionOffset <= 0.0f) && firstTabDiffers()); + } + + @Override + protected int getOptimalHeight(@NonNull TabMeasurement measurement, + int position, + float positionOffset) { + if (position > 0) { + return measurement.getMaxTabHeight(); + } + + if (positionOffset < 0.01f) { + return measurement.getFirstTabHeight(); + } + + int firstHeight = measurement.getFirstTabHeight(); + int maxHeight = measurement.getMaxTabHeight(); + + return Math.round(firstHeight + (maxHeight - firstHeight) * positionOffset); + } + + private boolean firstTabDiffers() { + Assert.assertTrue(mTabsHeightCache.size() > 0); + TabMeasurement measurement = mTabsHeightCache.valueAt(mTabsHeightCache.size() - 1); + return measurement.getFirstTabHeight() != measurement.getMaxTabHeight(); + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/ScrollableViewPager.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/ScrollableViewPager.java new file mode 100644 index 000000000..e2a704336 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/ScrollableViewPager.java @@ -0,0 +1,120 @@ +package com.yandex.div.view.tabs; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.customview.widget.ViewDragHelper; +import androidx.viewpager.widget.ViewPager; +import com.yandex.div.util.NestedHorizontalScrollCompanion; +import com.yandex.div.view.OnInterceptTouchEventListener; +import com.yandex.div.view.OnInterceptTouchEventListenerHost; +import java.util.Set; + +public class ScrollableViewPager extends ViewPager implements OnInterceptTouchEventListenerHost { + + private final NestedHorizontalScrollCompanion mNestedScrollCompanion = new NestedHorizontalScrollCompanion(this); + + @Nullable + private ViewDragHelper mViewDragHelper; + private boolean mIsScrollEnabled = true; + private boolean mIsEdgeScrollEnabled = true; + private boolean mIsSwipeLocked = false; + private boolean mIsScrollLocked = false; + @Nullable + private Set mDisabledPages; + @Nullable + private OnInterceptTouchEventListener mOnInterceptTouchEventListener; + + public ScrollableViewPager(@NonNull Context context) { + this(context, null); + } + + public ScrollableViewPager(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + @Nullable + @Override + public OnInterceptTouchEventListener getOnInterceptTouchEventListener() { + return mOnInterceptTouchEventListener; + } + + @Override + public void setOnInterceptTouchEventListener(@Nullable OnInterceptTouchEventListener listener) { + mOnInterceptTouchEventListener = listener; + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + mNestedScrollCompanion.dispatchOnScrollChanged(); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + boolean result = super.dispatchTouchEvent(ev); + mNestedScrollCompanion.dispatchTouchEventAfterSuperCall(ev); + return result; + } + + @Override + public boolean onTouchEvent(@NonNull MotionEvent event) { + return processTouchEvent(event) && super.onTouchEvent(event); + } + + @Override + public boolean onInterceptTouchEvent(@NonNull MotionEvent event) { + boolean intercepted = false; + if (mOnInterceptTouchEventListener != null) { + intercepted = mOnInterceptTouchEventListener.onInterceptTouchEvent(this, event); + } + return intercepted || (processTouchEvent(event) && super.onInterceptTouchEvent(event)); + } + + private boolean processTouchEvent(@NonNull MotionEvent event) { + if (!mIsEdgeScrollEnabled && mViewDragHelper != null) { + final int action = event.getAction(); + final boolean pointerUp = + (action & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN; + if (pointerUp) { + mIsSwipeLocked = false; + } + mViewDragHelper.processTouchEvent(event); + } + if (mDisabledPages != null) { + mIsScrollLocked = mIsScrollEnabled && mDisabledPages.contains(getCurrentItem()); + } + return !mIsSwipeLocked && !mIsScrollLocked && mIsScrollEnabled; + } + + public void setEdgeScrollEnabled(boolean enabled) { + mIsEdgeScrollEnabled = enabled; + if (!enabled) { + mViewDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() { + @Override + public boolean tryCaptureView(View view, int i) { + return false; + } + + @Override + public void onEdgeDragStarted(int edgeFlags, int pointerId) { + super.onEdgeDragStarted(edgeFlags, pointerId); + mIsSwipeLocked = (edgeFlags & ViewDragHelper.EDGE_RIGHT) != 0 + || (edgeFlags & ViewDragHelper.EDGE_LEFT) != 0; + } + }); + mViewDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_RIGHT | ViewDragHelper.EDGE_LEFT); + } + } + + public void setScrollEnabled(boolean enabled) { + mIsScrollEnabled = enabled; + } + + public void setDisabledScrollPages(@Nullable Set disabledPages) { + mDisabledPages = disabledPages; + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/TabItem.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/TabItem.java new file mode 100644 index 000000000..2f7112e59 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/TabItem.java @@ -0,0 +1,42 @@ +package com.yandex.div.view.tabs; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; +import androidx.appcompat.widget.TintTypedArray; +import com.yandex.div.core.views.R; + +/** + * TabItem is a special 'view' which allows you to declare tab items for a {@link BaseIndicatorTabLayout} within + * a layout. This view is not actually added to TabLayout, it is just a dummy which allows setting + * of a tab items's text, icon and custom layout. See TabLayout for more information on how to use + * it. + * + * @attr ref com.google.android.material.R.styleable#TabItem_android_icon + * @attr ref com.google.android.material.R.styleable#TabItem_android_text + * @attr ref com.google.android.material.R.styleable#TabItem_android_layout + * @see BaseIndicatorTabLayout + */ +@SuppressLint("RestrictedApi") +class TabItem extends View { + public final CharSequence text; + public final Drawable icon; + public final int customLayout; + + public TabItem(Context context) { + this(context, null); + } + + public TabItem(Context context, AttributeSet attrs) { + super(context, attrs); + + final TintTypedArray a = + TintTypedArray.obtainStyledAttributes(context, attrs, R.styleable.TabItem); + text = a.getText(R.styleable.TabItem_android_text); + icon = a.getDrawable(R.styleable.TabItem_android_icon); + customLayout = a.getResourceId(R.styleable.TabItem_android_layout, 0); + a.recycle(); + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/TabMeasurement.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/TabMeasurement.java new file mode 100644 index 000000000..101c2327b --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/TabMeasurement.java @@ -0,0 +1,79 @@ +package com.yandex.div.view.tabs; + +import android.os.Bundle; +import androidx.annotation.NonNull; + +class TabMeasurement { + private static final String FIRST_TAB_HEIGHT_PREFIX = "FIRST_TAB_HEIGHT_PREFIX"; + private static final String MAX_TAB_HEIGHT_PREFIX = "MAX_TAB_HEIGHT_PREFIX"; + + private final int mTabCount; + private int mFirstTabHeight = -1; + private int mMaxTabHeight = -1; + private int[] mTabHeight; + @NonNull + private final TabMeasurementFunction mTabMeasurementFunction; + + TabMeasurement(int tabCount, @NonNull TabMeasurementFunction tabMeasurementFunction) { + mTabCount = tabCount; + mTabMeasurementFunction = tabMeasurementFunction; + mTabHeight = new int[mTabCount]; + } + + int getFirstTabHeight() { + if (mFirstTabHeight < 0) { + mFirstTabHeight = mTabMeasurementFunction.getTabHeight(0); + } + return mFirstTabHeight; + } + + int getTabHeight(int position) { + if (mTabCount == 0) { + return 0; + } + if (position < 0) { + return getTabHeight(0); + } + if (position >= mTabCount) { + return getTabHeight(mTabCount); + } + if (mTabHeight[position] <= 0) { + mTabHeight[position] = mTabMeasurementFunction.getTabHeight(position); + } + return mTabHeight[position]; + } + + int getMaxTabHeight() { + if (mMaxTabHeight < 0) { + int max = getFirstTabHeight(); + for (int i = 1; i < mTabCount; ++i) { + max = Math.max(max, mTabMeasurementFunction.getTabHeight(i)); + } + mMaxTabHeight = max; + } + return mMaxTabHeight; + } + + void saveState(@NonNull Bundle bundle, int key) { + if (mFirstTabHeight >= 0) { + bundle.putInt(FIRST_TAB_HEIGHT_PREFIX + key, mFirstTabHeight); + } + if (mMaxTabHeight >= 0) { + bundle.putInt(MAX_TAB_HEIGHT_PREFIX + key, mMaxTabHeight); + } + } + + void restoreState(@NonNull Bundle bundle, int key) { + mFirstTabHeight = bundle.getInt(FIRST_TAB_HEIGHT_PREFIX + key, -1); + mMaxTabHeight = bundle.getInt(MAX_TAB_HEIGHT_PREFIX + key, -1); + } + + void removeState(@NonNull Bundle bundle, int key) { + bundle.remove(FIRST_TAB_HEIGHT_PREFIX + key); + bundle.remove(MAX_TAB_HEIGHT_PREFIX + key); + } + + interface TabMeasurementFunction { + int getTabHeight(int tab); + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/TabView.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/TabView.java new file mode 100644 index 000000000..20ec6b5ed --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/TabView.java @@ -0,0 +1,303 @@ +package com.yandex.div.view.tabs; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Typeface; +import android.text.Layout; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.method.TransformationMethod; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.view.ViewCompat; +import com.yandex.div.font.DivTypefaceProvider; +import com.yandex.div.font.DivTypefaceType; + +/** + * View that represents tab title inside {@link BaseIndicatorTabLayout}. + */ +public final class TabView extends AppCompatTextView { + + private static final String ELLIPSIS_PLACEHOLDER_TO_MEASURE_WIDTH = "..."; + + @Nullable + private DivTypefaceProvider mTypefaceProvider; + @StyleRes + private int mTextAppearance; + private boolean mBoldTextOnSelection; + private boolean mEllipsizeEnabled; + + @NonNull + private MaxWidthProvider mMaxWidthProvider = () -> Integer.MAX_VALUE; + @Nullable + private OnUpdateListener mOnUpdateListener; + @Nullable + private BaseIndicatorTabLayout.Tab mTab; + @Nullable + private DivTypefaceType mInactiveTypefaceType; + @Nullable + private DivTypefaceType mActiveTypefaceType; + + private boolean mIsActiveType; + + public TabView(@NonNull Context context) { + this(context, null); + } + + public TabView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public TabView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + setGravity(Gravity.START | Gravity.CENTER_VERTICAL); + setClickable(true); + setMaxLines(1); + setSingleLine(true); + + OnClickListener defaultAspectJLoggingOnClickListener = v -> {}; + setOnClickListener(defaultAspectJLoggingOnClickListener); + } + + public void setTabPadding(int start, int top, int end, int bottom) { + ViewCompat.setPaddingRelative(this, start, top, end, bottom); + } + + void setTextTypeface(@Nullable DivTypefaceProvider typefaceProvider, @StyleRes int textAppearance) { + mTypefaceProvider = typefaceProvider; + mTextAppearance = textAppearance; + setupTypeface(); + } + + public void setInactiveTypefaceType(@Nullable DivTypefaceType inactiveTypefaceType) { + mInactiveTypefaceType = inactiveTypefaceType; + } + + public void setActiveTypefaceType(@Nullable DivTypefaceType activeTypefaceType) { + mActiveTypefaceType = activeTypefaceType; + } + + void setTextColorList(@Nullable ColorStateList textColorList) { + if (textColorList != null) { + setTextColor(textColorList); + } + } + + void setBoldTextOnSelection(boolean boldTextOnSelection) { + mBoldTextOnSelection = boldTextOnSelection; + } + + void setEllipsizeEnabled(boolean ellipsizeEnabled) { + mEllipsizeEnabled = ellipsizeEnabled; + setEllipsize(ellipsizeEnabled ? TextUtils.TruncateAt.END : null); + } + + public void setMaxWidthProvider(@NonNull MaxWidthProvider provider) { + mMaxWidthProvider = provider; + } + + void setOnUpdateListener(@Nullable OnUpdateListener listener) { + mOnUpdateListener = listener; + } + + @Override + public boolean performClick() { + final boolean value = super.performClick(); + + if (mTab != null) { + mTab.select(); + return true; + } else { + return value; + } + } + + @Override + public void setSelected(boolean selected) { + final boolean changed = (isSelected() != selected); + super.setSelected(selected); + + setTypefaceType(selected); + + if (mBoldTextOnSelection && changed) { + setupTypeface(); + } + + if (changed && selected) { + sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); + } + } + + private void setupTypeface() { + final boolean selected = isSelected(); + // Note: setTypeface must be called after setTextAppearance + if (!selected) { + //noinspection deprecation + setTextAppearance(getContext(), mTextAppearance); + } + } + + @Nullable + private Typeface getDefaultTypeface() { + if (mTypefaceProvider != null) { + if (mIsActiveType) { + if (mActiveTypefaceType != null) { + return mActiveTypefaceType.getTypeface(mTypefaceProvider); + } + } else { + if (mInactiveTypefaceType != null) { + return mInactiveTypefaceType.getTypeface(mTypefaceProvider); + } + } + } + return mTypefaceProvider != null ? mTypefaceProvider.getMedium() : null; + } + + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + // This view masquerades as an action bar tab. + //noinspection deprecation + event.setClassName(ActionBar.Tab.class.getName()); + } + + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + // This view masquerades as an action bar tab. + //noinspection deprecation + info.setClassName(ActionBar.Tab.class.getName()); + } + + /** + * Sets typeface type. + * @param isActiveType + */ + public void setTypefaceType(boolean isActiveType) { + final boolean changed = (mIsActiveType != isActiveType); + mIsActiveType = isActiveType; + if (changed) { + requestLayout(); + } + } + + @Override + public void onMeasure(final int origWidthMeasureSpec, final int origHeightMeasureSpec) { + // Hack: setup typeface if needed without requestLayout() & invalidate() + final TextPaint textPaint = getPaint(); + if (textPaint != null) { + final Typeface typeface = getDefaultTypeface(); + if (typeface != null) { + textPaint.setTypeface(typeface); + } + } + + if (!mEllipsizeEnabled) { + super.onMeasure(origWidthMeasureSpec, origHeightMeasureSpec); + return; + } + + final int specWidthSize = MeasureSpec.getSize(origWidthMeasureSpec); + final int specWidthMode = MeasureSpec.getMode(origWidthMeasureSpec); + final int maxWidth = mMaxWidthProvider.getMaxWidth(); + + final int widthMeasureSpec; + + if (maxWidth > 0 && (specWidthMode == MeasureSpec.UNSPECIFIED + || specWidthSize > maxWidth)) { + // If we have a max width and a given spec which is either unspecified or + // larger than the max width, update the width spec using the same mode + //noinspection Range + widthMeasureSpec = MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST); + } else { + // Else, use the original width spec + widthMeasureSpec = origWidthMeasureSpec; + } + + super.onMeasure(widthMeasureSpec, origHeightMeasureSpec); + + fixTextEllipsis(widthMeasureSpec, origHeightMeasureSpec); + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + setAlpha(enabled ? 1.0f : 0.2f); + } + + @SuppressLint("WrongCall") + private void fixTextEllipsis(final int widthMeasureSpec, final int heightMeasureSpec) { + final Layout layout = getLayout(); + if (layout == null || layout.getEllipsisCount(0) <= 0) { + return; + } + + if (mTab == null) { + return; + } + + CharSequence text = mTab.getText(); + if (text == null) { + return; + } + + final TextPaint textPaint = layout.getPaint(); + if (textPaint == null) { + return; + } + + TransformationMethod trans = getTransformationMethod(); + if (trans != null) { + text = trans.getTransformation(text, this); + } + + if (text == null) { + return; + } + + final int currentWidth = (int) layout.getLineMax(0); + final float ellipsisWidth = textPaint.measureText(ELLIPSIS_PLACEHOLDER_TO_MEASURE_WIDTH); + text = TextUtils.ellipsize(text, textPaint, currentWidth - ellipsisWidth, TextUtils.TruncateAt.END); + + setText(text); + + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + void setTab(@Nullable final BaseIndicatorTabLayout.Tab tab) { + if (tab != mTab) { + mTab = tab; + update(); + } + } + + void reset() { + setTab(null); + setSelected(false); + } + + void update() { + setText(mTab == null ? null : mTab.getText()); + + if (mOnUpdateListener != null) { + mOnUpdateListener.onUpdated(this); + } + } + + interface OnUpdateListener { + void onUpdated(@NonNull TabView tabView); + } + + interface MaxWidthProvider { + int getMaxWidth(); + } +} diff --git a/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/ViewPagerFixedSizeLayout.java b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/ViewPagerFixedSizeLayout.java new file mode 100644 index 000000000..9b5945d39 --- /dev/null +++ b/client/android/div-core-views/src/main/java/com/yandex/div/view/tabs/ViewPagerFixedSizeLayout.java @@ -0,0 +1,80 @@ +package com.yandex.div.view.tabs; + +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.SparseArray; +import android.widget.FrameLayout; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.viewpager.widget.ViewPager; +import com.yandex.div.core.views.R; + +/** + * An auxiliary view, that embraces {@link ViewPager} and controls its height by setting + * its own height. The height value is calculated at the latest moment possible. It is assumed + * that a calculator delegate would iterate over pager contents and estimate its height. + */ +public class ViewPagerFixedSizeLayout extends FrameLayout { + @Nullable + private HeightCalculator mHeightCalculator; + + private int mCollapsiblePaddingBottom = 0; + + public ViewPagerFixedSizeLayout(Context context) { + super(context); + } + + public ViewPagerFixedSizeLayout(Context context, AttributeSet attrs) { + super(context, attrs); + final TypedArray style = + context.obtainStyledAttributes(attrs, R.styleable.ViewPagerFixedSizeLayout); + mCollapsiblePaddingBottom = + style.getDimensionPixelSize(R.styleable.ViewPagerFixedSizeLayout_collapsiblePaddingBottom, 0); + style.recycle(); + } + + public ViewPagerFixedSizeLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + final TypedArray style = + context.obtainStyledAttributes(attrs, R.styleable.ViewPagerFixedSizeLayout, defStyleAttr, 0); + mCollapsiblePaddingBottom = + style.getDimensionPixelSize(R.styleable.ViewPagerFixedSizeLayout_collapsiblePaddingBottom, 0); + style.recycle(); + } + + public void setHeightCalculator(@Nullable HeightCalculator heightCalculator) { + mHeightCalculator = heightCalculator; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (mHeightCalculator != null) { + int newHeight = mHeightCalculator.measureHeight(widthMeasureSpec, heightMeasureSpec); + heightMeasureSpec = MeasureSpec.makeMeasureSpec(newHeight, MeasureSpec.EXACTLY); + } + + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + public void setCollapsiblePaddingBottom(int padding) { + if (mCollapsiblePaddingBottom != padding) { + mCollapsiblePaddingBottom = padding; + // TODO(gulevsky): notify padding changed + } + } + + public int getCollapsiblePaddingBottom() { + return mCollapsiblePaddingBottom; + } + + public interface HeightCalculator { + void setPositionAndOffsetForMeasure(int position, float positionOffset); + int measureHeight(int widthMeasureSpec, int heightMeasureSpec); + boolean shouldRequestLayoutOnScroll(int position, float positionOffset); + void dropMeasureCache(); + void saveInstanceState(@NonNull SparseArray container); + void restoreInstanceState(@NonNull SparseArray container); + } +} diff --git a/client/android/div-core-views/src/main/res/color/tab_text_color.xml b/client/android/div-core-views/src/main/res/color/tab_text_color.xml new file mode 100644 index 000000000..789d45ad1 --- /dev/null +++ b/client/android/div-core-views/src/main/res/color/tab_text_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/client/android/div-core-views/src/main/res/drawable-hdpi/ic_more_vert_white_24dp.png b/client/android/div-core-views/src/main/res/drawable-hdpi/ic_more_vert_white_24dp.png new file mode 100644 index 000000000..fdc4a5ad2 Binary files /dev/null and b/client/android/div-core-views/src/main/res/drawable-hdpi/ic_more_vert_white_24dp.png differ diff --git a/client/android/div-core-views/src/main/res/drawable-hdpi/overflow_menu_button.png b/client/android/div-core-views/src/main/res/drawable-hdpi/overflow_menu_button.png new file mode 100755 index 000000000..6f7df6253 Binary files /dev/null and b/client/android/div-core-views/src/main/res/drawable-hdpi/overflow_menu_button.png differ diff --git a/client/android/div-core-views/src/main/res/drawable-mdpi/ic_more_vert_white_24dp.png b/client/android/div-core-views/src/main/res/drawable-mdpi/ic_more_vert_white_24dp.png new file mode 100644 index 000000000..1d8ad18a0 Binary files /dev/null and b/client/android/div-core-views/src/main/res/drawable-mdpi/ic_more_vert_white_24dp.png differ diff --git a/client/android/div-core-views/src/main/res/drawable-mdpi/overflow_menu_button.png b/client/android/div-core-views/src/main/res/drawable-mdpi/overflow_menu_button.png new file mode 100755 index 000000000..c2d60d574 Binary files /dev/null and b/client/android/div-core-views/src/main/res/drawable-mdpi/overflow_menu_button.png differ diff --git a/client/android/div-core-views/src/main/res/drawable-xhdpi/ic_more_vert_white_24dp.png b/client/android/div-core-views/src/main/res/drawable-xhdpi/ic_more_vert_white_24dp.png new file mode 100644 index 000000000..1b04eda04 Binary files /dev/null and b/client/android/div-core-views/src/main/res/drawable-xhdpi/ic_more_vert_white_24dp.png differ diff --git a/client/android/div-core-views/src/main/res/drawable-xhdpi/overflow_menu_button.png b/client/android/div-core-views/src/main/res/drawable-xhdpi/overflow_menu_button.png new file mode 100755 index 000000000..5fe5312e6 Binary files /dev/null and b/client/android/div-core-views/src/main/res/drawable-xhdpi/overflow_menu_button.png differ diff --git a/client/android/div-core-views/src/main/res/drawable-xxhdpi/ic_more_vert_white_24dp.png b/client/android/div-core-views/src/main/res/drawable-xxhdpi/ic_more_vert_white_24dp.png new file mode 100644 index 000000000..2955c02ec Binary files /dev/null and b/client/android/div-core-views/src/main/res/drawable-xxhdpi/ic_more_vert_white_24dp.png differ diff --git a/client/android/div-core-views/src/main/res/drawable-xxhdpi/overflow_menu_button.png b/client/android/div-core-views/src/main/res/drawable-xxhdpi/overflow_menu_button.png new file mode 100755 index 000000000..660f47f9c Binary files /dev/null and b/client/android/div-core-views/src/main/res/drawable-xxhdpi/overflow_menu_button.png differ diff --git a/client/android/div-core-views/src/main/res/drawable/tabs_default_indicator.xml b/client/android/div-core-views/src/main/res/drawable/tabs_default_indicator.xml new file mode 100644 index 000000000..9cffa55c9 --- /dev/null +++ b/client/android/div-core-views/src/main/res/drawable/tabs_default_indicator.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/client/android/div-core-views/src/main/res/values/attrs.xml b/client/android/div-core-views/src/main/res/values/attrs.xml new file mode 100644 index 000000000..4eb610ccc --- /dev/null +++ b/client/android/div-core-views/src/main/res/values/attrs.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/android/div-core-views/src/main/res/values/dimens.xml b/client/android/div-core-views/src/main/res/values/dimens.xml new file mode 100644 index 000000000..d85064afc --- /dev/null +++ b/client/android/div-core-views/src/main/res/values/dimens.xml @@ -0,0 +1,10 @@ + + + 8dp + 12dp + 24dp + + 72dp + 72dp + 14sp + diff --git a/client/android/div-core-views/src/main/res/values/ids.xml b/client/android/div-core-views/src/main/res/values/ids.xml new file mode 100644 index 000000000..ed85dfca3 --- /dev/null +++ b/client/android/div-core-views/src/main/res/values/ids.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/client/android/div-core-views/src/main/res/values/integer.xml b/client/android/div-core-views/src/main/res/values/integer.xml new file mode 100644 index 000000000..ec5a456ee --- /dev/null +++ b/client/android/div-core-views/src/main/res/values/integer.xml @@ -0,0 +1,5 @@ + + + + 300 + diff --git a/client/android/div-core-views/src/main/res/values/styles.xml b/client/android/div-core-views/src/main/res/values/styles.xml new file mode 100644 index 000000000..9e2f6a442 --- /dev/null +++ b/client/android/div-core-views/src/main/res/values/styles.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/client/android/div-core-views/src/test/java/com/yandex/div/core/widget/AutoEllipsizeHelperTest.kt b/client/android/div-core-views/src/test/java/com/yandex/div/core/widget/AutoEllipsizeHelperTest.kt new file mode 100644 index 000000000..720bb897d --- /dev/null +++ b/client/android/div-core-views/src/test/java/com/yandex/div/core/widget/AutoEllipsizeHelperTest.kt @@ -0,0 +1,135 @@ +package com.yandex.div.core.widget + +import android.text.Layout +import android.view.ViewTreeObserver +import com.yandex.div.view.AutoEllipsizeHelper +import com.yandex.div.view.EllipsizedTextView +import org.junit.Assert +import org.junit.Test +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class AutoEllipsizeHelperTest { + + private val preDrawListenerCaptor = argumentCaptor() + private val viewTreeObserver = mock { + on { addOnPreDrawListener(preDrawListenerCaptor.capture()) } doAnswer {} + } + private val layout = mock { + on { lineCount } doReturn 20 + } + private val textView = mock { + on { layout } doReturn layout + on { viewTreeObserver } doReturn viewTreeObserver + } + + private val underTest = AutoEllipsizeHelper(textView) + + @Test + fun `update max lines based on text params`() { + underTest.isEnabled = true + whenever(textView.height).thenReturn(50) + whenever(textView.lineHeight).thenReturn(5) + whenever(layout.getLineBottom(9)).thenReturn(50) + whenever(layout.getLineBottom(10)).thenReturn(55) + + underTest.onViewAttachedToWindow() + + Assert.assertFalse(preDrawListenerCaptor.firstValue.onPreDraw()) + verify(textView).maxLines = 10 + } + + @Test + fun `remove pre draw listener when max lines didn't change`() { + underTest.isEnabled = true + whenever(textView.maxLines).thenReturn(10) + whenever(textView.height).thenReturn(50) + whenever(textView.lineHeight).thenReturn(5) + whenever(layout.getLineBottom(9)).thenReturn(50) + whenever(layout.getLineBottom(10)).thenReturn(55) + + underTest.onViewAttachedToWindow() + + Assert.assertTrue(preDrawListenerCaptor.firstValue.onPreDraw()) + verify(viewTreeObserver).removeOnPreDrawListener(preDrawListenerCaptor.firstValue) + } + + @Test + fun `remove pre draw listener on detach`() { + underTest.isEnabled = true + + underTest.onViewAttachedToWindow() + underTest.onViewDetachedFromWindow() + + verify(viewTreeObserver).removeOnPreDrawListener(preDrawListenerCaptor.firstValue) + } + + @Test + fun `do nothing when become disabled`() { + underTest.isEnabled = true + + underTest.onViewAttachedToWindow() + underTest.isEnabled = false + + Assert.assertTrue(preDrawListenerCaptor.firstValue.onPreDraw()) + } + + @Test + fun `allow to overflow text by allowed threshold`() { + underTest.isEnabled = true + whenever(textView.height).thenReturn(50) + whenever(textView.lineHeight).thenReturn(5) + whenever(layout.getLineBottom(9)).thenReturn(50) + whenever(layout.getLineBottom(10)).thenReturn(55) + whenever(textView.paddingTop).thenReturn(1) + whenever(textView.paddingBottom).thenReturn(2) + + underTest.onViewAttachedToWindow() + + Assert.assertFalse(preDrawListenerCaptor.firstValue.onPreDraw()) + verify(textView).maxLines = 10 + } + + @Test + fun `reduce max lines if text overflows more than allowed threshold`() { + underTest.isEnabled = true + whenever(textView.height).thenReturn(50) + whenever(textView.lineHeight).thenReturn(5) + whenever(layout.getLineBottom(9)).thenReturn(50) + whenever(layout.getLineBottom(10)).thenReturn(55) + whenever(textView.paddingTop).thenReturn(2) + whenever(textView.paddingBottom).thenReturn(2) + + underTest.onViewAttachedToWindow() + + Assert.assertFalse(preDrawListenerCaptor.firstValue.onPreDraw()) + verify(textView).maxLines = 9 + } + + @Test + fun `constraint visible lines by layout lineCount`() { + underTest.isEnabled = true + whenever(textView.height).thenReturn(50) + whenever(textView.lineHeight).thenReturn(5) + whenever(layout.lineCount).thenReturn(5) + + underTest.onViewAttachedToWindow() + + Assert.assertFalse(preDrawListenerCaptor.firstValue.onPreDraw()) + verify(layout).getLineBottom(4) + } + + @Test + fun `return true when layout is null`() { + underTest.isEnabled = true + whenever(textView.layout).thenReturn(null) + + underTest.onViewAttachedToWindow() + + Assert.assertTrue(preDrawListenerCaptor.firstValue.onPreDraw()) + } +} diff --git a/client/android/div-core-views/src/test/java/com/yandex/div/core/widget/ProfilingSessionExtensionTest.kt b/client/android/div-core-views/src/test/java/com/yandex/div/core/widget/ProfilingSessionExtensionTest.kt new file mode 100644 index 000000000..f229305f0 --- /dev/null +++ b/client/android/div-core-views/src/test/java/com/yandex/div/core/widget/ProfilingSessionExtensionTest.kt @@ -0,0 +1,47 @@ +package com.yandex.div.core.widget + +import com.yandex.div.view.pooling.roundRoughly +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +@RunWith(Parameterized::class) +class ProfilingSessionExtensionTest(private val value: Long, private val grouped: Long) { + + companion object { + @JvmStatic + @Parameters + fun data() : Collection> { + return listOf>( + arrayOf(-20L, 0L), + arrayOf(0L, 0L), + arrayOf(10L, 0L), + arrayOf(24L, 20L), + arrayOf(57L, 40L), + arrayOf(101L, 100L), + arrayOf(128L, 100L), + arrayOf(467L, 400L), + arrayOf(1337L, 1200L), + arrayOf(1800L, 1800L), + arrayOf(2737L, 2500L), + arrayOf(4040L, 4000L), + arrayOf(6536L, 6000L), + arrayOf(9999L, 9000L), + arrayOf(13370L, 12000L), + arrayOf(17423L, 16000L), + arrayOf(9999L, 9000L), + arrayOf(29486L, 25000L), + arrayOf(42000L, 40000L), + arrayOf(65536L, 50000L), + arrayOf(262144L, 50000L) + ) + } + } + + @Test + fun `time rounded correctly`() { + assertEquals(grouped, value.roundRoughly()) + } +} diff --git a/client/android/div-core/build.gradle b/client/android/div-core/build.gradle new file mode 100644 index 000000000..854af53e2 --- /dev/null +++ b/client/android/div-core/build.gradle @@ -0,0 +1,7 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +dependencies { + implementation "androidx.core:core:$versions.androidx.core" +} diff --git a/client/android/div-core/jacoco.excludes b/client/android/div-core/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-core/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-core/proguard-rules.pro b/client/android/div-core/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/client/android/div-core/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/client/android/div-core/src/main/AndroidManifest.xml b/client/android/div-core/src/main/AndroidManifest.xml new file mode 100644 index 000000000..aec91a471 --- /dev/null +++ b/client/android/div-core/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/client/android/div-core/src/main/java/com/yandex/div/DivDataTag.java b/client/android/div-core/src/main/java/com/yandex/div/DivDataTag.java new file mode 100644 index 000000000..6161c9da9 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/DivDataTag.java @@ -0,0 +1,42 @@ +package com.yandex.div; + +import androidx.annotation.NonNull; + +/** + * A tag identifies data associated with a div view, based on this tag we can retrieve previous data state and build view accordingly + */ +public class DivDataTag { + + public static final DivDataTag INVALID = new DivDataTag(""); + + @NonNull + private final String mId; + + public DivDataTag(@NonNull String id) { + mId = id; + } + + @NonNull + public String getId() { + return mId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + DivDataTag tag = (DivDataTag) o; + return mId.equals(tag.mId); + } + + @Override + public int hashCode() { + return mId.hashCode(); + } +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/annotations/Mockable.kt b/client/android/div-core/src/main/java/com/yandex/div/core/annotations/Mockable.kt new file mode 100644 index 000000000..710764f0b --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/annotations/Mockable.kt @@ -0,0 +1,8 @@ +package com.yandex.div.core.annotations + +/** + * Used to annotate kotlin that can be mocked by mockito. + */ +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.CLASS) +annotation class Mockable diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/annotations/PublicApi.kt b/client/android/div-core/src/main/java/com/yandex/div/core/annotations/PublicApi.kt new file mode 100644 index 000000000..797fa40df --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/annotations/PublicApi.kt @@ -0,0 +1,8 @@ +package com.yandex.div.core.annotations + +/** + * Marks types that are part of public API. + */ +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS) +annotation class PublicApi diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/images/BitmapSource.kt b/client/android/div-core/src/main/java/com/yandex/div/core/images/BitmapSource.kt new file mode 100644 index 000000000..25c450da7 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/images/BitmapSource.kt @@ -0,0 +1,7 @@ +package com.yandex.div.core.images + +enum class BitmapSource { + NETWORK, + DISK, + MEMORY +} \ No newline at end of file diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/images/CachedBitmap.java b/client/android/div-core/src/main/java/com/yandex/div/core/images/CachedBitmap.java new file mode 100644 index 000000000..ec184695d --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/images/CachedBitmap.java @@ -0,0 +1,91 @@ +package com.yandex.div.core.images; + +import android.graphics.Bitmap; +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.div.core.annotations.PublicApi; + +/** + * Provides cached {@link Bitmap}. + */ +@PublicApi +public class CachedBitmap { + + @NonNull private final Bitmap mBitmap; + @Nullable private final Uri mCacheUri; + @Nullable private final byte[] mBytes; + @NonNull private final BitmapSource mFrom; + + public CachedBitmap(@NonNull Bitmap bitmap, @Nullable Uri cacheUri, @NonNull BitmapSource from) { + this(bitmap, null, cacheUri, from); + } + + public CachedBitmap(@NonNull Bitmap bitmap, @Nullable byte[] bytes, @Nullable Uri cacheUri, @NonNull BitmapSource from) { + mBitmap = bitmap; + mCacheUri = cacheUri; + mBytes = bytes; + mFrom = from; + } + + /** + * Returns cached bitmap. + */ + @NonNull + public Bitmap getBitmap() { + return mBitmap; + } + + @Nullable + public byte[] getBytes() { + return mBytes; + } + + /** + * Returns cached URI of the image. + */ + @Nullable + public Uri getCacheUri() { + return mCacheUri; + } + + /** + * Returns source of the image. + */ + @NonNull + public BitmapSource getFrom() { + return mFrom; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + CachedBitmap that = (CachedBitmap) o; + + if (!mBitmap.equals(that.getBitmap())) { + return false; + } + + if (mFrom != that.getFrom()) { + return false; + } + + final Uri thatCacheUri = that.getCacheUri(); + + return mCacheUri != null ? mCacheUri.equals(thatCacheUri) : thatCacheUri == null; + } + + @Override + public int hashCode() { + int result = mBitmap.hashCode(); + result = 31 * result + mFrom.hashCode(); + result = 31 * result + (mCacheUri != null ? mCacheUri.hashCode() : 0); + return result; + } +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/images/DivImageDownloadCallback.java b/client/android/div-core/src/main/java/com/yandex/div/core/images/DivImageDownloadCallback.java new file mode 100644 index 000000000..d855dbfb8 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/images/DivImageDownloadCallback.java @@ -0,0 +1,47 @@ +package com.yandex.div.core.images; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import com.yandex.div.core.annotations.PublicApi; + +@PublicApi +public class DivImageDownloadCallback { + + /** + * Called when image is successfully loaded. + */ + @UiThread + public void onSuccess(@NonNull CachedBitmap cachedBitmap) { + // no implementation + } + + /** + * Is called when image load is failed. + */ + @UiThread + public void onError() { + // no implementation + } + + /** + * Is called if immediate call to {@link #onError} or {@link #onSuccess(CachedBitmap)} didn't happen. + * That is, image is not in cache or it's only on disk cache and callback asked for memory cache only. + * Callback will be called later when result is ready. + * + * It is either executed immediately or never executed. + */ + @UiThread + public void onScheduling() { + // no implementation + } + + /** + * May be used to provide additional data for image load failures logging. + */ + @UiThread + @Nullable + public String getAdditionalLogInfo() { + return null; + } +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/images/DivImageLoader.java b/client/android/div-core/src/main/java/com/yandex/div/core/images/DivImageLoader.java new file mode 100644 index 000000000..d01889e36 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/images/DivImageLoader.java @@ -0,0 +1,75 @@ +package com.yandex.div.core.images; + +import android.widget.ImageView; +import androidx.annotation.NonNull; + +/** + * Image loader contract. + */ +public interface DivImageLoader { + + /** + * Starts image loading by given imageUrl. + *

    + * Contract : callback MUST BE stored in {@link java.lang.ref.WeakReference} in order to prevent leakage. + * + * @param imageUrl image url. + * @param callback callback to invoke after image is loaded. + * @return reference to cancel loading + */ + @NonNull + LoadReference loadImage(@NonNull String imageUrl, @NonNull DivImageDownloadCallback callback); + + /** + * Starts image loading by given imageUrl. Download raw bytes in result. + *

    + * Contract : callback MUST BE stored in {@link java.lang.ref.WeakReference} in order to prevent leakage. + * + * @param imageUrl image url. + * @param callback callback to invoke after image is loaded. + * @return reference to cancel loading + */ + @NonNull + LoadReference loadImageBytes(@NonNull String imageUrl, @NonNull DivImageDownloadCallback callback); + + /** + * Starts image loading reference by given imageUrl. + *

    + * + * @param imageUrl image url. + * @param imageView image view to set image bitmap to. + * @return reference to cancel loading + */ + @NonNull + LoadReference loadImage(@NonNull String imageUrl, @NonNull ImageView imageView); + + /** + * Starts image loading by given imageUrl. + *

    + * Contract : callback MUST BE stored in {@link java.lang.ref.WeakReference} in order to prevent leakage. + * + * @param imageUrl image url. + * @param callback callback to invoke after image is loaded. + * @param loadPriority loading priority. + * @return reference to cancel loading + */ + @NonNull + default LoadReference loadImage(@NonNull String imageUrl, @NonNull DivImageDownloadCallback callback, @DivImagePriority int loadPriority) { + return loadImage(imageUrl, callback); + } + + /** + * Starts image loading by given imageUrl. Download raw bytes in result. + *

    + * Contract : callback MUST BE stored in {@link java.lang.ref.WeakReference} in order to prevent leakage. + * + * @param imageUrl image url. + * @param callback callback to invoke after image is loaded. + * @param loadPriority loading priority. + * @return reference to cancel loading + */ + @NonNull + default LoadReference loadImageBytes(@NonNull String imageUrl, @NonNull DivImageDownloadCallback callback, @DivImagePriority int loadPriority) { + return loadImageBytes(imageUrl, callback); + } +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/images/DivImagePriority.kt b/client/android/div-core/src/main/java/com/yandex/div/core/images/DivImagePriority.kt new file mode 100644 index 000000000..afc3147d2 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/images/DivImagePriority.kt @@ -0,0 +1,15 @@ +package com.yandex.div.core.images + +import androidx.annotation.IntDef + +@kotlin.annotation.Retention(AnnotationRetention.SOURCE) +@IntDef( + DivImagePriority.IMAGES_PRIORITY_DEFAULT, + DivImagePriority.IMAGES_PRIORITY_PRELOAD +) +annotation class DivImagePriority { + companion object { + const val IMAGES_PRIORITY_PRELOAD = -1 + const val IMAGES_PRIORITY_DEFAULT = 0 + } +} \ No newline at end of file diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/images/LoadReference.java b/client/android/div-core/src/main/java/com/yandex/div/core/images/LoadReference.java new file mode 100644 index 000000000..944f34877 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/images/LoadReference.java @@ -0,0 +1,8 @@ +package com.yandex.div.core.images; + +/** + * Interface to cancel loading. + */ +public interface LoadReference { + void cancel(); +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/interfaces/UriHandler.java b/client/android/div-core/src/main/java/com/yandex/div/core/interfaces/UriHandler.java new file mode 100644 index 000000000..b18465d40 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/interfaces/UriHandler.java @@ -0,0 +1,20 @@ +package com.yandex.div.core.interfaces; + +import android.net.Uri; +import androidx.annotation.NonNull; +import com.yandex.div.core.annotations.PublicApi; + +/** + * Basic interface for handling {@link Uri}'s. + */ +@PublicApi +public interface UriHandler { + + /** + * Handles the given Uri. + * + * @param uri {@link Uri} to handle + * @return {@code true} if the Uri was successfully handled, {@code false} otherwise. + */ + boolean handleUri(@NonNull Uri uri); +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/system/Android12Compat.kt b/client/android/div-core/src/main/java/com/yandex/div/core/system/Android12Compat.kt new file mode 100644 index 000000000..a7d549be9 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/system/Android12Compat.kt @@ -0,0 +1,9 @@ +package com.yandex.div.core.system + +/** + * Temporary constants until real build be configured + */ +object Android12Compat { + const val VERSION_CODE = 31 + const val BLUETOOTH_CONNECT_PERMISSION = "android.permission.BLUETOOTH_CONNECT" +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/utils/CastUtil.kt b/client/android/div-core/src/main/java/com/yandex/div/core/utils/CastUtil.kt new file mode 100644 index 000000000..3d944bb97 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/utils/CastUtil.kt @@ -0,0 +1,19 @@ +package com.yandex.div.core.utils + +/** + * Allows to smartcast var's on the fly. + */ +inline fun Any?.ifIs(block: (T) -> Unit) { + if (this is T) { + block(this) + } +} + +/** + * Inverted version of [ifIs]. + */ +inline fun Any?.ifIsNot(block: (Any?) -> Unit) { + if (this !is T) { + block(this) + } +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/utils/EnumUtils.java b/client/android/div-core/src/main/java/com/yandex/div/core/utils/EnumUtils.java new file mode 100644 index 000000000..14f2e254b --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/utils/EnumUtils.java @@ -0,0 +1,31 @@ +package com.yandex.div.core.utils; + +import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Contains methods to safely work with enums. + */ +public class EnumUtils { + + private EnumUtils() { /* do not instantiate */ } + + /** + * Finds enum value that matches 'enumName' or returns null if didn't find + * @param any enum + * @return enum item if found or null otherwise + */ + @Nullable + public static > T findEnum(@Nullable String enumName, @NonNull Class enumType) { + if (!TextUtils.isEmpty(enumName)) { + for (T value : enumType.getEnumConstants()) { + if (value.name().equals(enumName)) { + return value; + } + } + } + + return null; + } +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/utils/IOUtils.java b/client/android/div-core/src/main/java/com/yandex/div/core/utils/IOUtils.java new file mode 100644 index 000000000..5ebe85ca6 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/utils/IOUtils.java @@ -0,0 +1,96 @@ +package com.yandex.div.core.utils; + +import android.database.Cursor; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.Charset; + +public final class IOUtils { + + private static final int BUFFER_SIZE = 2048; + + private IOUtils() { + } + + /** + * Closes given closable without any side effects + * + * NOTE: Don't pass {@link Cursor} to this method, because some + * devices may throw {@link IncompatibleClassChangeError} + * + * @param closeable closeable for close + */ + public static void closeSilently(@Nullable Closeable closeable) { + try { + if (closeable != null) { + closeable.close(); + } + } catch (IOException ignore) { + } + } + + /** + * Don't use closeSilently(Closable) because of ClasCastException on some devices + */ + public static void closeCursorSilently(@Nullable Cursor cursor) { + if (cursor != null && !cursor.isClosed()) { + cursor.close(); + } + } + + public static void flushSilently(@Nullable OutputStream outputStream) { + try { + if (outputStream != null) { + outputStream.flush(); + } + } catch (IOException ignore) { + } + } + + @Nullable + public static byte[] toByteArray(@Nullable InputStream inputStream) throws IOException { + if (inputStream == null) { + return null; + } + + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + final byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, read); + } + + return outputStream.toByteArray(); + } + + @NonNull + public static String toString(@NonNull InputStream inputStream) throws IOException { + char[] buffer = new char[BUFFER_SIZE]; + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + StringBuilder builder = new StringBuilder(inputStream.available()); + int read; + while ((read = reader.read(buffer)) != -1) { + builder.append(buffer, 0, read); + } + return builder.toString(); + } + + @NonNull + public static String toString(@NonNull InputStream inputStream, @NonNull Charset charset) throws IOException { + char[] buffer = new char[BUFFER_SIZE]; + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset)); + StringBuilder builder = new StringBuilder(inputStream.available()); + int read; + while ((read = reader.read(buffer)) != -1) { + builder.append(buffer, 0, read); + } + return builder.toString(); + } +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/utils/PermissionUtils.java b/client/android/div-core/src/main/java/com/yandex/div/core/utils/PermissionUtils.java new file mode 100644 index 000000000..5bec28849 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/utils/PermissionUtils.java @@ -0,0 +1,204 @@ +package com.yandex.div.core.utils; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.provider.Settings; +import androidx.annotation.NonNull; +import androidx.collection.ArrayMap; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Contains utility methods to check and request permissions. + */ +public class PermissionUtils { + + private static final String LOG_TAG = PermissionUtils.class.getSimpleName(); + + private static final String PREFS_NAME = "PermissionUtils.Prefs"; + private static final String PREFS_KEY_ANSWERED_PERMISSION_SET = "prefs_key_answered_permission_set"; + + private PermissionUtils() { + } + + /** + * Checks if passed permission is granted + */ + public static boolean hasPermission(@NonNull Context context, @NonNull String permission) { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED; + } + + /** + * Requests permissions to the application. + */ + public static void requestPermissions(@NonNull Activity activity, + int requestCode, + @NonNull String[] permissions) { + ActivityCompat.requestPermissions(activity, permissions, requestCode); + } + + /** + * Returns easy readable grant results. + * + * @param permissions The requested permissions. Never null. + * @param grantResults The grant results for the corresponding permissions + * which is either {@link PackageManager#PERMISSION_GRANTED} + * or {@link PackageManager#PERMISSION_DENIED}. Never null. + * @return true if granted else false + */ + @NonNull + public static GrantResults parseGrantResults(@NonNull String[] permissions, @NonNull int[] grantResults) { + Map resultMap = new ArrayMap<>(); + + // From docs: It is possible that the permissions request interaction with the user is interrupted. + // In this case you will receive empty permissions and results arrays which should be treated as a cancellation. + if (grantResults.length == 0 || permissions.length != grantResults.length) { + return new GrantResults(resultMap); + } + + for (int i = 0; i < permissions.length; i++) { + String permission = permissions[i]; + int grantResult = grantResults[i]; + + boolean isGranted = (grantResult == PackageManager.PERMISSION_GRANTED); + resultMap.put(permission, isGranted); + } + return new GrantResults(resultMap); + } + + /** + * Note that {@link #markUserAnswered} must be called in order to get correct results. + * + * @see #markUserAnswered + */ + public static boolean isDeniedWithDontAsk(@NonNull Activity activity, @NonNull String permission) { + return !hasPermission(activity, permission) && + !shouldShowRequestPermissionRationale(activity, Collections.singleton(permission)) && + userEverAnswered(activity, permission); + } + + /** + * Must be called from {@link Activity#onRequestPermissionsResult}/{@link Fragment#onRequestPermissionsResult}. + * + * This class must know whether the user has answered for the permission ask or not. + * this knowledge is required for checking 'Don't ask' state. + * When no permission granted, {@link #shouldShowRequestPermissionRationale} gives: + * 1) False for the first dialog launch (until the user presses any button on a dialog), + * 2) True for the sequential asks, + * 3) False after the dialog blocked with 'Don't ask'. + * Thus to distinguish 'first launch' and 'Don't ask' blocked state, we need to store + * an additional state by ourselves. + * + * Note also that changing a permission in app settings manually does NOT resets + * {@link #shouldShowRequestPermissionRationale} method behavior to the 'first launch' state + * thus this settings must outlive manual permission change. + * + * @see #isDeniedWithDontAsk + */ + public static void markUserAnswered(@NonNull Context context, @NonNull String[] permissions) { + Set answeredPermissions = new HashSet<>(getAnsweredPermissions(context)); + for (String permission : permissions) { + if (answeredPermissions.contains(permission)) { + continue; + } + answeredPermissions.add(permission); + } + context.getSharedPreferences(PREFS_NAME, 0).edit() + .putStringSet(PREFS_KEY_ANSWERED_PERMISSION_SET, answeredPermissions) + .apply(); + } + + public static boolean userEverAnswered(@NonNull Context context, @NonNull String permission) { + return getAnsweredPermissions(context).contains(permission); + } + + private static Set getAnsweredPermissions(@NonNull Context context) { + return context.getSharedPreferences(PREFS_NAME, 0) + .getStringSet(PREFS_KEY_ANSWERED_PERMISSION_SET, Collections.EMPTY_SET); + } + + public static class GrantResults { + @NonNull + private final Map mResultMap; + + GrantResults(@NonNull Map resultMap) { + mResultMap = resultMap; + } + + public boolean contains(@NonNull String permission) { + return mResultMap.containsKey(permission); + } + + public boolean isPermissionGranted(@NonNull String permission) { + if (!mResultMap.containsKey(permission)) { + return false; + } + return mResultMap.get(permission); + } + + public boolean isDeniedWithDontAsk(@NonNull Activity activity, @NonNull String permission) { + return !isPermissionGranted(permission) && + !shouldShowRequestPermissionRationale(activity, Collections.singleton(permission)) && + userEverAnswered(activity, permission); + } + + public boolean areAllPermissionsGranted() { + if (mResultMap.isEmpty()) { + return false; + } + + for (boolean isGranted : mResultMap.values()) { + if (!isGranted) return false; + } + return true; + } + + public boolean isAnyPermissionDeniedWithDontAsk(@NonNull Activity activity) { + for (Map.Entry entry : mResultMap.entrySet()) { + Boolean isGranted = entry.getValue(); + if (!isGranted && + !shouldShowRequestPermissionRationale(activity, Collections.singleton(entry.getKey()))) { + return true; + } + } + return false; + } + + public Collection allPermissions() { + return mResultMap.keySet(); + } + } + + /** + * @return True if at least for one permission {@link ActivityCompat#shouldShowRequestPermissionRationale} + * returns True. + */ + public static boolean shouldShowRequestPermissionRationale(@NonNull Activity activity, + @NonNull Iterable permissions) { + for (String permission : permissions) { + if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) { + return true; + } + } + return false; + } + + /** + * Call it when permission is blocked + */ + public static void openSettings(@NonNull Context context) { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", context.getPackageName(), null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } +} diff --git a/client/android/div-core/src/main/java/com/yandex/div/core/utils/UiThreadHandler.kt b/client/android/div-core/src/main/java/com/yandex/div/core/utils/UiThreadHandler.kt new file mode 100644 index 000000000..6441bd730 --- /dev/null +++ b/client/android/div-core/src/main/java/com/yandex/div/core/utils/UiThreadHandler.kt @@ -0,0 +1,56 @@ +package com.yandex.div.core.utils + +import android.os.Handler +import android.os.Looper + +/** + * Provides static access to the main thread handler. + */ +object UiThreadHandler { + + private val INSTANCE = Handler(Looper.getMainLooper()) + + /** + * Returns value indicating if current thread is main thread. + */ + @JvmStatic + fun isMainThread() = Thread.currentThread() == Looper.getMainLooper().thread + + /** + * Returns main thread handler. + */ + @JvmStatic + fun get(): Handler { + return INSTANCE + } + + /** + * Posts given function on the main thread. + */ + fun postOnMainThread(runnable: () -> Unit) = INSTANCE.post(runnable) + + /** + * Executes a given [Runnable] on main thread. + * Executes runnable immediately if current thread is main thread, posts runnable to main thread handler otherwise. + */ + @JvmStatic + fun executeOnMainThread(runnable: Runnable) { + if (isMainThread()) { + runnable.run() + } else { + INSTANCE.post(runnable) + } + } + + /** + * Executes a given [action] on main thread. + * Executes runnable immediately if current thread is main thread, posts runnable to main thread handler otherwise. + */ + inline fun executeOnMainThread(crossinline action: () -> Unit) { + if (isMainThread()) { + action() + } else { + get().post { action() } + } + } +} diff --git a/client/android/div-data/YAOWNERS b/client/android/div-data/YAOWNERS new file mode 100644 index 000000000..933cc4122 --- /dev/null +++ b/client/android/div-data/YAOWNERS @@ -0,0 +1,9 @@ +pkurchatov@yandex-team.ru +avstaim@yandex-team.ru +tayrinn@yandex-team.ru +netimen@yandex-team.ru +gulevsky@yandex-team.ru +gump@yandex-team.ru + +set noparent +set lock \ No newline at end of file diff --git a/client/android/div-data/build.gradle b/client/android/div-data/build.gradle new file mode 100644 index 000000000..9741b60ee --- /dev/null +++ b/client/android/div-data/build.gradle @@ -0,0 +1,88 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +def generatedSrcDir = new File(buildDir, "generated/source") +def generatedDivSrcDir = new File(generatedSrcDir, "div") + +allOpen { + annotation("com.yandex.div.core.annotations.Mockable") +} + +android { + + sourceSets { + main { + java.srcDirs += generatedDivSrcDir + } + } + + libraryVariants.all { variant -> + variant.preBuildProvider.configure { dependsOn "generateHomePojoTask" } + } +} + +dependencies { + implementation project(path: ':assertion') + implementation project(path: ':div-core') + implementation project(path: ':div-evaluable') + implementation project(path: ':div-json') + implementation project(path: ':utils') + testImplementation "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin" +} + +static def convertPathToUnixStyle(String path) { + return "/mnt/" + path.substring(0, 1).toLowerCase() + path.substring(2).replace("\\", "/") +} + +// Allows to use the Mainframer with Windows client +task setHomePojoGeneratorExecAttrOnMainframer(type: Exec) { + def binPath = new File(projectDir, '../../../../internal/homeapigenerator-binaries/linux/HomeAPIGenerator.sh').absolutePath + commandLine 'chmod', '755', binPath + println commandLine +} + +setHomePojoGeneratorExecAttrOnMainframer.onlyIf { rootDir.toString().contains('mainframer') } + +def osName = System.getProperty('os.name').toLowerCase() +def schemas = [ + ["name" : "div2", + "scheme": new File(projectDir, "../../../schema"), + "generated": generatedDivSrcDir.absolutePath + '/com/yandex/div2', + "config": new File(projectDir, 'div2-generator-config.json')] +] + +schemas.forEach{ item -> + tasks.create(name: "scheme_${item['name']}", type: Exec, dependsOn: setHomePojoGeneratorExecAttrOnMainframer) { + def schemasDirectory = item['scheme'].absolutePath + def generatedDir = item['generated'] + def configPath = item['config'].absolutePath + + println "Process schemas: $schemasDirectory" + def binPath = new File(projectDir, '../../../../internal/homeapigenerator-binaries/').absolutePath + if (osName.contains('windows')) { + binPath = binPath + '/linux/HomeAPIGenerator' + def bashCommand = 'start /w c:\\Windows\\System32\\bash.exe -c \"' + + convertPathToUnixStyle(binPath) + ' ' + + convertPathToUnixStyle(configPath) + ' ' + + convertPathToUnixStyle(schemasDirectory) + ' ' + + convertPathToUnixStyle(generatedDir) + '\"' + commandLine 'cmd', '/c', bashCommand + + } else if (osName.contains('linux')) { + binPath = binPath + '/linux/HomeAPIGenerator.sh' + commandLine binPath, configPath, schemasDirectory, generatedDir + } else { + binPath = binPath + '/macosx/HomeAPIGenerator' + commandLine binPath, configPath, schemasDirectory, generatedDir + } + + println commandLine + + inputs.dir item['scheme'] + inputs.file binPath + outputs.dir generatedDir + } +} + +task generateHomePojoTask(dependsOn: tasks.findAll {task -> task.name.startsWith('scheme_')}) diff --git a/client/android/div-data/div2-generator-config.json b/client/android/div-data/div2-generator-config.json new file mode 100644 index 000000000..d4454f908 --- /dev/null +++ b/client/android/div-data/div2-generator-config.json @@ -0,0 +1,28 @@ +{ + "lang": "kotlin", + "imports": [ + "android.graphics.Color", + "android.net.Uri", + "androidx.annotation.ColorInt", + "com.yandex.div.json.*", + "com.yandex.div.json.expressions.Expression", + "com.yandex.div.json.expressions.ExpressionsList", + "com.yandex.div.json.schema.*", + "com.yandex.div.core.annotations.Mockable", + "java.io.IOException", + "java.util.BitSet", + "org.json.JSONObject", + "com.yandex.div.data.*" + ], + "java": { + "package": "com.yandex.div2", + "annotations": [ + "@Mockable" + ] + }, + "errorsCollectors": [ + "div-data" + ], + "generateExpressions": true, + "generateUnresolvedEntities": true +} diff --git a/client/android/div-data/jacoco.excludes b/client/android/div-data/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-data/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-data/proguard-rules.pro b/client/android/div-data/proguard-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/client/android/div-data/src/main/AndroidManifest.xml b/client/android/div-data/src/main/AndroidManifest.xml new file mode 100644 index 000000000..9773b52e4 --- /dev/null +++ b/client/android/div-data/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/client/android/div-data/src/main/java/com/yandex/div/DivApi.kt b/client/android/div-data/src/main/java/com/yandex/div/DivApi.kt new file mode 100644 index 000000000..c133bf12e --- /dev/null +++ b/client/android/div-data/src/main/java/com/yandex/div/DivApi.kt @@ -0,0 +1,14 @@ +package com.yandex.div + + +/** + * Div API parameters. + */ + +object DivApi { + + /** + * Div API version. + */ + const val VERSION = "2.3" +} diff --git a/client/android/div-data/src/main/java/com/yandex/div/DivBlockWithId.java b/client/android/div-data/src/main/java/com/yandex/div/DivBlockWithId.java new file mode 100644 index 000000000..2080fa5a7 --- /dev/null +++ b/client/android/div-data/src/main/java/com/yandex/div/DivBlockWithId.java @@ -0,0 +1,47 @@ +package com.yandex.div; + +import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.div.core.util.Assert; +import com.yandex.div.json.JSONSerializable; +import com.yandex.div.json.ParsingErrorLogger; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Base class for blocks, provides unique block identification inside a div. + * This identification is runtime. See DivView#switchToState + * This id is like xpath, like 'tabs/0/gallery/1/button' + */ +public abstract class DivBlockWithId implements JSONSerializable { + + @NonNull + private String mBlockId = ""; + + @SuppressWarnings("unused") // this constructor required by generated inherited classes + protected DivBlockWithId(@Nullable JSONObject jsonSource, @Nullable ParsingErrorLogger parsingErrorCallback) { } + + @NonNull + public static String appendId(@Nullable String parentId, @Nullable String childId) { + return parentId + "/" + childId; + } + + @NonNull + public String getBlockId() { + if (TextUtils.isEmpty(mBlockId)) { + Assert.fail("block id not initialized, call setBlockId first"); + } + return mBlockId; + } + + public void setBlockId(@NonNull String blockId) { + mBlockId = blockId; + } + + @NonNull + @Override + public JSONObject writeToJSON() throws JSONException { // this method is required by generated inherited classes + return new JSONObject(); + } +} diff --git a/client/android/div-data/src/main/java/com/yandex/div/data/DivParsingEnvironment.kt b/client/android/div-data/src/main/java/com/yandex/div/data/DivParsingEnvironment.kt new file mode 100644 index 000000000..22a099257 --- /dev/null +++ b/client/android/div-data/src/main/java/com/yandex/div/data/DivParsingEnvironment.kt @@ -0,0 +1,28 @@ +package com.yandex.div.data + +import com.yandex.div.json.ParsingEnvironment +import com.yandex.div.json.ParsingErrorLogger +import com.yandex.div.json.TemplateParsingEnvironment +import com.yandex.div.json.templates.InMemoryTemplateProvider +import com.yandex.div.json.templates.MainTemplateProvider +import com.yandex.div.json.templates.TemplateProvider +import com.yandex.div2.DivTemplate +import org.json.JSONException +import org.json.JSONObject + +class DivParsingEnvironment @JvmOverloads constructor( + logger: ParsingErrorLogger, + mainTemplateProvider: MainTemplateProvider = MainTemplateProvider( + InMemoryTemplateProvider(), + TemplateProvider.empty(), + ) +) : TemplateParsingEnvironment(logger, mainTemplateProvider) { + override val templates: MainTemplateProvider = mainTemplateProvider + override val templateFactory = object: TemplateFactory { + + @Throws(JSONException::class) + override fun create(env: ParsingEnvironment, topLevel: Boolean, json: JSONObject): DivTemplate { + return DivTemplate(env, topLevel, json) + } + } +} diff --git a/client/android/div-data/src/main/java/com/yandex/div/data/ErrorsCollectorEnvironment.kt b/client/android/div-data/src/main/java/com/yandex/div/data/ErrorsCollectorEnvironment.kt new file mode 100644 index 000000000..f81fa7dc9 --- /dev/null +++ b/client/android/div-data/src/main/java/com/yandex/div/data/ErrorsCollectorEnvironment.kt @@ -0,0 +1,31 @@ +package com.yandex.div.data + +import com.yandex.div.json.ParsingEnvironment +import com.yandex.div.json.ParsingErrorLogger + +internal class ErrorsCollectorEnvironment( + origin: ParsingEnvironment, +): ParsingEnvironment { + private val originLogger = origin.logger + private val errors = mutableListOf() + + override val templates = origin.templates + override val logger = ParsingErrorLogger { e -> + errors.add(e) + originLogger.logError(e) + } + + fun collectErrors(): List { + return errors.toList() + } +} + +internal fun ParsingEnvironment.withErrorsCollector() = ErrorsCollectorEnvironment(this) + +internal fun ParsingEnvironment.collectErrors(): List { + return if (this is ErrorsCollectorEnvironment) { + this.collectErrors() + } else { + emptyList() + } +} diff --git a/client/android/div-data/src/main/java/com/yandex/div/data/Variable.kt b/client/android/div-data/src/main/java/com/yandex/div/data/Variable.kt new file mode 100644 index 000000000..51a77295a --- /dev/null +++ b/client/android/div-data/src/main/java/com/yandex/div/data/Variable.kt @@ -0,0 +1,196 @@ +package com.yandex.div.data + +import android.net.Uri +import androidx.annotation.MainThread +import com.yandex.div.core.annotations.Mockable +import com.yandex.div.core.util.Assert +import com.yandex.div.evaluable.types.Color +import com.yandex.div.json.STRING_TO_COLOR_INT +import com.yandex.div.json.toBoolean +import com.yandex.div.util.SynchronizedList + +@Mockable +sealed class Variable { + abstract val name: String + private val observers = SynchronizedList<(Variable) -> Unit>() + + class StringVariable( + override val name: String, + val defaultValue: String, + ) : Variable() { + internal var value: String = defaultValue + set(value) { + if (field == value) { + return + } + field = value + notifyVariableChanged(this) + } + } + + class IntegerVariable( + override val name: String, + val defaultValue: Int, + ) : Variable() { + internal var value: Int = defaultValue + set(value) { + if (field == value) { + return + } + field = value + notifyVariableChanged(this) + } + } + + class BooleanVariable( + override val name: String, + val defaultValue: Boolean) : Variable() { + internal var value: Boolean = defaultValue + set(value) { + if (field == value) { + return + } + field = value + notifyVariableChanged(this) + } + } + + class DoubleVariable(override val name: String, + val defaultValue: Double) : Variable() { + internal var value: Double = defaultValue + set(value) { + if (field == value) { + return + } + field = value + notifyVariableChanged(this) + } + } + + class ColorVariable( + override val name: String, + val defaultValue: Int, + ) : Variable() { + + internal var value: Color = Color(defaultValue) + set(value) { + if (field == value) { + return + } + field = value + notifyVariableChanged(this) + } + } + + class UrlVariable( + override val name: String, + val defaultValue: Uri, + ) : Variable() { + internal var value: Uri = defaultValue + set(value) { + if (field == value) { + return + } + field = value + notifyVariableChanged(this) + } + } + + fun getValue(): Any { + return when (this) { + is StringVariable -> value + is IntegerVariable -> value + is BooleanVariable -> value + is DoubleVariable -> value + is ColorVariable -> value + is UrlVariable -> value + } + } + + fun getDefaultValue(): Any { + return when (this) { + is StringVariable -> defaultValue + is IntegerVariable -> defaultValue + is BooleanVariable -> defaultValue + is DoubleVariable -> defaultValue + is ColorVariable -> defaultValue + is UrlVariable -> defaultValue + } + } + + fun addObserver(observer: (Variable) -> Unit) { + observers.add(observer) + } + + fun removeObserver(observer: (Variable) -> Unit) { + observers.remove(observer) + } + + protected fun notifyVariableChanged(v: Variable) { + Assert.assertMainThread() + observers.forEach { it.invoke(v) } + } + + @MainThread + @Throws(VariableMutationException::class) + fun set(newValue: String) { + return when (this) { + is StringVariable -> value = newValue + is IntegerVariable -> value = newValue.parseAsInt() + is BooleanVariable -> value = newValue.parseAsBoolean() + is DoubleVariable -> value = newValue.parseAsDouble() + is ColorVariable -> { + val color = STRING_TO_COLOR_INT(newValue) ?: throw VariableMutationException( + "Wrong value format for color variable: '$newValue'") + value = Color(color) + } + is UrlVariable -> value = newValue.parseAsUri() + } + } + + @MainThread + @Throws(VariableMutationException::class) + fun setValue(from: Variable) { + when { + this is StringVariable && from is StringVariable -> this.value = from.value + this is IntegerVariable && from is IntegerVariable -> this.value = from.value + this is BooleanVariable && from is BooleanVariable -> this.value = from.value + this is DoubleVariable && from is DoubleVariable -> this.value = from.value + this is ColorVariable && from is ColorVariable -> this.value = from.value + this is UrlVariable && from is UrlVariable -> this.value = from.value + else -> throw VariableMutationException("Setting value to $this from $from not supported!") + } + } + + private fun String.parseAsInt(): Int { + return try { + this.toInt() + } catch (e: NumberFormatException) { + throw VariableMutationException(cause = e) + } + } + + private fun String.parseAsBoolean(): Boolean { + try { + return parseAsInt().toBoolean() + } catch (e: IllegalArgumentException) { + throw VariableMutationException(cause = e) + } + } + + private fun String.parseAsDouble(): Double { + return try { + this.toDouble() + } catch (e: NumberFormatException) { + throw VariableMutationException(cause = e) + } + } + + private fun String.parseAsUri(): Uri { + return try { + Uri.parse(this) + } catch (e: IllegalArgumentException) { + throw VariableMutationException(cause = e) + } + } +} diff --git a/client/android/div-data/src/main/java/com/yandex/div/data/VariableDeclarationException.kt b/client/android/div-data/src/main/java/com/yandex/div/data/VariableDeclarationException.kt new file mode 100644 index 000000000..d91fdc68b --- /dev/null +++ b/client/android/div-data/src/main/java/com/yandex/div/data/VariableDeclarationException.kt @@ -0,0 +1,6 @@ +package com.yandex.div.data + +class VariableDeclarationException( + message: String? = null, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/client/android/div-data/src/main/java/com/yandex/div/data/VariableMutationException.kt b/client/android/div-data/src/main/java/com/yandex/div/data/VariableMutationException.kt new file mode 100644 index 000000000..637d5ec10 --- /dev/null +++ b/client/android/div-data/src/main/java/com/yandex/div/data/VariableMutationException.kt @@ -0,0 +1,6 @@ +package com.yandex.div.data + +class VariableMutationException( + message: String? = null, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/client/android/div-data/src/main/java/com/yandex/div/util/DivDataUtils.kt b/client/android/div-data/src/main/java/com/yandex/div/util/DivDataUtils.kt new file mode 100644 index 000000000..d7e254064 --- /dev/null +++ b/client/android/div-data/src/main/java/com/yandex/div/util/DivDataUtils.kt @@ -0,0 +1,11 @@ +package com.yandex.div.util + +import com.yandex.div2.DivData + +object DivDataUtils { + const val INVALID_STATE_ID = -1 + + @JvmStatic + fun DivData.getInitialStateId(): Int = + if (states.isEmpty()) INVALID_STATE_ID else states[0].stateId +} \ No newline at end of file diff --git a/client/android/div-data/src/test/java/com/yandex/div/data/MutableExpressionTest.kt b/client/android/div-data/src/test/java/com/yandex/div/data/MutableExpressionTest.kt new file mode 100644 index 000000000..2c1795a17 --- /dev/null +++ b/client/android/div-data/src/test/java/com/yandex/div/data/MutableExpressionTest.kt @@ -0,0 +1,86 @@ +package com.yandex.div.data + +import com.yandex.div.json.ParsingErrorLogger +import com.yandex.div.json.TYPE_HELPER_STRING +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionResolver +import com.yandex.div.json.invalidValue +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests for [Expression.MutableExpression] + */ +@Config(sdk = [28]) +@RunWith(RobolectricTestRunner::class) +class MutableExpressionTest { + private val failFastLogger = ParsingErrorLogger { e -> throw e } + private val silentLogger = ParsingErrorLogger { e -> e.printStackTrace() } + private val resolver = mock { + on { get(any(), any(), any(), anyOrNull(), any(), any(), any()) } doReturn null + } + + + @Test + fun `once resolved expression cannot be resolved last valid value is used`() { + val initialValue = "yandex.ru" + val expression = mutableExpression( + rawExpression = "@{domain}", + logger = silentLogger, + validator = { it == initialValue }, + ) + + onResolveReturn(value = initialValue) + expression.evaluate(resolver) // resolving initialValue + throwOnExpressionResolve() + Assert.assertEquals(initialValue, expression.evaluate(resolver)) + } + + @Test + fun `once resolved expression becomes corrupted last valid value is used`() { + val initialValue = "http://yandex.ru/mail" + val expression = mutableExpression( + rawExpression = "http://@{host}/@{path}", + logger = silentLogger, + validator = { it == initialValue }, + ) + + onResolveReturn(value = initialValue) + + expression.evaluate(resolver) // resolving initialValue + + onResolveReturn(value = null) + Assert.assertEquals(initialValue, expression.evaluate(resolver)) + } + + private fun onResolveReturn(value: Any?) { + whenever(resolver.get(any(), any(), any(), anyOrNull(), any(), any(), any())) + .thenReturn(value) + } + + private fun throwOnExpressionResolve() { + whenever(resolver.get(any(), any(), any(), anyOrNull(), any(), any(), any())) + .thenThrow(invalidValue("/path", "value")) + } + + private fun mutableExpression( + rawExpression: String, + logger: ParsingErrorLogger = failFastLogger, + validator: (String) -> Boolean = { true }, + ) = Expression.MutableExpression( + expressionKey = "some_key", + rawExpression = rawExpression, + validator = validator, + converter = { it }, + logger = logger, + typeHelper = TYPE_HELPER_STRING, + ) +} diff --git a/client/android/div-data/src/test/java/com/yandex/div/data/VariableTest.kt b/client/android/div-data/src/test/java/com/yandex/div/data/VariableTest.kt new file mode 100644 index 000000000..1dd0bc967 --- /dev/null +++ b/client/android/div-data/src/test/java/com/yandex/div/data/VariableTest.kt @@ -0,0 +1,68 @@ +package com.yandex.div.data + +import android.net.Uri +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.random.Random +import kotlin.reflect.KClass + +/** + * Tests for [Variable]. + */ +@RunWith(RobolectricTestRunner::class) +class VariableTest { + + @Test + fun `all types of variables generated by test function`() { + val types = mutableListOf>() + Variable::class.sealedSubclasses.forEach { + types.add(it) + } + generateAllTypesOfRandomVariables().forEach { + types.remove(it::class) + } + + Assert.assertTrue( + """ + All types should be provided by `generateAllTypesOfRandomVariables()`! + Missing support for: ${types.joinToString()} + """.trimIndent(), + types.isEmpty() + ) + } + + @Test + fun `variables of same type can change their values`() { + val v1 = generateAllTypesOfRandomVariables() + val v2 = generateAllTypesOfRandomVariables() + v1.toList().forEachIndexed { index, _ -> + val oldVariable = v1[index] + val newVariable = v2[index] + oldVariable.setValue(from = newVariable) + Assert.assertEquals(newVariable.getValue(), oldVariable.getValue()) + } + } + + @Test + fun `variable sending change callbacks without actual change`() { + generateAllTypesOfRandomVariables().forEach { variable -> + variable.addObserver { + Assert.fail("Got variable callback from $it despite no actual change happened") + } + variable.setValue(variable) + } + } + + private fun generateAllTypesOfRandomVariables(): List { + return setOf( + Variable.StringVariable("string_var", Random.nextBytes(4).toString()), + Variable.IntegerVariable("int_var", Random.nextInt()), + Variable.DoubleVariable("double_var", Random.nextDouble()), + Variable.BooleanVariable("boolean_var", Random.nextBoolean()), + Variable.ColorVariable("color_var", Random.nextInt()), + Variable.UrlVariable("url_var", Uri.parse(Random.nextBytes(4).toString())), + ).toList() + } +} diff --git a/client/android/div-data/src/test/resources/robolectric.properties b/client/android/div-data/src/test/resources/robolectric.properties new file mode 100644 index 000000000..932b01b9e --- /dev/null +++ b/client/android/div-data/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/client/android/div-evaluable/YAOWNERS b/client/android/div-evaluable/YAOWNERS new file mode 100644 index 000000000..a47a61bbe --- /dev/null +++ b/client/android/div-evaluable/YAOWNERS @@ -0,0 +1,10 @@ +gulevsky@yandex-team.ru +tayrinn@yandex-team.ru +pkurchatov@yandex-team.ru +ttok@yandex-team.ru +i-ts@yandex-team.ru +jelyscs@yandex-team.ru +syntezzz@yandex-team.ru + +set noparent +set lock diff --git a/client/android/div-evaluable/build.gradle b/client/android/div-evaluable/build.gradle new file mode 100644 index 000000000..1d8841ae5 --- /dev/null +++ b/client/android/div-evaluable/build.gradle @@ -0,0 +1,31 @@ +apply plugin: 'java-library' +apply plugin: 'org.jetbrains.kotlin.jvm' +apply from: "${project.projectDir}/../publish-java.gradle" + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +task replJar(type: Jar, dependsOn: "testClasses") { + from "$buildDir/classes/kotlin/test" + from "$buildDir/classes/kotlin/main" + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } + duplicatesStrategy = DuplicatesStrategy.INCLUDE + manifest { + attributes 'Main-Class': 'com.yandex.div.evaluable.repl.EvaluableRepl' + } + classifier "repl" +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$versions.kotlin" + implementation "javax.inject:javax.inject:$versions.javax" + + testImplementation "org.json:json:$versions.json" + testImplementation "junit:junit:$versions.junit" + testImplementation "org.mockito:mockito-core:$versions.mockito" + testImplementation "org.mockito.kotlin:mockito-kotlin:$versions.mockitoKotlin" +} diff --git a/client/android/div-evaluable/jacoco.excludes b/client/android/div-evaluable/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-evaluable/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-evaluable/proguard-rules.pro b/client/android/div-evaluable/proguard-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/Evaluable.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/Evaluable.kt new file mode 100644 index 000000000..aea52e0d3 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/Evaluable.kt @@ -0,0 +1,126 @@ +package com.yandex.div.evaluable + +import com.yandex.div.evaluable.internal.Parser +import com.yandex.div.evaluable.internal.Token +import com.yandex.div.evaluable.internal.Tokenizer + +abstract class Evaluable(val rawExpr: String) { + + abstract val variables: List + + @Throws(EvaluableException::class) + internal abstract fun eval(evaluator: Evaluator): Any + + internal class Lazy(private val expr: String): Evaluable(expr) { + private val tokens = Tokenizer.tokenize(expr) + private lateinit var expression: Evaluable + override val variables: List + get() = if (this::expression.isInitialized) { + expression.variables + } else { + tokens.filterIsInstance(Token.Operand.Variable::class.java).map { it.name } + } + override fun eval(evaluator: Evaluator): Any { + if (!this::expression.isInitialized) { + expression = Parser.parse(tokens, rawExpr) + } + return expression.eval(evaluator) + } + override fun toString(): String = expr + } + + internal data class Unary( + val token: Token.Operator, + val expression: Evaluable, + val rawExpression: String, + ) : Evaluable(rawExpression) { + override val variables: List = expression.variables + override fun eval(evaluator: Evaluator): Any = evaluator.evalUnary(this) + override fun toString(): String = "$token$expression" + } + + internal data class Binary( + val token: Token.Operator.Binary, + val left: Evaluable, + val right: Evaluable, + val rawExpression: String, + ) : Evaluable(rawExpression) { + override val variables: List = left.variables + right.variables + override fun eval(evaluator: Evaluator): Any = evaluator.evalBinary(this) + override fun toString(): String = "($left $token $right)" + } + + internal data class Ternary( + val token: Token.Operator, + val firstExpression: Evaluable, + val secondExpression: Evaluable, + val thirdExpression: Evaluable, + val rawExpression: String, + ) : Evaluable(rawExpression) { + override val variables: List = firstExpression.variables + + secondExpression.variables + thirdExpression.variables + override fun eval(evaluator: Evaluator): Any = evaluator.evalTernary(this) + override fun toString(): String { + val opIf = Token.Operator.TernaryIf + val opElse = Token.Operator.TernaryElse + return "($firstExpression $opIf $secondExpression $opElse $thirdExpression)" + } + } + + internal data class FunctionCall( + val token: Token.Function, + val arguments: List, + val rawExpression: String, + ) : Evaluable(rawExpression) { + override val variables: List = + arguments.map { it.variables }.reduceOrNull { acc, vars -> acc + vars } ?: emptyList() + override fun eval(evaluator: Evaluator): Any = evaluator.evalFunctionCall(this) + override fun toString(): String { + val argsString = arguments.joinToString(separator = Token.Function.ArgumentDelimiter.toString()) + return "${token.name}($argsString)" + } + } + + internal data class StringTemplate( + val arguments: List, + val rawExpression: String, + ) : Evaluable(rawExpression) { + override val variables: List = arguments.map { it.variables }.reduce { acc, vars -> acc + vars } + override fun eval(evaluator: Evaluator): Any = evaluator.evalStringTemplate(this) + override fun toString(): String = arguments.joinToString(separator = "") + } + + internal data class Variable( + val token: Token.Operand.Variable, + val rawExpression: String, + ) : Evaluable(rawExpression) { + override val variables: List = listOf(token.name) + override fun eval(evaluator: Evaluator): Any = evaluator.evalVariable(this) + override fun toString(): String = token.name + } + + internal data class Value( + val token: Token.Operand.Literal, + val rawExpression: String, + ) : Evaluable(rawExpression) { + override val variables: List = emptyList() + override fun eval(evaluator: Evaluator): Any = evaluator.evalValue(this) + override fun toString(): String = when (token) { + is Token.Operand.Literal.Str -> "'${token.value}'" + is Token.Operand.Literal.Num -> token.value.toString() + is Token.Operand.Literal.Bool -> token.value.toString() + } + } + + companion object { + @JvmStatic + fun prepare(expr: String) : Evaluable { + return Parser.parse(Tokenizer.tokenize(expr), expr) + } + + @JvmStatic + fun lazy(expr: String) : Evaluable { + return Lazy(expr) + } + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/EvaluableException.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/EvaluableException.kt new file mode 100644 index 000000000..229892616 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/EvaluableException.kt @@ -0,0 +1,76 @@ +package com.yandex.div.evaluable + +import com.yandex.div.evaluable.internal.Token + +internal const val REASON_DIVISION_BY_ZERO = "Division by zero is not supported." +internal const val REASON_EMPTY_ARGUMENT_LIST = "Non empty argument list is required." +internal const val REASON_INTEGER_OVERFLOW = "Integer overflow." +internal const val REASON_OUT_OF_BOUNDS = "Indexes are out of bounds." +internal const val REASON_INDEXES_ORDER = "Indexes should be in ascending order." +internal const val REASON_CONVERT_TO_INTEGER = "Unable to convert value to Integer." +internal const val REASON_CONVERT_TO_NUMBER = "Unable to convert value to Number." +internal const val REASON_CONVERT_TO_BOOLEAN = "Unable to convert value to Boolean." +internal const val REASON_CONVERT_TO_COLOR = "Unable to convert value to Color, expected format #AARRGGBB." +internal const val REASON_OUT_OF_RANGE = "Value out of range 0..1." + +open class EvaluableException( + message: String, + cause: Exception? = null +) : RuntimeException(message, cause) + +class MissingVariableException( + val variableName: String, + cause: Exception? = null +) : EvaluableException("Variable '${variableName}' is missing", cause) + +internal fun throwExceptionOnEvaluationFailed( + expression: String, + reason: String, + cause: Exception? = null +): Nothing = throw EvaluableException("Failed to evaluate [$expression]. $reason", cause) + +internal fun throwExceptionOnFunctionEvaluationFailed( + name: String, + args: List, + reason: String, + cause: Exception? = null +): Nothing = throwExceptionOnEvaluationFailed(functionToMessageFormat(name, args), reason, cause) + + +private fun functionToMessageFormat(name: String, args: List): String { + return args.joinToString(prefix = "${name}(", postfix = ")") { + it.toMessageFormat() + } +} + + +internal fun throwExceptionOnEvaluationFailed( + operator: Token.Operator.Binary, + left: Any, + right: Any, +): Nothing { + val evaluable = "${left.toMessageFormat()} $operator ${right.toMessageFormat()}" + val typesMessage = if (left.javaClass != right.javaClass) { + "different types: ${EvaluableType.of(left).typeName} and ${EvaluableType.of(right).typeName}" + } else { + "${EvaluableType.of(left).typeName} type" + } + throwExceptionOnEvaluationFailed( + evaluable, + "Operator '$operator' cannot be applied to $typesMessage." + ) +} + +internal fun List.toMessageFormat(): String { + return this.joinToString(", ") { + it.toMessageFormat() + } +} + +internal fun Any.toMessageFormat(): String { + return if (this is String) { + "'$this'" + } else { + toString() + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/EvaluableType.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/EvaluableType.kt new file mode 100644 index 000000000..4aea5f05d --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/EvaluableType.kt @@ -0,0 +1,36 @@ +package com.yandex.div.evaluable + +import com.yandex.div.evaluable.types.Color +import com.yandex.div.evaluable.types.DateTime + +enum class EvaluableType(internal val typeName: String) { + INTEGER("Integer"), + NUMBER("Number"), + BOOLEAN("Boolean"), + STRING("String"), + DATETIME("DateTime"), + COLOR("Color"), + ; + + override fun toString(): String { + return typeName + } + + companion object { + + @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") + @JvmStatic + inline fun of(value: T): EvaluableType { + return when (value) { + is Int -> INTEGER + is Double -> NUMBER + is Boolean -> BOOLEAN + is String -> STRING + is DateTime -> DATETIME + is Color -> COLOR + null -> throw EvaluableException("Unable to find type for null") + else -> throw EvaluableException("Unable to find type for ${value!!::class.java.name}") + } + } + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/Evaluator.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/Evaluator.kt new file mode 100644 index 000000000..1014981b9 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/Evaluator.kt @@ -0,0 +1,254 @@ +package com.yandex.div.evaluable + +import com.yandex.div.evaluable.internal.Token +import com.yandex.div.evaluable.types.DateTime + +class Evaluator( + private val variableProvider: VariableProvider, + private val functionProvider: FunctionProvider +) { + + @Throws(EvaluableException::class) + @Suppress("UNCHECKED_CAST") + fun eval(expr: Evaluable): T { + return try { + expr.eval(this) as T + } catch (e: EvaluableException) { + throw e + } catch (e: Exception) { + throw EvaluableException(e.message.orEmpty(), e) + } + } + + internal fun evalUnary(unary: Evaluable.Unary): Any { + val literal: Any = eval(unary.expression) + return when (unary.token) { + is Token.Operator.Unary.Plus -> { + when (literal) { + is Int -> literal.unaryPlus() + is Double -> literal.unaryPlus() + else -> throwExceptionOnEvaluationFailed("+$literal", "A Number is expected after a unary plus.") + } + } + is Token.Operator.Unary.Minus -> { + when (literal) { + is Int -> literal.unaryMinus() + is Double -> literal.unaryMinus() + else -> throwExceptionOnEvaluationFailed("-$literal", "A Number is expected after a unary minus.") + } + } + Token.Operator.Unary.Not -> { + if (literal !is Boolean) { + throwExceptionOnEvaluationFailed("!$literal", "A Boolean is expected after a unary not.") + } + !literal + } + else -> throw EvaluableException("${unary.token} was incorrectly parsed as a unary operator.") + } + } + + internal fun evalBinary(binary: Evaluable.Binary): Any { + val left: Any = eval(binary.left) + + // Logical + if (binary.token is Token.Operator.Binary.Logical) { + return evalLogical(binary.token, left) { eval(binary.right) } + } + + val right: Any = eval(binary.right) + if (left.javaClass != right.javaClass) { + throwExceptionOnEvaluationFailed(binary.token, left, right) + } + + return when (binary.token) { + is Token.Operator.Binary.Equality -> evalEquality(binary.token, left, right) + is Token.Operator.Binary.Sum -> evalSum(binary.token, left, right) + is Token.Operator.Binary.Factor -> evalFactor(binary.token, left, right) + is Token.Operator.Binary.Comparison -> evalComparison(binary.token, left, right) + else -> throwExceptionOnEvaluationFailed(binary.token, left, right) + } + } + + private fun evalLogical( + operator: Token.Operator.Binary.Logical, + left: Any, + rightEvaluator: () -> Any + ): Any { + if (left !is Boolean) { + throwExceptionOnEvaluationFailed("$left $operator ...","'$operator' must be called with boolean operands.") + } + if (operator is Token.Operator.Binary.Logical.Or && left) { + return left + } + val right: Any = rightEvaluator() + if (right !is Boolean) { + throwExceptionOnEvaluationFailed(operator, left, right) + } + return if (operator is Token.Operator.Binary.Logical.Or) { + left || right + } else { + left && right + } + } + + private fun evalEquality(operator: Token.Operator.Binary.Equality, left: Any, right: Any): Any { + return when (operator) { + is Token.Operator.Binary.Equality.Equal -> left == right + is Token.Operator.Binary.Equality.NotEqual -> left != right + } + } + + private fun evalComparison( + operator: Token.Operator.Binary.Comparison, + left: Any, + right: Any + ): Any { + if (left is Double && right is Double) { + return evalComparableTypes(operator, left, right) + } + if (left is Int && right is Int) { + return evalComparableTypes(operator, left, right) + } + if (left is DateTime && right is DateTime) { + return evalComparableTypes(operator, left, right) + } + throwExceptionOnEvaluationFailed(operator, left, right) + } + + private fun > evalComparableTypes( + operator: Token.Operator.Binary.Comparison, + left: T, right: T + ): Any { + return when (operator) { + is Token.Operator.Binary.Comparison.Less -> left < right + is Token.Operator.Binary.Comparison.LessOrEqual -> left <= right + is Token.Operator.Binary.Comparison.GreaterOrEqual -> left >= right + is Token.Operator.Binary.Comparison.Greater -> left > right + } + } + + internal fun evalTernary(ternary: Evaluable.Ternary): Any { + if (ternary.token is Token.Operator.TernaryIfElse) { + val left: Any = eval(ternary.firstExpression) + if (left !is Boolean) { + throwExceptionOnEvaluationFailed(ternary.rawExpr, "Ternary must be called with a Boolean value as a condition.") + } + return if (left) { + eval(ternary.secondExpression) + } else { + eval(ternary.thirdExpression) + } + } else { + throwExceptionOnEvaluationFailed(ternary.rawExpr, "${ternary.token} was incorrectly parsed as a ternary operator.") + } + } + + internal fun evalFunctionCall(functionCall: Evaluable.FunctionCall): Any { + val arguments = mutableListOf() + for (arg in functionCall.arguments) { + arguments.add(eval(arg)) + } + val argTypes = arguments.map { arg -> + EvaluableType.of(arg) + } + val function = try { + functionProvider.get(functionCall.token.name, argTypes) + } catch (e: EvaluableException) { + throwExceptionOnFunctionEvaluationFailed(functionCall.token.name, arguments, e.message ?: "") + } + return function.invoke(arguments) + } + + internal fun evalStringTemplate(stringTemplate: Evaluable.StringTemplate): String { + val stringParts = mutableListOf() + for (arg in stringTemplate.arguments) { + val value: String = eval(arg).toString() + stringParts.add(value) + } + return stringParts.joinToString(separator = "") + } + + internal fun evalValue(call: Evaluable.Value): Any { + return when (val token = call.token) { + is Token.Operand.Literal.Num -> token.value + is Token.Operand.Literal.Bool -> token.value + is Token.Operand.Literal.Str -> token.value + } + } + + internal fun evalVariable(call: Evaluable.Variable): Any { + return variableProvider.get(call.token.name) + ?: throw MissingVariableException(variableName = call.token.name) + } + + companion object { + internal fun evalSum(operator: Token.Operator.Binary.Sum, left: Any, right: Any): Any { + return when { + left is String && right is String -> { + // Concatenation + when (operator) { + is Token.Operator.Binary.Sum.Plus -> "$left$right" + else -> throwExceptionOnEvaluationFailed(operator, left, right) + } + } + left is Int && right is Int -> { + when (operator) { + is Token.Operator.Binary.Sum.Plus -> left + right + is Token.Operator.Binary.Sum.Minus -> left - right + } + } + left is Double && right is Double -> { + when (operator) { + is Token.Operator.Binary.Sum.Plus -> left + right + is Token.Operator.Binary.Sum.Minus -> left - right + } + } + else -> throwExceptionOnEvaluationFailed(operator, left, right) + } + } + + internal fun evalFactor( + operator: Token.Operator.Binary.Factor, + left: Any, + right: Any + ): Any { + return when { + left is Int && right is Int -> { + when (operator) { + is Token.Operator.Binary.Factor.Multiplication -> left * right + is Token.Operator.Binary.Factor.Division -> { + if (right == 0) { + throwExceptionOnEvaluationFailed("$left / $right", REASON_DIVISION_BY_ZERO) + } + left / right + } + is Token.Operator.Binary.Factor.Modulo -> { + if (right == 0) { + throwExceptionOnEvaluationFailed("$left % $right", REASON_DIVISION_BY_ZERO) + } + left % right + } + } + } + left is Double && right is Double -> { + when (operator) { + is Token.Operator.Binary.Factor.Multiplication -> left * right + is Token.Operator.Binary.Factor.Division -> { + if (right == 0f) { + throwExceptionOnEvaluationFailed("$left / $right", REASON_DIVISION_BY_ZERO) + } + left / right + } + is Token.Operator.Binary.Factor.Modulo -> { + if (right == 0f) { + throwExceptionOnEvaluationFailed("$left % $right", REASON_DIVISION_BY_ZERO) + } + left % right + } + } + } + else -> throwExceptionOnEvaluationFailed(operator, left, right) + } + } + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/Function.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/Function.kt new file mode 100644 index 000000000..7a1aa50ac --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/Function.kt @@ -0,0 +1,73 @@ +package com.yandex.div.evaluable + +abstract class Function { + + abstract val name: String + abstract val declaredArgs: List + abstract val resultType: EvaluableType + abstract val isPure: Boolean + + protected abstract fun evaluate(args: List): Any + + operator fun invoke(args: List): Any { + val result = evaluate(args) + if (EvaluableType.of(result) != resultType) { + throw EvaluableException("Function returned ${EvaluableType.of(result)}, but $resultType was expected") + } + return result + } + + internal fun matchesArguments(argTypes: List): MatchResult { + val argumentMin: Int + val argumentMax: Int + if (declaredArgs.isEmpty()) { + argumentMin = 0 + argumentMax = 0 + } else { + val hasVarArg = declaredArgs.last().isVariadic + argumentMin = if (hasVarArg) declaredArgs.size - 1 else declaredArgs.size + argumentMax = if (hasVarArg) Int.MAX_VALUE else declaredArgs.size + } + + if (argTypes.size < argumentMin) { + return MatchResult.TooFewArguments(expected = argumentMin, actual = argTypes.size) + } + if (argTypes.size > argumentMax) { + return MatchResult.TooManyArguments(expected = argumentMax, actual = argTypes.size) + } + + for (index in argTypes.indices) { + val declaredArg = declaredArgs[index.coerceAtMost(declaredArgs.lastIndex)] + if (argTypes[index] != declaredArg.type) { + return MatchResult.ArgTypeMismatch(expected = declaredArg.type, actual = argTypes[index]) + } + } + return MatchResult.Ok + } + + override fun toString(): String { + return declaredArgs.joinToString(prefix = "${name}(", postfix = ")") { arg -> + if (arg.isVariadic) "vararg ${arg.type}" else arg.type.toString() + } + } + + companion object { + @JvmField + val STUB = object : Function() { + + override val name = "stub" + override val declaredArgs = emptyList() + override val resultType = EvaluableType.BOOLEAN + override val isPure = true + + override fun evaluate(args: List): Any = true + } + } + + internal sealed class MatchResult { + object Ok : MatchResult() + class TooFewArguments(val expected: Int, val actual: Int) : MatchResult() + class TooManyArguments(val expected: Int, val actual: Int) : MatchResult() + class ArgTypeMismatch(val expected: EvaluableType, val actual: EvaluableType) : MatchResult() + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/FunctionArgument.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/FunctionArgument.kt new file mode 100644 index 000000000..0578dc791 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/FunctionArgument.kt @@ -0,0 +1,6 @@ +package com.yandex.div.evaluable + +data class FunctionArgument( + val type: EvaluableType, + val isVariadic: Boolean = false +) diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/FunctionProvider.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/FunctionProvider.kt new file mode 100644 index 000000000..0547855ec --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/FunctionProvider.kt @@ -0,0 +1,16 @@ +package com.yandex.div.evaluable + +/** + * Interface for providing functions. + */ +interface FunctionProvider { + + fun get(name: String, args: List): Function + + companion object { + @JvmField + val STUB = object : FunctionProvider { + override fun get(name: String, args: List) = Function.STUB + } + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/VariableProvider.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/VariableProvider.kt new file mode 100644 index 000000000..07a06512a --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/VariableProvider.kt @@ -0,0 +1,11 @@ +package com.yandex.div.evaluable + +/** + * Interface for providing variable values. + */ +fun interface VariableProvider { + /** + * @return variable value or null if it is missing. + */ + fun get(name: String): Any? +} \ No newline at end of file diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/BuiltinFunctionProvider.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/BuiltinFunctionProvider.kt new file mode 100644 index 000000000..09b103026 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/BuiltinFunctionProvider.kt @@ -0,0 +1,124 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.FunctionProvider + +class BuiltinFunctionProvider : FunctionProvider { + + private val registry = FunctionRegistry() + + init { + // Arithmetic functions + registry.register(IntegerSum) + registry.register(DoubleSum) + + registry.register(IntegerSub) + registry.register(DoubleSub) + + registry.register(IntegerMul) + registry.register(DoubleMul) + + registry.register(IntegerDiv) + registry.register(DoubleDiv) + + registry.register(IntegerMod) + registry.register(DoubleMod) + + registry.register(IntegerMaxValue) + registry.register(IntegerMinValue) + + registry.register(DoubleMaxValue) + registry.register(DoubleMinValue) + + registry.register(IntegerMax) + registry.register(DoubleMax) + + registry.register(IntegerMin) + registry.register(DoubleMin) + + registry.register(IntegerAbs) + registry.register(DoubleAbs) + + registry.register(IntegerSignum) + registry.register(DoubleSignum) + + registry.register(IntegerCopySign) + registry.register(DoubleCopySign) + + registry.register(DoubleCeil) + registry.register(DoubleFloor) + registry.register(DoubleRound) + + // Color functions + registry.register(ColorAlphaComponentGetter) + registry.register(ColorStringAlphaComponentGetter) + registry.register(ColorRedComponentGetter) + registry.register(ColorStringRedComponentGetter) + registry.register(ColorGreenComponentGetter) + registry.register(ColorStringGreenComponentGetter) + registry.register(ColorBlueComponentGetter) + registry.register(ColorStringBlueComponentGetter) + + registry.register(ColorAlphaComponentSetter) + registry.register(ColorStringAlphaComponentSetter) + registry.register(ColorRedComponentSetter) + registry.register(ColorStringRedComponentSetter) + registry.register(ColorGreenComponentSetter) + registry.register(ColorStringGreenComponentSetter) + registry.register(ColorBlueComponentSetter) + registry.register(ColorStringBlueComponentSetter) + + registry.register(ColorArgb) + registry.register(ColorRgb) + + // Datetime functions + registry.register(ParseUnixTime) + registry.register(NowLocal) + registry.register(AddMillis) + registry.register(SetYear) + registry.register(SetMonth) + registry.register(SetDay) + registry.register(SetHours) + registry.register(SetMinutes) + registry.register(SetSeconds) + registry.register(SetMillis) + + // String functions + registry.register(StringLength) + registry.register(StringContains) + registry.register(StringSubstring) + registry.register(StringReplaceAll) + registry.register(StringIndex) + registry.register(StringLastIndex) + registry.register(StringEncodeUri) + registry.register(StringDecodeUri) + registry.register(ToLowerCase) + registry.register(ToUpperCase) + registry.register(Trim) + registry.register(TrimLeft) + registry.register(TrimRight) + + // Type cast functions + registry.register(NumberToInteger) + registry.register(BooleanToInteger) + registry.register(StringToInteger) + registry.register(IntegerToNumber) + registry.register(StringToNumber) + registry.register(IntegerToBoolean) + registry.register(StringToBoolean) + registry.register(IntegerToString) + registry.register(NumberToString) + registry.register(BooleanToString) + registry.register(ColorToString) + } + + override fun get(name: String, args: List): Function { + return registry.get(name, args) + } + + internal fun ensureFunctionRegistered(name: String, args: List, resultType: EvaluableType) { + registry.ensureRegistered(name, args, resultType) + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ColorFunctions.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ColorFunctions.kt new file mode 100644 index 000000000..4dae0f094 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ColorFunctions.kt @@ -0,0 +1,282 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.* +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.REASON_CONVERT_TO_COLOR +import com.yandex.div.evaluable.REASON_OUT_OF_RANGE +import com.yandex.div.evaluable.throwExceptionOnFunctionEvaluationFailed +import com.yandex.div.evaluable.types.Color + +internal abstract class ColorComponentGetter( + private val componentGetter: (Color) -> Int +) : Function() { + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.COLOR)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + return componentGetter(args.first() as Color).toColorFloatComponentValue() + } +} + +internal abstract class ColorStringComponentGetter( + private val componentGetter: ColorComponentGetter +) : Function() { + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + val colorString = args.first() as String + val color = try { + Color.parse(colorString) + } catch (e: IllegalArgumentException) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_CONVERT_TO_COLOR, e) + } + return componentGetter.invoke(listOf(color)) + } +} + +internal object ColorAlphaComponentGetter : ColorComponentGetter( + componentGetter = { color: Color -> color.alpha() } +) { + override val name = "getColorAlpha" +} + +internal object ColorStringAlphaComponentGetter : ColorStringComponentGetter( + componentGetter = ColorAlphaComponentGetter +) { + override val name = "getColorAlpha" +} + +internal object ColorRedComponentGetter : ColorComponentGetter( + componentGetter = { color: Color -> color.red() } +) { + override val name = "getColorRed" +} + +internal object ColorStringRedComponentGetter : ColorStringComponentGetter( + componentGetter = ColorRedComponentGetter +) { + override val name = "getColorRed" +} + +internal object ColorGreenComponentGetter : ColorComponentGetter( + componentGetter = { color: Color -> color.green() } +) { + override val name = "getColorGreen" +} + +internal object ColorStringGreenComponentGetter : ColorStringComponentGetter( + componentGetter = ColorGreenComponentGetter +) { + override val name = "getColorGreen" +} + +internal object ColorBlueComponentGetter : ColorComponentGetter( + componentGetter = { color: Color -> color.blue() } +) { + override val name = "getColorBlue" +} + +internal object ColorStringBlueComponentGetter : ColorStringComponentGetter( + componentGetter = ColorBlueComponentGetter +) { + override val name = "getColorBlue" +} + +internal abstract class ColorComponentSetter( + private val componentSetter: (color: Color, value: Double) -> Color +) : Function() { + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.COLOR), + FunctionArgument(type = EvaluableType.NUMBER), // color component value + ) + + override val resultType = EvaluableType.COLOR + + override val isPure = true + + override fun evaluate(args: List): Any { + val color = args[0] as Color + val value = args[1] as Double + return try { + componentSetter(color, value) + } catch (e: IllegalArgumentException) { + throwExceptionOnFunctionEvaluationFailed(name, listOf(color.toString(), value), REASON_OUT_OF_RANGE) + } + } +} + +internal abstract class ColorStringComponentSetter( + private val componentSetter: ColorComponentSetter +) : Function() { + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.STRING), + FunctionArgument(type = EvaluableType.NUMBER), // color component value + ) + + override val resultType = EvaluableType.COLOR + + override val isPure = true + + override fun evaluate(args: List): Any { + val colorString = args[0] as String + val color = try { + Color.parse(colorString) + } catch (e: IllegalArgumentException) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_CONVERT_TO_COLOR, e) + } + return componentSetter(listOf(color, args[1])) + } +} + +internal object ColorAlphaComponentSetter : ColorComponentSetter( + componentSetter = { color: Color, alpha: Double -> + Color.argb( + alpha.toColorIntComponentValue(), + color.red(), + color.green(), + color.blue() + ) + } +) { + override val name = "setColorAlpha" +} + +internal object ColorStringAlphaComponentSetter : ColorStringComponentSetter( + componentSetter = ColorAlphaComponentSetter +) { + override val name = "setColorAlpha" +} + +internal object ColorRedComponentSetter : ColorComponentSetter( + componentSetter = { color: Color, red: Double -> + Color.argb( + color.alpha(), + red.toColorIntComponentValue(), + color.green(), + color.blue() + ) + } +) { + override val name = "setColorRed" +} + +internal object ColorStringRedComponentSetter : ColorStringComponentSetter( + componentSetter = ColorRedComponentSetter +) { + override val name = "setColorRed" +} + +internal object ColorGreenComponentSetter : ColorComponentSetter( + componentSetter = { color: Color, green: Double -> + Color.argb( + color.alpha(), + color.red(), + green.toColorIntComponentValue(), + color.blue() + ) + } +) { + override val name = "setColorGreen" +} + +internal object ColorStringGreenComponentSetter : ColorStringComponentSetter( + componentSetter = ColorGreenComponentSetter +) { + override val name = "setColorGreen" +} + +internal object ColorBlueComponentSetter : ColorComponentSetter( + componentSetter = { color: Color, blue: Double -> + Color.argb( + color.alpha(), + color.red(), + color.green(), + blue.toColorIntComponentValue() + ) + } +) { + override val name = "setColorBlue" +} + +internal object ColorStringBlueComponentSetter : ColorStringComponentSetter( + componentSetter = ColorBlueComponentSetter +) { + override val name = "setColorBlue" +} + +internal object ColorArgb : Function() { + + override val name = "argb" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.NUMBER), // alpha value + FunctionArgument(type = EvaluableType.NUMBER), // red value + FunctionArgument(type = EvaluableType.NUMBER), // green value + FunctionArgument(type = EvaluableType.NUMBER), // blue value + ) + + override val resultType = EvaluableType.COLOR + + override val isPure = true + + override fun evaluate(args: List): Any { + return try { + val alpha = (args[0] as Double).toColorIntComponentValue() + val red = (args[1] as Double).toColorIntComponentValue() + val green = (args[2] as Double).toColorIntComponentValue() + val blue = (args[3] as Double).toColorIntComponentValue() + Color.argb(alpha, red, green, blue) + } catch (e: IllegalArgumentException) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_OUT_OF_RANGE) + } + } +} + +internal object ColorRgb : Function() { + + override val name = "rgb" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.NUMBER), // red value + FunctionArgument(type = EvaluableType.NUMBER), // green value + FunctionArgument(type = EvaluableType.NUMBER), // blue value + ) + + override val resultType = EvaluableType.COLOR + + override val isPure = true + + override fun evaluate(args: List): Any { + return try { + val red = (args[0] as Double).toColorIntComponentValue() + val green = (args[1] as Double).toColorIntComponentValue() + val blue = (args[2] as Double).toColorIntComponentValue() + Color.argb(255, red, green, blue) + } catch (e: IllegalArgumentException) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_OUT_OF_RANGE) + } + } +} + +@Throws(IllegalArgumentException::class) +private fun Int.toColorFloatComponentValue(): Double { + if (this !in 0..255) throw IllegalArgumentException("Value out of channel range 0..255") + return this.toDouble() / 255f +} + +@Throws(IllegalArgumentException::class) +private fun Double.toColorIntComponentValue(): Int { + if (this < 0f || this > 1f) throw IllegalArgumentException() + return (this * 255f + 0.5f).toInt() +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/DateTimeFunctions.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/DateTimeFunctions.kt new file mode 100644 index 000000000..d97aa235a --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/DateTimeFunctions.kt @@ -0,0 +1,280 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableException +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.types.DateTime +import java.util.TimeZone +import java.util.Calendar +import java.util.GregorianCalendar + +internal object ParseUnixTime : Function() { + + override val name = "parseUnixTime" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + override val resultType = EvaluableType.DATETIME + override val isPure = true + + override fun evaluate(args: List): Any { + val first = args.first() + val timestampInSeconds = first as Int + return DateTime( + timestampMillis = timestampInSeconds * 1000L, + timezoneMinutes = 0, + ) + } +} + +internal object NowLocal : Function() { + + override val name = "nowLocal" + + override val declaredArgs = emptyList() + override val resultType = EvaluableType.DATETIME + override val isPure = false + + override fun evaluate(args: List): Any { + return DateTime( + timestampMillis = System.currentTimeMillis(), + timezoneMinutes = TimeZone.getDefault().rawOffset * 60, + ) + } +} + +internal object AddMillis : Function() { + + override val name = "addMillis" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.DATETIME), + FunctionArgument(type = EvaluableType.INTEGER) + ) + override val resultType = EvaluableType.DATETIME + override val isPure = true + + override fun evaluate(args: List): Any { + val datetime = args[0] as DateTime + val millis = args[1] as Int + + return DateTime( + timestampMillis = datetime.timestampMillis + millis, + timezoneMinutes = datetime.timezoneMinutes, + ) + } +} + +internal object SetYear : Function() { + + override val name = "setYear" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.DATETIME), + FunctionArgument(type = EvaluableType.INTEGER) + ) + override val resultType = EvaluableType.DATETIME + override val isPure = true + + override fun evaluate(args: List): Any { + val datetime = args[0] as DateTime + val value = args[1] as Int + + val calendar = datetime.toCalendar() + calendar.set(Calendar.YEAR, value) + + return DateTime( + timestampMillis = calendar.timeInMillis, + timezoneMinutes = datetime.timezoneMinutes, + ) + } +} + +internal object SetMonth : Function() { + + override val name = "setMonth" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.DATETIME), + FunctionArgument(type = EvaluableType.INTEGER) + ) + override val resultType = EvaluableType.DATETIME + override val isPure = true + + @Throws(EvaluableException::class) + override fun evaluate(args: List): Any { + val datetime = args[0] as DateTime + val value = args[1] as Int + + if (value > 12 || value < 1) { + throw EvaluableException("Expecting months in [1..12], instead got $value") + } + val calendar = datetime.toCalendar() + calendar.set(Calendar.MONTH, value - 1) + + return DateTime( + timestampMillis = calendar.timeInMillis, + timezoneMinutes = datetime.timezoneMinutes, + ) + } +} + +internal object SetDay : Function() { + + override val name = "setDay" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.DATETIME), + FunctionArgument(type = EvaluableType.INTEGER) + ) + override val resultType = EvaluableType.DATETIME + override val isPure = true + + @Throws(EvaluableException::class) + override fun evaluate(args: List): Any { + val datetime = args[0] as DateTime + val value = args[1] as Int + + val calendar = datetime.toCalendar() + val daysInMonth: Int = calendar.getActualMaximum(Calendar.DAY_OF_MONTH) + when (value) { + in 1..daysInMonth -> calendar.set(Calendar.DAY_OF_MONTH, value) + -1 -> calendar.set(Calendar.DAY_OF_MONTH, 0) + else -> throw EvaluableException("Unable to set day $value for date $datetime") + } + + return DateTime( + timestampMillis = calendar.timeInMillis, + timezoneMinutes = datetime.timezoneMinutes, + ) + } +} + +internal object SetHours : Function() { + + override val name = "setHours" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.DATETIME), + FunctionArgument(type = EvaluableType.INTEGER) + ) + override val resultType = EvaluableType.DATETIME + override val isPure = true + + @Throws(EvaluableException::class) + override fun evaluate(args: List): Any { + val datetime = args[0] as DateTime + val value = args[1] as Int + + if (value > 23 || value < 0) { + throw EvaluableException("Expecting hour in [0..23], instead got $value") + } + val calendar = datetime.toCalendar() + calendar.timeInMillis = datetime.timestampMillis + calendar.set(Calendar.HOUR_OF_DAY, value) + + return DateTime( + timestampMillis = calendar.timeInMillis, + timezoneMinutes = datetime.timezoneMinutes, + ) + } +} + +internal object SetMinutes : Function() { + + override val name = "setMinutes" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.DATETIME), + FunctionArgument(type = EvaluableType.INTEGER) + ) + override val resultType = EvaluableType.DATETIME + override val isPure = true + + @Throws(EvaluableException::class) + override fun evaluate(args: List): Any { + val datetime = args[0] as DateTime + val value = args[1] as Int + + if (value > 59 || value < 0) { + throw EvaluableException("Expecting minute in [0..59], instead got $value") + } + val calendar = datetime.toCalendar() + calendar.set(Calendar.MINUTE, value) + + return DateTime( + timestampMillis = calendar.timeInMillis, + timezoneMinutes = datetime.timezoneMinutes, + ) + } +} + +internal object SetSeconds : Function() { + + override val name = "setSeconds" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.DATETIME), + FunctionArgument(type = EvaluableType.INTEGER) + ) + override val resultType = EvaluableType.DATETIME + override val isPure = true + + @Throws(EvaluableException::class) + override fun evaluate(args: List): Any { + val datetime = args[0] as DateTime + val value = args[1] as Int + + if (value > 59 || value < 0) { + throw EvaluableException("Expecting second in [0..59], instead got $value") + } + val calendar = datetime.toCalendar() + calendar.set(Calendar.SECOND, value) + + return DateTime( + timestampMillis = calendar.timeInMillis, + timezoneMinutes = datetime.timezoneMinutes, + ) + } +} + +internal object SetMillis : Function() { + + override val name = "setMillis" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.DATETIME), + FunctionArgument(type = EvaluableType.INTEGER) + ) + override val resultType = EvaluableType.DATETIME + override val isPure = true + + @Throws(EvaluableException::class) + override fun evaluate(args: List): Any { + val datetime = args[0] as DateTime + val value = args[1] as Int + + if (value > 999 || value < 0) { + throw EvaluableException("Expecting millis in [0..999], instead got $value") + } + + val calendar = datetime.toCalendar() + calendar.set(Calendar.MILLISECOND, value) + + return DateTime( + timestampMillis = calendar.timeInMillis, + timezoneMinutes = datetime.timezoneMinutes, + ) + } +} + +private fun DateTime.toCalendar(): Calendar { + val timezone = TimeZone.getDefault() + timezone.rawOffset = timezoneMinutes / 60 + + val calendar = GregorianCalendar.getInstance() + calendar.timeZone = timezone + calendar.timeInMillis = timestampMillis + + return calendar +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/FunctionRegistry.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/FunctionRegistry.kt new file mode 100644 index 000000000..0e4bdbdb2 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/FunctionRegistry.kt @@ -0,0 +1,75 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableException +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.FunctionProvider +import com.yandex.div.evaluable.toMessageFormat + +internal class FunctionRegistry : FunctionProvider { + + private val knownFunctions = mutableMapOf>() + + fun register(function: Function) { + val overloadedFunctions = knownFunctions.getOrPut(key = function.name) { mutableListOf() } + if (function !in overloadedFunctions) { + overloadedFunctions += validateFunction(function, overloadedFunctions) + } + } + + private fun validateFunction(function: Function, overloadedFunctions: List): Function { + return FunctionValidator.validateOverloading( + FunctionValidator.validateFunction(function), + overloadedFunctions + ) + } + + override fun get(name: String, args: List): Function { + val overloadedFunctions = knownFunctions.getOrElse(name) { + throw EvaluableException("Unknown function name: $name.") + } + + if (overloadedFunctions.size == 1) { + val function = overloadedFunctions.first() + when (val result = function.matchesArguments(args)) { + is Function.MatchResult.Ok -> return function + + is Function.MatchResult.TooFewArguments -> { + throw EvaluableException("Too few arguments passed to function '$name': expected ${result.expected}, got ${result.actual}.") + } + + is Function.MatchResult.TooManyArguments -> { + throw EvaluableException("Too many arguments passed to function '$name': expected ${result.expected}, got ${result.actual}.") + } + + is Function.MatchResult.ArgTypeMismatch -> { + throw EvaluableException("Call of function '$name' has argument type mismatch: expected ${result.expected}, got ${result.actual}.") + } + } + } + + return overloadedFunctions.find { function -> + function.matchesArguments(args) == Function.MatchResult.Ok + } ?: throw getFunctionArgumentsException(name, args) + } + + private fun getFunctionArgumentsException(name: String, args: List): Exception { + if (args.isEmpty()) { + return EvaluableException("Non empty argument list is required for function '$name'.") + } + return EvaluableException("Function '${name}' has no matching override for given argument types: ${args.toMessageFormat()}.") + } + + fun ensureRegistered(name: String, args: List, resultType: EvaluableType) { + val overloadedFunctions = knownFunctions.getOrElse(name) { + throw EvaluableException("Unknown function name: '$name'.") + } + if (overloadedFunctions.none { it.declaredArgs == args }) { + throw EvaluableException("Function with declared args is not registered.") + } + if (overloadedFunctions.none { it.resultType == resultType }) { + throw EvaluableException("Function with specified result type is not registered.") + } + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/FunctionValidator.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/FunctionValidator.kt new file mode 100644 index 000000000..231f07667 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/FunctionValidator.kt @@ -0,0 +1,65 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableException +import com.yandex.div.evaluable.Function + +internal object FunctionValidator { + + fun validateFunction(function: Function): Function { + val declaredArgs = function.declaredArgs + for (index in 0 until declaredArgs.lastIndex) { + val argument = declaredArgs[index] + if (argument.isVariadic) throw EvaluableException("Variadic argument allowed at the end of list only") + } + return function + } + + fun validateOverloading(nonValidatedFunction: Function, overloadedFunctions: List): Function { + overloadedFunctions.forEach { function -> + if (nonValidatedFunction.conflictsWith(function)) { + throw EvaluableException("Function $function has conflict with $function") + } + } + return nonValidatedFunction + } +} + +private fun Function.conflictsWith(other: Function): Boolean { + if (name != other.name) { + return false + } + + val shorterArgumentList = if (declaredArgs.size < other.declaredArgs.size) declaredArgs else other.declaredArgs + val longerArgumentList = if (shorterArgumentList == declaredArgs) other.declaredArgs else declaredArgs + + + if (shorterArgumentList.isEmpty()) { + if (longerArgumentList.firstOrNull()?.isVariadic == true) { + return true + } + return false + } + + for (index in 0 until shorterArgumentList.lastIndex) { + if (shorterArgumentList[index].type != longerArgumentList[index].type) { + return false + } + } + + if (shorterArgumentList.last().isVariadic) { + val varargType = shorterArgumentList.last().type + for (index in shorterArgumentList.lastIndex until longerArgumentList.size) { + val argument = longerArgumentList[index] + if (argument.type != varargType) { + return false + } + } + return true + } else if (shorterArgumentList.size == longerArgumentList.size) { + return shorterArgumentList.last().type == longerArgumentList.last().type + } else if (longerArgumentList.size == shorterArgumentList.size + 1) { + return !longerArgumentList.last().isVariadic + } else { + return false + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/IntegerArithmeticFunctions.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/IntegerArithmeticFunctions.kt new file mode 100644 index 000000000..66bd5bc7f --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/IntegerArithmeticFunctions.kt @@ -0,0 +1,251 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Evaluator +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.REASON_DIVISION_BY_ZERO +import com.yandex.div.evaluable.REASON_EMPTY_ARGUMENT_LIST +import com.yandex.div.evaluable.REASON_INTEGER_OVERFLOW +import com.yandex.div.evaluable.internal.Token +import com.yandex.div.evaluable.throwExceptionOnFunctionEvaluationFailed +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sign + +internal object IntegerSum : Function() { + + override val name = "sum" + + override val declaredArgs = + listOf(FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + return args.fold(initial = 0) { sum, arg -> + Evaluator.evalSum(Token.Operator.Binary.Sum.Plus, sum, arg) as Int + } + } +} + +internal object IntegerSub : Function() { + + override val name = "sub" + + override val declaredArgs = + listOf(FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + return args.foldIndexed(initial = 0) { index, acc, arg -> + if (index == 0) { + arg + } else { + Evaluator.evalSum(Token.Operator.Binary.Sum.Minus, acc, arg) + } as Int + } + } +} + +internal object IntegerMul : Function() { + + override val name = "mul" + + override val declaredArgs = + listOf(FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + return args.foldIndexed(initial = 0) { index, acc, arg -> + if (index == 0) { + arg + } else { + Evaluator.evalFactor(Token.Operator.Binary.Factor.Multiplication, acc, arg) + } as Int + } + } +} + +internal object IntegerDiv : Function() { + + override val name = "div" + + override val declaredArgs = + listOf(FunctionArgument(EvaluableType.INTEGER), FunctionArgument(EvaluableType.INTEGER)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + val dividend = args.first() as Int + val divisor = args.last() as Int + if (divisor == 0) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_DIVISION_BY_ZERO) + } + return dividend / divisor + } +} + +internal object IntegerMod : Function() { + + override val name = "mod" + + override val declaredArgs = + listOf(FunctionArgument(EvaluableType.INTEGER), FunctionArgument(EvaluableType.INTEGER)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + val dividend = args.first() as Int + val divisor = args.last() as Int + if (divisor == 0) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_DIVISION_BY_ZERO) + } + return dividend % divisor + } +} + +internal object IntegerMaxValue : Function() { + + override val name = "maxInteger" + + override val declaredArgs = emptyList() + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List) = Int.MAX_VALUE +} + +internal object IntegerMinValue : Function() { + + override val name = "minInteger" + + override val declaredArgs = emptyList() + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List) = Int.MIN_VALUE +} + +internal object IntegerMax : Function() { + + override val name = "max" + + override val declaredArgs = + listOf(FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + if (args.isEmpty()) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_EMPTY_ARGUMENT_LIST) + } + return args.fold(initial = Int.MIN_VALUE) { max, arg -> + max(max, arg as Int) + } + } +} + +internal object IntegerMin : Function() { + + override val name = "min" + + override val declaredArgs = + listOf(FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + if (args.isEmpty()) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_EMPTY_ARGUMENT_LIST) + } + return args.fold(initial = Int.MAX_VALUE) { min, arg -> + min(min, arg as Int) + } + } +} + +internal object IntegerAbs : Function() { + + override val name = "abs" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + val value = args.first() as Int + if (value == Int.MIN_VALUE) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_INTEGER_OVERFLOW) + } + return abs(value) + } +} + +internal object IntegerSignum : Function() { + + override val name = "signum" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + return (args.first() as Int).sign + } +} + +internal object IntegerCopySign : Function() { + + override val name = "copySign" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.INTEGER), + FunctionArgument(type = EvaluableType.INTEGER) + ) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + val magnitude = args.first() as Int + val sign = (args.last() as Int).sign + if (sign == 0) { + return magnitude + } + if (magnitude == Int.MIN_VALUE) { + if (sign == -1) { + return magnitude + } else { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_INTEGER_OVERFLOW) + } + } + return abs(magnitude) * sign + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/NumberArithmeticFunctions.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/NumberArithmeticFunctions.kt new file mode 100644 index 000000000..95046a4d7 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/NumberArithmeticFunctions.kt @@ -0,0 +1,285 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Evaluator +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.REASON_DIVISION_BY_ZERO +import com.yandex.div.evaluable.REASON_EMPTY_ARGUMENT_LIST +import com.yandex.div.evaluable.internal.Token +import com.yandex.div.evaluable.throwExceptionOnFunctionEvaluationFailed +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sign +import kotlin.math.withSign + +internal object DoubleSum : Function() { + + override val name = "sum" + + override val declaredArgs = + listOf(FunctionArgument(type = EvaluableType.NUMBER, isVariadic = true)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + return args.fold(initial = 0.0) { sum, arg -> + Evaluator.evalSum(Token.Operator.Binary.Sum.Plus, sum, arg) as Double + } + } +} + +internal object DoubleSub : Function() { + + override val name = "sub" + + override val declaredArgs = + listOf(FunctionArgument(type = EvaluableType.NUMBER, isVariadic = true)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + return args.foldIndexed(initial = 0.0) { index, acc, arg -> + if (index == 0) { + arg + } else { + Evaluator.evalSum(Token.Operator.Binary.Sum.Minus, acc, arg) + } as Double + } + } +} + +internal object DoubleMul : Function() { + + override val name = "mul" + + override val declaredArgs = + listOf(FunctionArgument(type = EvaluableType.NUMBER, isVariadic = true)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + return args.foldIndexed(initial = 0.0) { index, acc, arg -> + if (index == 0) { + arg + } else { + Evaluator.evalFactor(Token.Operator.Binary.Factor.Multiplication, acc, arg) + } as Double + } + } +} + +internal object DoubleDiv : Function() { + + override val name = "div" + + override val declaredArgs = + listOf(FunctionArgument(EvaluableType.NUMBER), FunctionArgument(EvaluableType.NUMBER)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + val dividend = args.first() as Double + val divisor = args.last() as Double + if (divisor == 0.0) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_DIVISION_BY_ZERO) + } + return dividend / divisor + } +} + +internal object DoubleMod : Function() { + + override val name = "mod" + + override val declaredArgs = + listOf(FunctionArgument(EvaluableType.NUMBER), FunctionArgument(EvaluableType.NUMBER)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + val dividend = args.first() as Double + val divisor = args.last() as Double + if (divisor == 0.0) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_DIVISION_BY_ZERO) + } + return dividend % divisor + } +} + +internal object DoubleMaxValue : Function() { + + override val name = "maxNumber" + + override val declaredArgs = emptyList() + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List) = Double.MAX_VALUE +} + +internal object DoubleMinValue : Function() { + + override val name = "minNumber" + + override val declaredArgs = emptyList() + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List) = Double.MIN_VALUE +} + +internal object DoubleMax : Function() { + + override val name = "max" + + override val declaredArgs = + listOf(FunctionArgument(type = EvaluableType.NUMBER, isVariadic = true)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + if (args.isEmpty()) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_EMPTY_ARGUMENT_LIST) + } + return args.fold(initial = args.first()) { max, arg -> + max(max as Double, arg as Double) + } + } +} + +internal object DoubleMin : Function() { + + override val name = "min" + + override val declaredArgs = + listOf(FunctionArgument(type = EvaluableType.NUMBER, isVariadic = true)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + if (args.isEmpty()) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_EMPTY_ARGUMENT_LIST) + } + return args.fold(initial = args.first()) { min, arg -> + min(min as Double, arg as Double) + } + } +} + +internal object DoubleAbs : Function() { + + override val name = "abs" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.NUMBER)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + return abs(args.first() as Double) + } +} + +internal object DoubleRound : Function() { + + override val name = "round" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.NUMBER)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + val value = args.first() as Double + return value.sign * floor(abs(value) + 0.5) + } +} + +internal object DoubleFloor : Function() { + + override val name = "floor" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.NUMBER)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + return floor(args.first() as Double) + } +} + +internal object DoubleCeil : Function() { + + override val name = "ceil" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.NUMBER)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + return ceil(args.first() as Double) + } +} + +internal object DoubleSignum : Function() { + + override val name = "signum" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.NUMBER)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + return (args.first() as Double).sign + } +} + +internal object DoubleCopySign : Function() { + + override val name = "copySign" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.NUMBER), + FunctionArgument(type = EvaluableType.NUMBER) + ) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + val magnitude = args.first() as Double + val sign = args.last() as Double + return magnitude.withSign(sign) + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/StringFunctions.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/StringFunctions.kt new file mode 100644 index 000000000..136bf7fa2 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/StringFunctions.kt @@ -0,0 +1,242 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.REASON_INDEXES_ORDER +import com.yandex.div.evaluable.REASON_OUT_OF_BOUNDS +import com.yandex.div.evaluable.throwExceptionOnFunctionEvaluationFailed +import java.net.URLDecoder +import java.net.URLEncoder + +internal object StringLength : Function() { + + override val name = "len" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + return (args.first() as String).length + } +} + +internal object StringContains : Function() { + + override val name = "contains" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.STRING), // string + FunctionArgument(type = EvaluableType.STRING), // substring + ) + + override val resultType = EvaluableType.BOOLEAN + + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + val substring = args[1] as String + return str.contains(substring, ignoreCase = false) + } +} + +internal object StringSubstring : Function() { + + override val name = "substring" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.STRING), // string + FunctionArgument(type = EvaluableType.INTEGER), // from + FunctionArgument(type = EvaluableType.INTEGER), // to + ) + + override val resultType = EvaluableType.STRING + + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + val startIndex = args[1] as Int + val endIndex = args[2] as Int + if (startIndex < 0 || endIndex > str.length) + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_OUT_OF_BOUNDS) + if (startIndex > endIndex) + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_INDEXES_ORDER) + return str.substring(startIndex, endIndex) + } +} + +internal object StringReplaceAll : Function() { + + override val name = "replaceAll" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.STRING), // string + FunctionArgument(type = EvaluableType.STRING), // old + FunctionArgument(type = EvaluableType.STRING), // new + ) + + override val resultType = EvaluableType.STRING + + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + val old = args[1] as String + val new = args[2] as String + if (old.isEmpty()) return str + return str.replace(old, new, ignoreCase = false) + } +} + +internal object StringIndex : Function() { + + override val name = "index" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.STRING), // string + FunctionArgument(type = EvaluableType.STRING), // substring + ) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + val substring = args[1] as String + return str.indexOf(substring, ignoreCase = false) + } +} + +internal object StringLastIndex : Function() { + + override val name = "lastIndex" + + override val declaredArgs = listOf( + FunctionArgument(type = EvaluableType.STRING), // string + FunctionArgument(type = EvaluableType.STRING), // substring + ) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + val substring = args[1] as String + return str.lastIndexOf(substring, ignoreCase = false) + } +} + +internal object StringEncodeUri : Function() { + + override val name = "encodeUri" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + + override val resultType = EvaluableType.STRING + + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + return URLEncoder.encode(str, Charsets.UTF_8.name()) + .replace("+", "%20") + .replace("%21", "!") + .replace("%7E", "~") + .replace("%27", "'") + .replace("%28", "(") + .replace("%29", ")") + } +} + +internal object StringDecodeUri : Function() { + + override val name = "decodeUri" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + + override val resultType = EvaluableType.STRING + + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + return URLDecoder.decode(str, Charsets.UTF_8.name()) + } +} + +internal object Trim : Function() { + + override val name = "trim" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + override val resultType = EvaluableType.STRING + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + return str.trim() + } +} + +internal object TrimLeft : Function() { + + override val name = "trimLeft" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + override val resultType = EvaluableType.STRING + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + return str.trimStart() + } +} + +internal object TrimRight : Function() { + + override val name = "trimRight" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + override val resultType = EvaluableType.STRING + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + return str.trimEnd() + } +} + +internal object ToUpperCase : Function() { + + override val name = "toUpperCase" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + override val resultType = EvaluableType.STRING + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + return str.toUpperCase() + } +} + +internal object ToLowerCase : Function() { + + override val name = "toLowerCase" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + override val resultType = EvaluableType.STRING + override val isPure = true + + override fun evaluate(args: List): Any { + val str = args[0] as String + return str.toLowerCase() + } +} \ No newline at end of file diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToBoolean.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToBoolean.kt new file mode 100644 index 000000000..5cb25bcb4 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToBoolean.kt @@ -0,0 +1,47 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.REASON_CONVERT_TO_BOOLEAN +import com.yandex.div.evaluable.throwExceptionOnFunctionEvaluationFailed + +private const val FUNCTION_NAME = "toBoolean" + +internal object IntegerToBoolean : Function() { + + override val name = FUNCTION_NAME + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + + override val resultType = EvaluableType.BOOLEAN + + override val isPure = true + + override fun evaluate(args: List): Any { + return when (args.first() as Int) { + 0 -> false + 1 -> true + else -> throwExceptionOnFunctionEvaluationFailed(name, args, REASON_CONVERT_TO_BOOLEAN) + } + } +} + +internal object StringToBoolean : Function() { + + override val name = FUNCTION_NAME + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + + override val resultType = EvaluableType.BOOLEAN + + override val isPure = true + + override fun evaluate(args: List): Any { + return when (args.first() as String) { + "true" -> true + "false" -> false + else -> throwExceptionOnFunctionEvaluationFailed(name, args, REASON_CONVERT_TO_BOOLEAN) + } + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToInteger.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToInteger.kt new file mode 100644 index 000000000..6329f21e4 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToInteger.kt @@ -0,0 +1,66 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.REASON_CONVERT_TO_INTEGER +import com.yandex.div.evaluable.throwExceptionOnFunctionEvaluationFailed + +private const val FUNCTION_NAME = "toInteger" +private const val NUMBER_MIN_INTEGER = Integer.MIN_VALUE.toDouble() +private const val NUMBER_MAX_INTEGER = Integer.MAX_VALUE.toDouble() + +internal object NumberToInteger : Function() { + + override val name = FUNCTION_NAME + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.NUMBER)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + val numberValue = args.first() as Double + if (numberValue < NUMBER_MIN_INTEGER || numberValue > NUMBER_MAX_INTEGER) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_CONVERT_TO_INTEGER) + } + return numberValue.toInt() + } +} + +internal object BooleanToInteger : Function() { + + override val name = "toInteger" + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.BOOLEAN)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + val booleanValue = args.first() as Boolean + return if (booleanValue) 1 else 0 + } +} + +internal object StringToInteger : Function() { + + override val name = FUNCTION_NAME + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + + override val resultType = EvaluableType.INTEGER + + override val isPure = true + + override fun evaluate(args: List): Any { + val stringValue = args.first() as String + return try { + Integer.parseInt(stringValue) + } catch (nfe: NumberFormatException) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_CONVERT_TO_INTEGER, nfe) + } + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToNumber.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToNumber.kt new file mode 100644 index 000000000..ef1665890 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToNumber.kt @@ -0,0 +1,49 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.REASON_CONVERT_TO_NUMBER +import com.yandex.div.evaluable.throwExceptionOnFunctionEvaluationFailed + +private const val FUNCTION_NAME = "toNumber" + +internal object IntegerToNumber : Function() { + + override val name = FUNCTION_NAME + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + val integerValue = args.first() as Int + return integerValue.toDouble() + } +} + +internal object StringToNumber : Function() { + + override val name = FUNCTION_NAME + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.STRING)) + + override val resultType = EvaluableType.NUMBER + + override val isPure = true + + override fun evaluate(args: List): Any { + val stringValue = args.first() as String + try { + val numberValue = java.lang.Double.parseDouble(stringValue) + if (numberValue == Double.POSITIVE_INFINITY || numberValue == Double.NEGATIVE_INFINITY) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_CONVERT_TO_NUMBER) + } + return numberValue + } catch (nfe: NumberFormatException) { + throwExceptionOnFunctionEvaluationFailed(name, args, REASON_CONVERT_TO_NUMBER, nfe) + } + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToString.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToString.kt new file mode 100644 index 000000000..16e680fdf --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/function/ToString.kt @@ -0,0 +1,71 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Function +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.types.Color + +private const val FUNCTION_NAME = "toString" + +internal object IntegerToString : Function() { + + override val name = FUNCTION_NAME + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + + override val resultType = EvaluableType.STRING + + override val isPure = true + + override fun evaluate(args: List): Any { + val integerValue = args.first() as Int + return integerValue.toString() + } +} + +internal object NumberToString : Function() { + + override val name = FUNCTION_NAME + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.NUMBER)) + + override val resultType = EvaluableType.STRING + + override val isPure = true + + override fun evaluate(args: List): Any { + val numberValue = args.first() as Double + return numberValue.toString() + } +} + +internal object BooleanToString : Function() { + + override val name = FUNCTION_NAME + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.BOOLEAN)) + + override val resultType = EvaluableType.STRING + + override val isPure = true + + override fun evaluate(args: List): Any { + val booleanValue = args.first() as Boolean + return if (booleanValue) "true" else "false" + } +} + +internal object ColorToString : Function() { + + override val name = FUNCTION_NAME + + override val declaredArgs = listOf(FunctionArgument(type = EvaluableType.COLOR)) + + override val resultType = EvaluableType.STRING + + override val isPure = true + + override fun evaluate(args: List): Any { + return (args.first() as Color).toString() + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/internal/Parser.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/internal/Parser.kt new file mode 100644 index 000000000..49523e632 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/internal/Parser.kt @@ -0,0 +1,196 @@ +package com.yandex.div.evaluable.internal + +import com.yandex.div.evaluable.Evaluable +import com.yandex.div.evaluable.EvaluableException + +/** + * Converts tokens to expression using this Mantras: + * + * The Mantra of Expression + * expression -> or ( "?" expression ":" expression )? + * or -> and ( "||" and )* + * and -> equal ( "&&" equal )* + * equal -> comparison ( ( "!=" | "==" ) comparison )* + * comparison -> sum ( ( ">" | ">=" | "<" | "<=" ) sum )* + * sum -> factor ( ( "-" | "+" ) factor )* + * factor -> unary ( ( "/" | "*" | "%") unary )* + * unary -> ( "!" | "-" | "+" ) unary | exponent + * exponent -> call ( "^" unary )* + * call -> function | variable | number | boolean + * + * The Mantra of Function + * function -> name "(" ( arguments )* ")" + * arguments -> expression ( ',' expression )* + * + * The Mantra of Variables + * variable -> name + * name -> CHAR ( CHAR | DIGIT | _ | . )* + * + * The Mantra of Literals + * int -> DIGIT ( DIGIT )* + * decimal -> DIGIT ( DIGIT | . )* + * string -> ' (SYMBOL)* ' + * */ +internal object Parser { + + fun parse(tokens: List, rawExpression: String): Evaluable { + if (tokens.isEmpty()) { + throw EvaluableException("Expression expected") + } + + val state = ParsingState(tokens, rawExpression) + val expression = expression(state) + if (state.isNotAtEnd()) { + throw EvaluableException("Expression expected") + } + + return expression + } + + private fun expression(state: ParsingState): Evaluable { + val first = or(state) + if (state.isNotAtEnd() && state.currentToken() is Token.Operator.TernaryIf) { + state.forward() + val second = expression(state) + if (state.currentToken() !is Token.Operator.TernaryElse) { + throw EvaluableException("':' expected in ternary-if-else expression") + } + state.forward() + val third = expression(state) + return Evaluable.Ternary(Token.Operator.TernaryIfElse, first, second, third, state.rawExpr) + } + return first + } + + private fun or(state: ParsingState): Evaluable { + var left = and(state) + while (state.isNotAtEnd() && state.currentToken() is Token.Operator.Binary.Logical.Or) { + state.forward() + val right = and(state) + left = Evaluable.Binary(Token.Operator.Binary.Logical.Or, left, right, state.rawExpr) + } + return left + } + + private fun and(state: ParsingState): Evaluable { + var left = equal(state) + while (state.isNotAtEnd() && state.currentToken() is Token.Operator.Binary.Logical.And) { + state.forward() + val right = equal(state) + left = Evaluable.Binary(Token.Operator.Binary.Logical.And, left, right, state.rawExpr) + } + return left + } + + private fun equal(state: ParsingState): Evaluable { + var left = comparison(state) + while (state.isNotAtEnd() && state.currentToken() is Token.Operator.Binary.Equality) { + val operator = state.next() + val right = comparison(state) + left = Evaluable.Binary(operator as Token.Operator.Binary, left, right, state.rawExpr) + } + return left + } + + private fun comparison(state: ParsingState): Evaluable { + var left = sum(state) + while (state.isNotAtEnd() && state.currentToken() is Token.Operator.Binary.Comparison) { + val operator = state.next() + val right = sum(state) + left = Evaluable.Binary(operator as Token.Operator.Binary, left, right, state.rawExpr) + } + return left + } + + private fun sum(state: ParsingState): Evaluable { + var left = factor(state) + while(state.isNotAtEnd() && state.currentToken() is Token.Operator.Binary.Sum) { + val operator = state.next() + left = Evaluable.Binary(operator as Token.Operator.Binary, left, factor(state), state.rawExpr) + } + return left + } + + private fun factor(state: ParsingState): Evaluable { + var left = unary(state) + while(state.isNotAtEnd() && state.currentToken() is Token.Operator.Binary.Factor) { + val operator = state.next() + left = Evaluable.Binary(operator as Token.Operator.Binary, left, unary(state), state.rawExpr) + } + return left + } + + private fun unary(state: ParsingState): Evaluable { + return if (state.isNotAtEnd() && state.currentToken() is Token.Operator.Unary) { + val token = state.next() + Evaluable.Unary(token as Token.Operator, unary(state), state.rawExpr) + } else { + exponent(state) + } + } + + private fun exponent(state: ParsingState): Evaluable { + var call = call(state) + if (state.isNotAtEnd() && state.currentToken() is Token.Operator.Binary.Power) { + state.forward() + call = Evaluable.Binary(Token.Operator.Binary.Power, call, unary(state), state.rawExpr) + } + return call + } + + private fun call(state: ParsingState): Evaluable { + if (state.isAtEnd()) { + throw EvaluableException("Expression expected") + } + return when (val token = state.next()) { + is Token.Operand.Literal -> Evaluable.Value(token, state.rawExpr) + is Token.Operand.Variable -> Evaluable.Variable(token, state.rawExpr) + is Token.Function -> { + if (state.next() !is Token.Bracket.LeftRound) { + throw EvaluableException("'(' expected after function call") + } + val arguments = mutableListOf() + while (state.currentToken() !is Token.Bracket.RightRound) { + arguments += expression(state) + if (state.currentToken() is Token.Function.ArgumentDelimiter) state.forward() + } + if (state.next() !is Token.Bracket.RightRound) { + throw EvaluableException("expected ')' after a function call") + } + Evaluable.FunctionCall(token, arguments, state.rawExpr) + } + is Token.Bracket.LeftRound -> { + val result = expression(state) + if (state.next() !is Token.Bracket.RightRound) { + throw EvaluableException("')' expected after expression") + } + result + } + is Token.StringTemplate.Start -> { + val arguments = mutableListOf() + while (state.isNotAtEnd() && state.currentToken() !is Token.StringTemplate.End) { + if (state.currentToken() is Token.StringTemplate.StartOfExpression + || state.currentToken() is Token.StringTemplate.EndOfExpression) { + state.forward() + continue + } + arguments += expression(state) + } + if (state.next() !is Token.StringTemplate.End) { + throw EvaluableException("expected ''' at end of a string template") + } + Evaluable.StringTemplate(arguments, state.rawExpr) + } + else -> throw EvaluableException("Expression expected") + } + } + + private data class ParsingState(private val tokens: List, val rawExpr: String) { + var index: Int = 0 + fun currentToken() = tokens[index] + fun next() = tokens[forward()] + fun forward() = index++ + fun isNotAtEnd() = !isAtEnd() + fun isAtEnd() = index >= tokens.size + } +} \ No newline at end of file diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/internal/Token.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/internal/Token.kt new file mode 100644 index 000000000..15f7d01cf --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/internal/Token.kt @@ -0,0 +1,133 @@ +package com.yandex.div.evaluable.internal + +internal sealed interface Token { + sealed interface Operand : Token { + sealed interface Literal : Operand { + @JvmInline + value class Num(val value: Number) : Literal + @JvmInline + value class Bool(val value: Boolean) : Literal + @JvmInline + value class Str(val value: String) : Literal + } + @JvmInline + value class Variable(val name: String) : Operand + } + + sealed interface Operator : Token { + + sealed interface Unary : Operator { + object Plus : Unary { + override fun toString() = "+" + } + + object Minus : Unary { + override fun toString() = "-" + } + + object Not : Unary { + override fun toString() = "!" + } + } + + sealed interface Binary : Operator { + sealed interface Sum : Binary { + object Plus : Sum { + override fun toString() = "+" + } + + object Minus : Sum { + override fun toString() = "-" + } + } + + sealed interface Factor : Binary { + object Multiplication : Factor { + override fun toString() = "*" + } + + object Division : Factor { + override fun toString() = "/" + } + + object Modulo : Factor { + override fun toString() = "%" + } + } + + // Exponent + object Power : Binary { + override fun toString() = "^" + } + + sealed interface Logical : Binary { + object And : Logical { + override fun toString() = "&&" + } + + object Or : Logical { + override fun toString() = "||" + } + } + + sealed interface Comparison : Binary { + object Greater : Comparison { + override fun toString() = ">" + } + + object GreaterOrEqual : Comparison { + override fun toString() = ">=" + } + + object Less : Comparison { + override fun toString() = "<" + } + + object LessOrEqual : Comparison { + override fun toString() = "<=" + } + } + + sealed interface Equality : Binary { + object Equal : Equality { + override fun toString() = "==" + } + + object NotEqual : Equality { + override fun toString() = "!=" + } + } + } + + // Ternary + object TernaryIf : Operator { + override fun toString() = "?" + } + object TernaryElse : Operator { + override fun toString() = ":" + } + object TernaryIfElse : Operator + } + + data class Function(val name: String) : Token { + object ArgumentDelimiter : Token { + override fun toString() = "," + } + } + + object StringTemplate : Operand { + object Start : Token + object End : Token + object StartOfExpression : Token + object EndOfExpression : Token + } + + sealed interface Bracket : Token { + object LeftRound : Bracket { + override fun toString() = "(" + } + object RightRound : Bracket { + override fun toString() = ")" + } + } +} \ No newline at end of file diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/internal/Tokenizer.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/internal/Tokenizer.kt new file mode 100644 index 000000000..71bab14f6 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/internal/Tokenizer.kt @@ -0,0 +1,397 @@ +package com.yandex.div.evaluable.internal + +import com.yandex.div.evaluable.EvaluableException + +internal object Tokenizer { + + private const val EMPTY_CHAR = '\u0000' + + fun tokenize(input: String): List { + val state = TokenizationState(input) + processStringTemplate(state, state.tokens, false) + return state.tokens + } + + private fun processStringTemplate(state: TokenizationState, + tokens: MutableList, + isPartOfExpression: Boolean = true) { + if (isPartOfExpression) { + state.forward() + } + + val stringTemplateTokens = mutableListOf() + val stringLiteral = processString(state, isPartOfExpression) + + if (state.currentChar().isAtEnd()) { + if (isPartOfExpression) { + throw EvaluableException("'\'' expected at end of string literal at ${state.index}") + } + stringLiteral?.let { tokens.add(it) } + return + } + + if (state.currentChar().isAtEndOfStringLiteral(state)) { + tokens.add(stringLiteral ?: Token.Operand.Literal.Str("")) + state.forward() + return + } + + if (stringLiteral != null && state.currentChar().isStartOfExpression(state)) { + stringTemplateTokens.add(Token.StringTemplate.Start) + stringTemplateTokens.add(stringLiteral) + } + + while (state.currentChar().isStartOfExpression(state)) { + val expressionTokens = mutableListOf() + processExpression(state, expressionTokens) + + val stringAfterExpression = processString(state) + + val isSpecialCaseWithExpressionInStringTemplate = !isPartOfExpression + && stringTemplateTokens.isEmpty() + && stringAfterExpression == null + && !state.currentChar().isStartOfExpression(state) + + if (isSpecialCaseWithExpressionInStringTemplate) { + tokens.addAll(expressionTokens) + return + } + + if (stringTemplateTokens.isEmpty()) { + stringTemplateTokens.add(Token.StringTemplate.Start) + } + stringTemplateTokens.add(Token.StringTemplate.StartOfExpression) + stringTemplateTokens.addAll(expressionTokens) + stringTemplateTokens.add(Token.StringTemplate.EndOfExpression) + stringAfterExpression?.let { stringTemplateTokens.add(it) } + } + + if (isPartOfExpression && !state.currentChar().isAtEndOfStringLiteral(state)) { + throw EvaluableException("'\'' expected at end of string literal at ${state.index}") + } + + if (stringTemplateTokens.isNotEmpty()) { + tokens.addAll(stringTemplateTokens) + tokens.add(Token.StringTemplate.End) + } + + if (isPartOfExpression) { + state.forward() + } + } + + private fun processString(state: TokenizationState, isLiteral: Boolean = true): Token.Operand.Literal.Str? { + val start = state.index + + while (!isAtEndOfString(state, isLiteral)) { + state.forward() + } + + val string = state.part(start, state.index) + .replace("\\'", "'") + .replace("\\@{", "@{") + + return if (string.isNotEmpty()) { + Token.Operand.Literal.Str(string) + } else { + null + } + } + + private fun isAtEndOfString(state: TokenizationState, isLiteral: Boolean): Boolean { + return state.currentChar().isAtEnd() + || state.currentChar().isStartOfExpression(state) + || (isLiteral && state.currentChar().isAtEndOfStringLiteral(state)) + } + + private fun processExpression(state: TokenizationState, tokens: MutableList = state.tokens): Boolean { + if (!state.currentChar().isStartOfExpression(state)) { + return false + } + state.forward(2) + while(!state.currentChar().isAtEnd() && state.currentChar() != '}') { + when(state.currentChar()) { + '?' -> { + tokens.add(Token.Operator.TernaryIf) + state.forward() + } + ':' -> { + tokens.add(Token.Operator.TernaryElse) + state.forward() + } + '+' -> { + val token = when { + isUnaryOperator(tokens) -> Token.Operator.Unary.Plus + isOperator(tokens) -> Token.Operator.Binary.Sum.Plus + else -> throw invalidToken(state) + } + tokens.add(token) + state.forward() + } + '-' -> { + val token = when { + isUnaryOperator(tokens) -> Token.Operator.Unary.Minus + isOperator(tokens) -> Token.Operator.Binary.Sum.Minus + else -> throw invalidToken(state) + } + tokens.add(token) + state.forward() + } + '*' -> { + tokens.add(Token.Operator.Binary.Factor.Multiplication) + state.forward() + } + '/' -> { + tokens.add(Token.Operator.Binary.Factor.Division) + state.forward() + } + '%' -> { + tokens.add(Token.Operator.Binary.Factor.Modulo) + state.forward() + } + '!' -> { + when { + state.nextChar() == '=' -> { + tokens.add(Token.Operator.Binary.Equality.NotEqual) + state.forward(2) + } + isUnaryOperator(tokens) -> { + tokens.add(Token.Operator.Unary.Not) + state.forward() + } + else -> throw invalidToken(state) + } + } + '&' -> { + when { + state.nextChar() == '&' -> { + tokens.add(Token.Operator.Binary.Logical.And) + state.forward(2) + } + else -> throw invalidToken(state) + } + } + '|' -> { + when { + state.nextChar() == '|' -> { + tokens.add(Token.Operator.Binary.Logical.Or) + state.forward(2) + } + else -> throw invalidToken(state) + } + } + '<' -> { + when { + state.nextChar() == '=' -> { + tokens.add(Token.Operator.Binary.Comparison.LessOrEqual) + state.forward(2) + } + else -> { + tokens.add(Token.Operator.Binary.Comparison.Less) + state.forward() + } + } + } + '>' -> { + when { + state.nextChar() == '=' -> { + tokens.add(Token.Operator.Binary.Comparison.GreaterOrEqual) + state.forward(2) + } + else -> { + tokens.add(Token.Operator.Binary.Comparison.Greater) + state.forward() + } + } + } + '=' -> { + when { + state.nextChar() == '=' -> { + tokens.add(Token.Operator.Binary.Equality.Equal) + state.forward(2) + } + else -> throw invalidToken(state) + } + } + '(' -> { + tokens.add(Token.Bracket.LeftRound) + state.forward() + } + ')' -> { + tokens.add(Token.Bracket.RightRound) + state.forward() + } + ',' -> { + tokens.add(Token.Function.ArgumentDelimiter) + state.forward() + } + '\'' -> processStringTemplate(state, tokens) + else -> { + when { + state.currentChar().isWhiteSpace() -> { state.forward() } + state.currentChar().isDecimal(state.prevChar(), state.nextChar()) -> processNumber(state, tokens) + state.currentChar().isAlphabetic() -> processIdentifier(state, tokens) + else -> throw invalidToken(state) + } + } + } + } + + if (!state.currentChar().isAtEndOfExpression()) { + throw EvaluableException("'}' expected at end of expression at ${state.index}") + } + state.forward() + + return true + } + + private fun processNumber(state: TokenizationState, tokens: MutableList) { + val start = state.index + val isNegative = tokens.lastOrNull() is Token.Operator.Unary.Minus + if (isNegative) { + tokens.removeLastOrNull() + } + + do { + state.forward() + } while (state.currentChar().isDigit()) + + if (state.charAt(start) == '.' || state.currentChar().isDecimal(state.prevChar(), state.nextChar())) { + while (state.currentChar().isDecimal(state.prevChar(), state.nextChar())) { + state.forward() + } + val valueStr = if (isNegative) { + "-${state.part(start, state.index)}" + } else { + state.part(start, state.index) + } + val value = try { + valueStr.toDouble() + } catch (e: Exception) { + throw EvaluableException("Value $valueStr can't be converted to Number type.") + } + tokens.add(Token.Operand.Literal.Num(value)) + } else { + val valueStr = if (isNegative) { + "-${state.part(start, state.index)}" + } else { + state.part(start, state.index) + } + val value = try { + valueStr.toInt() + } catch (e: Exception) { + throw EvaluableException("Value $valueStr can't be converted to Integer type.") + } + tokens.add(Token.Operand.Literal.Num(value)) + } + } + + private fun processIdentifier(state: TokenizationState, tokens: MutableList) { + val start = state.index + + while (state.currentChar().isValidIdentifier()) state.forward() + val name = state.part(start, state.index) + when { + processKeyword(name, tokens) -> { + return + } + + state.currentChar() == '(' -> { + if (name.contains('.')) { + throw EvaluableException("Invalid function name '$name'") + } + tokens.add(Token.Function(name)) + } + + else -> { + tokens.add(Token.Operand.Variable(name)) + } + } + } + + private fun processKeyword(identifier: String, tokens: MutableList): Boolean { + val token = when (identifier) { + "true" -> Token.Operand.Literal.Bool(true) + "false" -> Token.Operand.Literal.Bool(false) + else -> null + } + + return if (token == null) { + false + } else { + tokens.add(token) + true + } + } + + private fun invalidToken(state: TokenizationState) = + EvaluableException("Invalid token '${state.currentChar()}' at position ${state.index}") + + private fun isOperator(tokens: List): Boolean { + if (tokens.isEmpty() || tokens.last() is Token.Operator.Unary) { + return false + } + return tokens.last() is Token.Operand || tokens.last() is Token.Bracket.RightRound + } + + private fun isUnaryOperator(tokens: List): Boolean { + return !isOperator(tokens) && tokens.lastOrNull() !is Token.Operator.Unary + } + + private data class TokenizationState(private val source: String) { + var index: Int = 0 + val tokens = mutableListOf() + + fun prevChar(step: Int = 1) = if (index - step >= 0) { + source[index - step] + } else { + EMPTY_CHAR + } + + fun currentChar() = if (index >= source.length) { + EMPTY_CHAR + } else { + source[index] + } + + fun charAt(position: Int) = if (position in source.indices) { + source[position] + } else { + EMPTY_CHAR + } + + fun part(from: Int, to: Int) = source.substring(from, to) + + fun nextChar(step: Int = 1) = if (index + step >= source.length) { + EMPTY_CHAR + } else { + source[index + step] + } + + fun forward(count: Int = 1): Int { + val value = index + index += count + return value + } + } + + private fun Char.isAlphabetic() = this in 'a'..'z' || this in 'A'..'Z' || this == '_' + private fun Char.isNumber() = this in '0'..'9' + private fun Char.isDecimal( + previousChar: Char = EMPTY_CHAR, + nextChar: Char = EMPTY_CHAR + ) = this.isDigit() || when (this) { + '.' -> nextChar.isDigit() + 'e', 'E' -> previousChar.isDigit() && (nextChar.isDigit() || nextChar == '+' || nextChar == '-') + '+', '-' -> (previousChar == 'e' || previousChar == 'E') && nextChar.isDigit() + else -> false + } + private fun Char.isWhiteSpace() = this == ' ' || this == '\t' || this == '\r' || this == '\n' + private fun Char.isValidIdentifier() = this.isAlphabetic() || this.isNumber() || this == '.' + private fun Char.isAtEndOfStringLiteral(state: TokenizationState) = + this == '\'' && state.prevChar() != '\\' + private fun Char.isStartOfExpression(state: TokenizationState) = + this == '@' && state.prevChar() != '\\' && state.nextChar() == '{' + private fun Char.isAtEndOfExpression() = this == '}' + private fun Char.isAtEnd() = this == EMPTY_CHAR +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/types/Color.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/types/Color.kt new file mode 100644 index 000000000..46b23a63d --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/types/Color.kt @@ -0,0 +1,114 @@ +package com.yandex.div.evaluable.types + +import kotlin.jvm.Throws + +@JvmInline +value class Color(val value: Int) { + + /** + * Copied from android.graphics.Color. + * Return the alpha component of a color int. This is the same as saying + * color >>> 24 + */ + fun alpha() = value ushr 24 + + /** + * Copied from android.graphics.Color. + * Return the red component of a color int. This is the same as saying + * (color >> 16) & 0xFF + */ + fun red()= value shr 16 and 0xFF + + /** + * Copied from android.graphics.Color. + * Return the green component of a color int. This is the same as saying + * (color >> 8) & 0xFF + */ + fun green() = value shr 8 and 0xFF + + /** + * Copied from android.graphics.Color. + * Return the blue component of a color int. This is the same as saying + * color & 0xFF + */ + fun blue() = value and 0xFF + + + + override fun toString(): String { + return "#" + Integer.toHexString(value).padStart(8, '0').uppercase() + } + + companion object { + /** + * Copied from android.graphics.Color. + * Return a color-int from alpha, red, green, blue components. + * These component values should be ([0..255]), but there is no + * range check performed, so if they are out of range, the + * returned color is undefined. + * @param alpha Alpha component ([0..255]) of the color + * @param red Red component ([0..255]) of the color + * @param green Green component ([0..255]) of the color + * @param blue Blue component ([0..255]) of the color + */ + fun argb(alpha: Int, red: Int, green: Int, blue: Int) = + Color(alpha shl 24 or (red shl 16) or (green shl 8) or blue) + + /** + * Copied from android.graphics.Color. + * Return a color-int from alpha, red, green, blue components. + * These component values should be ([0..255]), but there is no + * range check performed, so if they are out of range, the + * returned color is undefined. + * @param red Red component ([0..255]) of the color + * @param green Green component ([0..255]) of the color + * @param blue Blue component ([0..255]) of the color + */ + fun rgb(red: Int, green: Int, blue: Int) = argb(0xff, red, green, blue) + + /** + * Parse the color string, and return the corresponding color-int. + * If the string cannot be parsed, throws an IllegalArgumentException + * exception. Supported formats are: + * + * + * * `#RGB` + * * `#ARGB` + * * `#RRGGBB` + * * `#AARRGGBB` + */ + @Throws( + IllegalArgumentException::class, + NumberFormatException::class + ) + fun parse(colorString: String): Color { + require(colorString.isNotEmpty()) { "Expected color string, actual string is empty" } + require(colorString[0] == '#') { "Unknown color $colorString" } + val normalizedColorString = when (colorString.length) { + 4 -> { + val r: Char = colorString[1] + val g: Char = colorString[2] + val b: Char = colorString[3] + String(charArrayOf('f', 'f', r, r, g, g, b, b)) + } + 5 -> { + val a: Char = colorString[1] + val r: Char = colorString[2] + val g: Char = colorString[3] + val b: Char = colorString[4] + String(charArrayOf(a, a, r, r, g, g, b, b)) + } + 7 -> { + "ff" + colorString.substring(1) + } + 9 -> { + colorString.substring(1) + } + else -> { + throw IllegalArgumentException("Unknown color $colorString") + } + } + return Color(normalizedColorString.toLong(16).toInt()) + } + } +} diff --git a/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/types/DateTime.kt b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/types/DateTime.kt new file mode 100644 index 000000000..0669c0376 --- /dev/null +++ b/client/android/div-evaluable/src/main/java/com/yandex/div/evaluable/types/DateTime.kt @@ -0,0 +1,68 @@ +package com.yandex.div.evaluable.types + +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import java.util.Locale +import java.util.SimpleTimeZone + +private const val TO_MILLIS = 60 * 1000 + +internal class DateTime( + internal val timestampMillis: Long, + internal val timezoneMinutes: Int, +) : Comparable { + private val calendar by lazy(LazyThreadSafetyMode.NONE) { + Calendar.getInstance(utcTimezone).apply { timeInMillis = timestampMillis } + } + private val timestampUtc = timestampMillis - (timezoneMinutes * TO_MILLIS) + + override fun toString(): String { + return formatDate(calendar) + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + if (other !is DateTime) { + return false + } + + return timestampUtc == other.timestampUtc + } + + override fun hashCode(): Int { + return timestampUtc.hashCode() + } + + companion object { + private const val DEFAULT_FORMAT_PATTERN = "yyyy-MM-dd hh:mm:ss" + private val utcTimezone = SimpleTimeZone(0, "UTC") + + internal fun parseAsUTC(source: String): DateTime { + val dateFormat = SimpleDateFormat(DEFAULT_FORMAT_PATTERN, Locale.getDefault()) + val date: Date = dateFormat.parse(source)!! + return DateTime( + timestampMillis = date.time + Calendar.getInstance().timeZone.rawOffset, + timezoneMinutes = 0, + ) + } + + internal fun formatDate(c: Calendar): String { + val yyyy = c.get(Calendar.YEAR).toString() + val MM = (c.get(Calendar.MONTH) + 1).toString().padStart(2, '0') + val DD = c.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0') + val hh = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val mm = c.get(Calendar.MINUTE).toString().padStart(2, '0') + val ss = c.get(Calendar.SECOND).toString().padStart(2, '0') + return "$yyyy-$MM-$DD $hh:$mm:$ss" + } + } + + override fun compareTo(other: DateTime): Int { + return timestampUtc.compareTo(other.timestampUtc) + } +} diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/EvaluableTest.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/EvaluableTest.kt new file mode 100644 index 000000000..994a7442c --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/EvaluableTest.kt @@ -0,0 +1,646 @@ +package com.yandex.div.evaluable + +import junit.framework.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +private typealias VariableName = String +private typealias NumOfInvokes = Int + +class EvaluableTest { + + private val variableProvider = mock() + private val functionProvider = mock() + private val evaluator = Evaluator(variableProvider, functionProvider) + + // Ternary Operator Test + @Test + fun `ternary # evaluate first expression without second`() { + setVariable(VAR_A, 1) + setVariable(VAR_B, 2.0) + assertTernaryExpression("true ? $VAR_A : $VAR_B", 1, VAR_A to 1, VAR_B to 0) + } + + @Test + fun `ternary # evaluate second expression without first`() { + setVariable(VAR_A, 1) + setVariable(VAR_B, 2.0) + assertTernaryExpression("false ? $VAR_A : $VAR_B", 2.0, VAR_A to 0, VAR_B to 1) + } + + // Constant String Test + @Test + fun `constant # plain string without expression`() { + assertStringTemplate("This is constant string", "This is constant string") + } + + @Test + fun `constant # plain string with escaped expression`() { + assertStringTemplate("This is @{constant} string", "This is \\@{constant} string") + } + + @Test + fun `constant # plain string with single quoted part`() { + assertStringTemplate("This is 'constant' string", "This is 'constant' string") + } + + // StringTemplate Test + @Test + fun `string template # with empty expression`() { + assertStringTemplate("This is constant string", "This is constant@{} string") + } + + @Test + fun `string template # with string expression`() { + assertStringTemplate("This is constant string", "This is @{'constant'} string") + } + + @Test + fun `string template # with String variable`() { + setVariable(TEST_VARIABLE_1, "constant") + assertStringTemplate("This is constant string", "This is @{$TEST_VARIABLE_1} string") + } + + @Test + fun `string template # with Int expression`() { + assertStringTemplate("This is 5", "This is @{5}") + } + + @Test + fun `string template # with Int variable`() { + setVariable(TEST_VARIABLE_1, 5) + assertStringTemplate("This is 5", "This is @{$TEST_VARIABLE_1}") + } + + @Test + fun `string template # with Decimal expression`() { + assertStringTemplate("This is 15.0", "This is @{15.0}") + } + + @Test + fun `string template # with Decimal variable`() { + setVariable(TEST_VARIABLE_1, 5.0) + assertStringTemplate("This is 5.0", "This is @{$TEST_VARIABLE_1}") + } + + @Test + fun `string template # with Boolean variable`() { + setVariable(TEST_VARIABLE_1, true) + assertStringTemplate("This is true", "This is @{$TEST_VARIABLE_1}") + setVariable(TEST_VARIABLE_1, false) + assertStringTemplate("This is false", "This is @{$TEST_VARIABLE_1}") + } + + @Test + fun `string template # with several expressions`() { + setVariable(TEST_VARIABLE_1, true) + setVariable(TEST_VARIABLE_2, 1) + assertStringTemplate("This is true and 1", "This is @{$TEST_VARIABLE_1} and @{$TEST_VARIABLE_2}") + assertStringTemplate("This is start and this is end", "@{'This is start'} and @{'this is end'}") + + } + + @Test + fun `string template # with expressions in different positions`() { + assertStringTemplate("This string template has expression only on start", "@{'This string template'} has expression only on start") + assertStringTemplate("This string template has expression in the middle", "This string template @{'has expression'} in the middle") + assertStringTemplate("And this string template has expression only on end", "And this string template has expression @{'only on end'}") + } + + // Literals Test + @Test + fun `literals # positive Int`() { + assertIntExpression(5, "5") + assertIntExpression(15, "15") + assertIntExpression(155, "155") + } + + @Test + fun `literals # negative Int`() { + assertIntExpression(-9, "-9") + assertIntExpression(-95, "-95") + assertIntExpression(-958, "-958") + } + + @Test + fun `literals # simple positive Decimal`() { + assertDecimalExpression(5.0, "5.0000") + assertDecimalExpression(15.0, "15.0") + assertDecimalExpression(155.0, "155.0000") + } + + @Test + fun `literals # simple negative Decimal`() { + assertDecimalExpression(-9.0, "-9.000") + assertDecimalExpression(-95.0, "-95.0") + assertDecimalExpression(-958.0, "-958.0000000") + } + + @Test + fun `literals # positive Decimal without leading zero`() { + assertDecimalExpression(.5, ".5") + assertDecimalExpression(.12, ".120") + assertDecimalExpression(.1239, ".1239") + } + + @Test + fun `literals # negative Decimal without leading zero`() { + assertDecimalExpression(-.9, "-.9") + assertDecimalExpression(-.95, "-.950") + assertDecimalExpression(-.958, "-.958000") + } + + @Test + fun `literals # positive scientific Decimal`() { + assertDecimalExpression(12e3, "12e3") + assertDecimalExpression(0.123e+4, "0.123e+4") + assertDecimalExpression(0.123E+4, "0.123E+4") + assertDecimalExpression(0.123e-4, "0.123e-4") + assertDecimalExpression(0.123E-4, "0.123E-4") + } + + @Test + fun `literals # negative scientific Decimal`() { + assertDecimalExpression(-12e3, "-12e3") + assertDecimalExpression(-0.123e+4, "-0.123e+4") + assertDecimalExpression(-0.123E+4, "-0.123E+4") + assertDecimalExpression(-0.123e-4, "-0.123e-4") + assertDecimalExpression(-0.123E-4, "-0.123E-4") + } + + @Test + fun `literals # positive scientific Decimal without leading zero`() { + assertDecimalExpression(.123e+4, ".123e+4") + assertDecimalExpression(.123E+4, ".123E+4") + assertDecimalExpression(.123e-4, ".123e-4") + assertDecimalExpression(.123E-4, ".123E-4") + } + + @Test + fun `literals # negative scientific Decimal without leading zero`() { + assertDecimalExpression(-.123e+4, "-.123e+4") + assertDecimalExpression(-.123E+4, "-.123E+4") + assertDecimalExpression(-.123e-4, "-.123e-4") + assertDecimalExpression(-.123E-4, "-.123E-4") + } + + @Test + fun `literals # String is String`() { + assertStringExpression("a", "'a'") + assertStringExpression("asdf87987_/asdsdf", "'asdf87987_/asdsdf'") + assertStringExpression("as8h)(&(G(*YHiud", "'as8h)(&(G(*YHiud'") + } + + @Test + fun `literals # escaping quote in String`() { + assertStringExpression("sdfh ao 89&(*'9sd ", "'sdfh ao 89&(*\\'9sd ' ") + } + + @Test + fun `literals # String escaping`() { + assertStringExpression("'string'", "'\\'string\\''") + } + + @Test + fun `literals # booleans`() { + assertBooleanExpression(true, "true") + assertBooleanExpression(false, "false") + } + + // Variables Test + @Test + fun `variables # Int variable is Int`() { + setVariable(TEST_VARIABLE_1, 1) + assertIntExpression(1, TEST_VARIABLE_1) + } + + @Test + fun `variables # Decimal variable is Decimal`() { + setVariable(TEST_VARIABLE_1, 1.0) + assertDecimalExpression(1.0, TEST_VARIABLE_1) + } + + @Test + fun `variables # String variable is String`() { + setVariable(TEST_VARIABLE_1, TEST_VARIABLE_2) + assertStringExpression(TEST_VARIABLE_2, TEST_VARIABLE_1) + } + + @Test + fun `variables # String variable is Boolean`() { + setVariable(TEST_VARIABLE_1, true) + assertBooleanExpression(true, TEST_VARIABLE_1) + } + + + // Int equality tests + @Test + fun `equality # Int literal equals the same Int literal`() { + assertEqualityExpression(true, "5", "5") + assertEqualityExpression(true, "15", "15") + assertEqualityExpression(true, "155", "155") + assertEqualityExpression(true, "-958", "-958") + } + + @Test + fun `equality # different Int literals not equals`() { + assertEqualityExpression(false, "5", "7") + assertEqualityExpression(false, "15", "27") + assertEqualityExpression(false, "155", "157") + assertEqualityExpression(false, "-955", "958") + } + + @Test + fun `equality # Int literal equals the same Int variable`() { + setVariable(TEST_VARIABLE_1, 5) + assertEqualityExpression(true, "5", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, 15) + assertEqualityExpression(true, "15", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, 155) + assertEqualityExpression(true, "155", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, -958) + assertEqualityExpression(true, "-958", TEST_VARIABLE_1) + } + + @Test + fun `equality # different Int literal and variable not equals`() { + setVariable(TEST_VARIABLE_1, 7) + assertEqualityExpression(false, "5", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, 25) + assertEqualityExpression(false, "15", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, 15) + assertEqualityExpression(false, "155", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, 958) + assertEqualityExpression(false, "-958", TEST_VARIABLE_1) + } + + + // Decimal equality tests + @Test + fun `equality # Decimal literal equals the same Decimal literal`() { + assertEqualityExpression(true, "5.0000", "5.0000") + assertEqualityExpression(true, "15.0", "15.0") + assertEqualityExpression(true, "155.000", "155.000") + assertEqualityExpression(true, "-958.0000000", "-958.0000000") + } + + @Test + fun `equality # different Decimal literals not equals`() { + assertEqualityExpression(false, "5.0000", "7.0000") + assertEqualityExpression(false, "15.0", "-15.0") + assertEqualityExpression(false, "155.0000", "5.0000") + assertEqualityExpression(false, "-958.0000000", "-8.0000000") + } + + @Test + fun `equality # Decimal literal equals the same Decimal variable`() { + setVariable(TEST_VARIABLE_1, 5.0) + assertEqualityExpression(true, "5.0000", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, 15.0) + assertEqualityExpression(true, "15.0", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, 155.0) + assertEqualityExpression(true, "155.0000", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, -958.0) + assertEqualityExpression(true, "-958.0000000", TEST_VARIABLE_1) + } + + @Test + fun `equality # different Decimal literal and variable not equals`() { + setVariable(TEST_VARIABLE_1, 7.0) + assertEqualityExpression(false, "5.0000", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, -15.0) + assertEqualityExpression(false, "15.0", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, 5.0) + assertEqualityExpression(false, "155.0000", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, -8.0) + assertEqualityExpression(false, "-958.0000000", TEST_VARIABLE_1) + } + + // String equality tests + @Test + fun `equality # String literal equals the same String literal`() { + assertEqualityExpression(true, "'a'", "'a'") + assertEqualityExpression(true, "'asdf87987_/asdsdf'", "'asdf87987_/asdsdf'") + assertEqualityExpression(true, "'as8h)(&(G(*YHiud'", "'as8h)(&(G(*YHiud'") + assertEqualityExpression(true, "'sdfh ao 89&(*&9sd '", "'sdfh ao 89&(*&9sd '") + } + + @Test + fun `equality # different String literals not equals`() { + assertEqualityExpression(false, "'a'", "'sa'") + assertEqualityExpression(false, "'asdf8798dsdf'", "'asdf87987_/asdsdf'") + assertEqualityExpression(false, "'as8h)(&(G(*YHiud'", "'as8hG(*YHiud'") + assertEqualityExpression(false, "'sdfh ao89&(*&9sd '", "'sdfh ao 8*&9sd '") + } + + @Test + fun `equality # String literal equals the same String variable`() { + setVariable(TEST_VARIABLE_1, "a") + assertEqualityExpression(true, "'a'", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, "asdf87987_/asdsdf") + assertEqualityExpression(true, "'asdf87987_/asdsdf'", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, "as8h)(&(G(*YHiud") + assertEqualityExpression(true, "'as8h)(&(G(*YHiud'", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, "sdfh ao 89&(*&9sd ") + assertEqualityExpression(true, "'sdfh ao 89&(*&9sd '", TEST_VARIABLE_1) + } + + @Test + fun `equality # different String literal and variable not equals`() { + setVariable(TEST_VARIABLE_1, "sa") + assertEqualityExpression(false, "'a'", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, "asdf8798dsdf") + assertEqualityExpression(false, "'asdf87987_/asdsdf'", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, "as8hG(*YHiud") + assertEqualityExpression(false, "'as8h)(&(G(*YHiud'", TEST_VARIABLE_1) + + setVariable(TEST_VARIABLE_1, "sdfh ao89&(*&9sd") + assertEqualityExpression(false, "'sdfh ao 89&(*&9sd '", TEST_VARIABLE_1) + } + + // Int comparison tests + @Test + fun `comparison # Int literal comparison test`() { + assertComparisonExpression(false, false, "1", "0") + assertComparisonExpression(true, false, "1", "10") + assertComparisonExpression(false, true, "10", "10") + } + + @Test + fun `comparison # Int literal and variable comparison test`() { + setVariable(TEST_VARIABLE_1, 1) + assertComparisonExpression(false, false, TEST_VARIABLE_1, "0") + assertComparisonExpression(false, true, "1", TEST_VARIABLE_1) + assertComparisonExpression(true, false, TEST_VARIABLE_1, "10") + } + + // Decimal comparison tests + @Test + fun `comparison # Decimal literal comparison test`() { + assertComparisonExpression(false, false, "1.0", "-5.00") + assertComparisonExpression(false, true, "10.000", "10.0") + assertComparisonExpression(true, false, "-10.000", "0.0") + } + + @Test + fun `comparison # Decimal literal and variable comparison test`() { + setVariable(TEST_VARIABLE_1, 1.0) + assertComparisonExpression(false, false, TEST_VARIABLE_1, "0.0") + assertComparisonExpression(false, true, TEST_VARIABLE_1, "1.0") + assertComparisonExpression(true, false, "-157.0", TEST_VARIABLE_1) + } + + // Logical + @Test + fun `logical # true variable is true`() { + setVariable(TEST_VARIABLE_1, true) + assertBooleanExpression(true, TEST_VARIABLE_1) + } + + @Test + fun `logical # false variable is false`() { + setVariable(TEST_VARIABLE_1, false) + assertBooleanExpression(false, TEST_VARIABLE_1) + } + + @Test + fun `logical # true variable AND true variable is true`() { + setVariable(TEST_VARIABLE_1, true) + setVariable(TEST_VARIABLE_2, true) + assertBooleanExpression(true, "$TEST_VARIABLE_1 && $TEST_VARIABLE_2") + } + + @Test + fun `logical # true variable AND false variable is false`() { + setVariable(TEST_VARIABLE_1, true) + setVariable(TEST_VARIABLE_2, false) + assertBooleanExpression(false, "$TEST_VARIABLE_1 && $TEST_VARIABLE_2") + } + + @Test + fun `logical # false variable AND true variable is false`() { + setVariable(TEST_VARIABLE_1, false) + setVariable(TEST_VARIABLE_2, true) + assertBooleanExpression(false, "$TEST_VARIABLE_1 && $TEST_VARIABLE_2") + } + + @Test + fun `logical # false variable AND false variable is false`() { + setVariable(TEST_VARIABLE_1, false) + setVariable(TEST_VARIABLE_2, false) + assertBooleanExpression(false, "$TEST_VARIABLE_1 && $TEST_VARIABLE_2") + } + + @Test + fun `logical # true variable OR true variable is true`() { + setVariable(TEST_VARIABLE_1, true) + setVariable(TEST_VARIABLE_2, true) + assertBooleanExpression(true, "$TEST_VARIABLE_1 || $TEST_VARIABLE_2") + } + + @Test + fun `logical # true variable OR false variable is true`() { + setVariable(TEST_VARIABLE_1, true) + setVariable(TEST_VARIABLE_2, false) + assertBooleanExpression(true, "$TEST_VARIABLE_1 || $TEST_VARIABLE_2") + } + + @Test + fun `logical # false variable OR true variable is true`() { + setVariable(TEST_VARIABLE_1, false) + setVariable(TEST_VARIABLE_2, true) + assertBooleanExpression(true, "$TEST_VARIABLE_1 || $TEST_VARIABLE_2") + } + + @Test + fun `logical # false variable OR false variable is false`() { + setVariable(TEST_VARIABLE_1, false) + setVariable(TEST_VARIABLE_2, false) + assertBooleanExpression(false, "$TEST_VARIABLE_1 || $TEST_VARIABLE_2") + } + + @Test + fun `logical # logical operations with int`() { + setVariable(TEST_VARIABLE_1, true) + assertLogicalExpression(true, TEST_VARIABLE_1, false, "false") + assertLogicalExpression(true, TEST_VARIABLE_1, true, "true") + + setVariable(TEST_VARIABLE_1, false) + assertLogicalExpression(false, TEST_VARIABLE_1, false, "false") + assertLogicalExpression(false, TEST_VARIABLE_1, true, "true") + } + + // Stress tests + @Test(expected = EvaluableException::class) + fun `errors # invalid Int literal`() { + assertIntExpression(Int.MAX_VALUE, "9999999999999999") + } + + @Test(expected = EvaluableException::class) + fun `errors # invalid underscore in Int literal`() { + assertIntExpression(99_999, "99_999") + assertIntExpression(-99_999, "-99_999") + } + + @Test(expected = EvaluableException::class) + fun `errors # invalid negative Int literal`() { + assertIntExpression(Int.MIN_VALUE, "-9999999999999999") + } + + @Test(expected = EvaluableException::class) + fun `errors # invalid Decimal literal`() { + assertDecimalExpression(123.0e5, "123.e5") + } + + @Test(expected = EvaluableException::class) + fun `errors # invalid underscore in Decimal literal`() { + assertDecimalExpression(99_999.0, "99_999.0") + assertDecimalExpression(99_999.000_000, "99_999.000_000") + assertDecimalExpression(-99_999.0, "-99_999.0") + assertDecimalExpression(-99_999.000_000, "-99_999.000_000") + } + + @Test(expected = EvaluableException::class) + fun `errors # invalid couple unary plus usage`() { + assertIntExpression(1, "++1") + assertIntExpression(1, "+ +1") + } + + @Test(expected = EvaluableException::class) + fun `errors # invalid couple unary minus usage`() { + assertIntExpression(1, "--1") + assertIntExpression(1, "- -1") + } + + @Test(expected = EvaluableException::class) + fun `errors # invalid couple unary not usage`() { + setVariable(TEST_VARIABLE_1, true) + assertBooleanExpression(true, "!!$TEST_VARIABLE_1") + assertBooleanExpression(true, "! !$TEST_VARIABLE_1") + } + + @Test(expected = EvaluableException::class) + fun `errors # logical operation with not valid int`() { + setVariable(TEST_VARIABLE_1, true) + assertBooleanExpression(true, "$TEST_VARIABLE_1 && 2") + } + + @Test(expected = EvaluableException::class) + fun `errors # invalid Str literal without '`() { + assertStringExpression("This is not a string", "This is not a string") + } + + @Test(expected = EvaluableException::class) + fun `errors # invalid Str literal without one of '`() { + assertStringExpression("This is not a string", "'This is not a string") + assertStringExpression("This is not a string", "This is not a string'") + } + + private fun assertLogicalExpression(leftExpected: Boolean, left: String, rightExpected: Boolean, right: String) { + assertBooleanExpression(leftExpected && rightExpected, left, "&&", right) + assertBooleanExpression(leftExpected && rightExpected, right, "&&", left) + assertBooleanExpression(leftExpected || rightExpected, left, "||", right) + assertBooleanExpression(leftExpected || rightExpected, right, "||", left) + } + + private fun assertEqualityExpression(expected: Boolean, left: String, right: String) { + assertBooleanExpression(expected, left, "==", right) + assertBooleanExpression(expected, right, "==", left) + assertBooleanExpression(!expected, left, "!=", right) + assertBooleanExpression(!expected, right, "!=", left) + } + + private fun assertComparisonExpression(leftLess: Boolean, equal: Boolean, left: String, right: String) { + assertBooleanExpression(leftLess && !equal, left, "<", right) + assertBooleanExpression(leftLess || equal, left, "<=", right) + assertBooleanExpression(!leftLess && !equal, left, ">", right) + assertBooleanExpression(!leftLess || equal, left, ">=", right) + } + + private fun assertBooleanExpression(expected: Boolean, left: String, operator:String, right: String) { + assertBooleanExpression(expected, "$left $operator $right") + } + + private fun assertBooleanExpression(expected: Boolean, expr: String) { + assertBooleanTemplate(expected, "@{$expr}") + } + + private fun assertBooleanTemplate(expected: Boolean, template: String) { + val actual: Any = evaluator.eval(Evaluable.prepare(template)) + assert(actual is Boolean) + assertEquals(expected, actual as Boolean) + } + + private fun assertIntExpression(expected: Int, expr: String) { + assertIntTemplate(expected, "@{$expr}") + } + + private fun assertIntTemplate(expected: Int, template: String) { + val actual: Any = evaluator.eval(Evaluable.prepare(template)) + assert(actual is Int) + assertEquals(expected, actual as Int) + } + + private fun assertDecimalExpression(expected: Double, expr: String) { + assertDecimalTemplate(expected, "@{$expr}") + } + + private fun assertDecimalTemplate(expected: Double, template: String) { + val actual: Any = evaluator.eval(Evaluable.prepare(template)) + assert(actual is Double) + assertEquals(expected, actual as Double) + } + + private fun assertStringExpression(expected: String, expr: String) { + assertStringTemplate(expected, "@{$expr}") + } + + private fun assertStringTemplate(expected: String, template: String) { + val actual: Any = evaluator.eval(Evaluable.prepare(template)) + assert(actual is String) + assertEquals(expected, actual as String) + } + + private inline fun assertTernaryExpression( + expr: String, expected: T, + vararg variablesInvokes: Pair + ) { + val actual = evaluator.eval(Evaluable.prepare("@{$expr}")) + variablesInvokes.forEach { (varName, invokes) -> + verify(variableProvider, times(invokes)).get(varName) + } + assert(actual is T) + assertEquals(expected, actual as T) + } + + private fun setVariable(name: String, value: Any) { + whenever(variableProvider.get(name)).thenReturn(value) + } + + companion object { + const val TEST_VARIABLE_1 = "test_1" + const val TEST_VARIABLE_2 = "test_2" + const val VAR_A = "var_a" + const val VAR_B = "var_b" + } +} diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/FunctionTest.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/FunctionTest.kt new file mode 100644 index 000000000..60c43baaf --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/FunctionTest.kt @@ -0,0 +1,100 @@ +package com.yandex.div.evaluable + +import com.yandex.div.evaluable.function.FunctionImpl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FunctionTest { + + @Test + fun `function without arguments matches empty argument list`() { + val function = FunctionImpl("func", emptyList()) + assertMatchesArguments(function, emptyList()) + } + + @Test + fun `function with only int argument matches single int value`() { + val function = FunctionImpl("func", listOf(FunctionArgument(EvaluableType.INTEGER))) + assertMatchesArguments(function, listOf(EvaluableType.INTEGER)) + } + + @Test + fun `function with only number argument matches single number value`() { + val function = FunctionImpl("func", listOf(FunctionArgument(EvaluableType.NUMBER))) + assertMatchesArguments(function, listOf(EvaluableType.NUMBER)) + } + + @Test + fun `function with only bool argument matches single bool value`() { + val function = FunctionImpl("func", listOf(FunctionArgument(EvaluableType.BOOLEAN))) + assertMatchesArguments(function, listOf(EvaluableType.BOOLEAN)) + } + + @Test + fun `function with only string argument matches single string value`() { + val function = FunctionImpl("func", listOf(FunctionArgument(EvaluableType.STRING))) + assertMatchesArguments(function, listOf(EvaluableType.STRING)) + } + + @Test + fun `too few arguments passed to function`() { + val function = FunctionImpl( + name = "func", + declaredArgs = listOf(FunctionArgument(EvaluableType.INTEGER), FunctionArgument(EvaluableType.INTEGER)) + ) + assertTooFewArguments(function, listOf(EvaluableType.INTEGER)) + } + + @Test + fun `too many arguments passed to function`() { + val function = FunctionImpl("func", emptyList()) + assertTooManyArguments(function, listOf(EvaluableType.INTEGER)) + } + + @Test + fun `function has type mismatch with passed argument`() { + val function = FunctionImpl("func", listOf(FunctionArgument(EvaluableType.INTEGER))) + assertArgumentTypeMismatch(function, listOf(EvaluableType.NUMBER)) + } + + @Test + fun `variadic function matches multiple values`() { + val function = FunctionImpl("func", listOf(FunctionArgument(EvaluableType.INTEGER, isVariadic = true))) + assertMatchesArguments( + function, + listOf(EvaluableType.INTEGER, EvaluableType.INTEGER, EvaluableType.INTEGER, EvaluableType.INTEGER) + ) + } + + @Test + fun `no variadic arguments passed to function`() { + val function = FunctionImpl("func", listOf(FunctionArgument(EvaluableType.INTEGER, isVariadic = true))) + assertMatchesArguments(function, emptyList()) + } + + @Test + fun `variadic function has type mismatch with passed argument`() { + val function = FunctionImpl("func", listOf(FunctionArgument(EvaluableType.INTEGER, isVariadic = true))) + assertArgumentTypeMismatch( + function, + listOf(EvaluableType.INTEGER, EvaluableType.NUMBER) + ) + } + + private fun assertMatchesArguments(function: Function, argTypes: List) { + assertEquals(Function.MatchResult.Ok, function.matchesArguments(argTypes)) + } + + private fun assertTooFewArguments(function: Function, argTypes: List) { + assertTrue(function.matchesArguments(argTypes) is Function.MatchResult.TooFewArguments) + } + + private fun assertTooManyArguments(function: Function, argTypes: List) { + assertTrue(function.matchesArguments(argTypes) is Function.MatchResult.TooManyArguments) + } + + private fun assertArgumentTypeMismatch(function: Function, argTypes: List) { + assertTrue(function.matchesArguments(argTypes) is Function.MatchResult.ArgTypeMismatch) + } +} diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/TokenizerTest.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/TokenizerTest.kt new file mode 100644 index 000000000..6d9f9b500 --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/TokenizerTest.kt @@ -0,0 +1,679 @@ +package com.yandex.div.evaluable + +import com.yandex.div.evaluable.internal.Token +import com.yandex.div.evaluable.internal.Tokenizer +import org.junit.Assert.assertEquals +import org.junit.Test + +class TokenizerTest { + + @Test + fun `string # simple string`() { + assertTemplate("This is a string", s("This is a string")) + } + + @Test + fun `string # simple string with empty expression`() { + assertTemplate("This is@{} a string", + ts, s("This is"), `@{`, `}`, s(" a string"), te) + } + + @Test + fun `string # two expression without any string`() { + assertTemplate("@{}@{}", + ts, `@{`, `}`, `@{`, `}`, te) + } + + @Test + fun `string # simple string with single quoted part`() { + assertTemplate("This is a 'string'", + s("This is a 'string'")) + } + + @Test + fun `string # just expression without any string`() { + assertTemplate("@{}") + } + + @Test + fun `value # empty value`() { + assertExpression("") + } + + @Test + fun `operands # unsigned Int operands`() { + assertUnsignedNumberLiteral("7", 7) + assertUnsignedNumberLiteral("19", 19) + assertUnsignedNumberLiteral("150", 150) + } + + @Test + fun `operands # unsigned Decimal operands`() { + assertUnsignedNumberLiteral("7.0", 7.0) + assertUnsignedNumberLiteral("19.000", 19.0) + assertUnsignedNumberLiteral("150.00", 150.0) + } + + @Test + fun `operands # positive Int operands`() { + assertPositiveNumberLiteral("+7", 7) + assertPositiveNumberLiteral("+19", 19) + assertPositiveNumberLiteral("+150", 150) + } + + @Test + fun `operands # positive Decimal operands`() { + assertPositiveNumberLiteral("+7.0", 7.0) + assertPositiveNumberLiteral("+19.000", 19.0) + assertPositiveNumberLiteral("+150.00", 150.0) + } + + @Test + fun `operands # negative Int operands`() { + assertNegativeNumberLiteral("-7", -7) + assertNegativeNumberLiteral("-19", -19) + assertNegativeNumberLiteral("-150", -150) + } + + @Test + fun `operands # negative Decimal operands`() { + assertNegativeNumberLiteral("-7.0", -7.0) + assertNegativeNumberLiteral("-19.000", -19.0) + assertNegativeNumberLiteral("-150.00", -150.0) + } + + @Test + fun `operands # empty Str operands`() { + assertStringLiteral("''", "") + } + + @Test + fun `operands # simple Str operands`() { + assertStringLiteral("'This is a string'", "This is a string") + } + + @Test + fun `operands # simple Str operands with single quote`() { + assertStringLiteral("'This\\'s a string'", "This's a string") + } + + @Test + fun `operands # low case variable name`() { + assertVariableName("a", "a") + assertVariableName("index", "index") + assertVariableName("state", "state") + } + + @Test + fun `operands # variable name with digits`() { + assertVariableName("a1", "a1") + assertVariableName("index123", "index123") + assertVariableName("st1t2", "st1t2") + } + + @Test + fun `operands # camel case variable name`() { + assertVariableName("aA", "aA") + assertVariableName("isAtEnd","isAtEnd") + assertVariableName("startOfExpression","startOfExpression") + } + + @Test + fun `operands # variable name with underline`() { + assertVariableName("_aA","_aA") + assertVariableName("is_AtEnd","is_AtEnd") + assertVariableName("__is_AtEnd","__is_AtEnd") + assertVariableName("start__OfExpression","start__OfExpression") + } + + @Test + fun `operands # variable name with dot`() { + assertVariableName("a.A","a.A") + assertVariableName("isAt.End", "isAt.End") + assertVariableName("startOf.Expression", "startOf.Expression") + } + + @Test + fun `operands # crazy variable name`() { + assertVariableName("_a.__A", "_a.__A") + assertVariableName("isAt.._2__..E3nd", "isAt.._2__..E3nd") + assertVariableName("_..._S6tarOf.___.Expres2ion", "_..._S6tarOf.___.Expres2ion") + } + + @Test + fun `operators # ternary operator`() { + assertExpression( + "true ? 1 : 0", + b(true), + ternIf, n(1), ternElse, n(0) + ) + assertExpression( + "true ? false : 'str'", + b(true), + ternIf, b(false), ternElse, s("str") + ) + assertExpression( + "2 > 0 ? 1 : 0", + n(2), greater, n(0), + ternIf, n(1), ternElse, n(0) + ) + assertExpression( + "2 > (3 + 1) ? 1 : 'some string'", + n(2), greater, `(`, n(3), plus, n(1), `)`, + ternIf, n(1), ternElse, s("some string") + ) + } + + @Test + fun `operators # arithmetic operators`() { + assertExpression("1 - 1", n(1), minus, n(1)) + assertExpression("1 + 1", n(1), plus, n(1)) + assertExpression("1 * 1", n(1), mul, n(1)) + assertExpression("1 / 1", n(1), div, n(1)) + assertExpression("1 % 1", n(1), mod, n(1)) + } + + @Test + fun `operators # logical operators`() { + assertExpression("&&", and) + assertExpression("||", or) + } + + @Test + fun `operators # equality operators`() { + assertExpression("==", equal) + assertExpression("!=", notEqual) + } + + @Test + fun `operators # comparison operators`() { + assertExpression("<", less) + assertExpression("<=", lessOrEqual) + assertExpression(">", greater) + assertExpression(">=", greaterOrEqual) + } + + @Test + fun `brackets # simple brackets`() { + assertExpression("(", `(`) + assertExpression(")", `)`) + assertExpression("()", `(`, `)`) + assertExpression("(())",`(`, `(`, `)`, `)`) + assertExpression("()()", `(`, `)`, `(`, `)`) + } + + @Test + fun `whitespaces # just ignore whitespaces`() { + assertExpression(" ") + assertExpression("\t") + assertExpression("\r") + } + + @Test + fun `functions # low case function name`() { + assertFunctionName("a()", "a") + assertFunctionName("index()","index") + assertFunctionName("state()", "state") + } + + @Test + fun `functions # function name with digits`() { + assertFunctionName("a1()","a1") + assertFunctionName("index123()","index123") + assertFunctionName("st1t2()","st1t2") + } + + @Test + fun `functions # camel case function name`() { + assertFunctionName("aA()","aA") + assertFunctionName("isAtEnd()","isAtEnd") + assertFunctionName("startOfExpression()","startOfExpression") + } + + @Test + fun `functions # function name with underline`() { + assertFunctionName("_aA()","_aA") + assertFunctionName("is_AtEnd()", "is_AtEnd") + assertFunctionName("__is_AtEnd()","__is_AtEnd") + assertFunctionName("start__OfExpression()","start__OfExpression") + } + + @Test + fun `functions # functions call with simple args`() { + assertExpression("a(1)", f("a"), `(`, n(1), `)`) + assertExpression("a(2.0)", f("a"), `(`, n(2.0), `)`) + assertExpression("a(2.0, 1)", f("a"), `(`, n(2.0), `,`, n(1), `)`) + assertExpression("a('str')", f("a"), `(`, s("str"), `)`) + assertExpression("a(2.0, 'str', 1)", f("a"), `(`, n(2.0), `,`, s("str"), `,`, n(1), `)`) + assertExpression("a(variable)", f("a"), `(`, v("variable"), `)`) + assertExpression("a('str', 2.0, variable, 1)", f("a"), `(`, + s("str"), `,`, n(2.0), `,`, v("variable"), `,`, n(1), `)`) + } + + @Test + fun `functions # hierarchy functions call`() { + assertExpression("a(c())", f("a"), `(`, f("c"), `(`, `)`, `)`) + assertExpression("a(b, c())", f("a"), `(`, v("b"), `,`, f("c"), `(`, `)`, `)`) + assertExpression("a(b(), c)", f("a"), `(`, f("b"), `(`, `)`, `,`, v("c"), `)`) + assertExpression("a(b, c(e, f()))", f("a"), `(`, v("b"), `,`, + f("c"), `(`, v("e"), `,`, f("f"), `(`, `)`, `)`, `)`) + assertExpression("a(b(e, f()), c)", f("a"), `(`, f("b"), `(`, + v("e"), `,`, f("f"), `(`, `)`, `)`, `,`, v("c"), `)`) + } + + @Test + fun `equality # Int literal equality`() { + assertSignedEquality("1", n(1), "1", n(1)) + assertSignedEquality("15", n(15), "192", n(192)) + } + + @Test + fun `equality # Decimal literal equality`() { + assertSignedEquality("1.0", n(1.0), "1.00", n(1.0)) + assertSignedEquality("15.000", n(15.0), "192.00", n(192.0)) + } + + @Test + fun `equality # Int with Decimal literal equality`() { + assertSignedEquality("1", n(1), "1.00", n(1.0)) + assertSignedEquality("15.000", n(15.0), "192", n(192)) + } + + @Test + fun `equality # variable equality`() { + assertSignedEquality("a", v("a"), "b", v("b")) + } + + @Test + fun `equality # function call equality`() { + assertSignedEquality("a()", listOf(f("a"), `(`, `)`), "b()", listOf(f("b"), `(`, `)`)) + } + + @Test + fun `equality # variable with Int equality`() { + assertSignedEquality("1", n(1), "b", v("b")) + } + + @Test + fun `equality # function call with Int equality`() { + assertSignedEquality("1", listOf(n(1)), "b()", listOf(f("b"), `(`, `)`)) + } + + @Test + fun `equality # variable with Decimal equality`() { + assertSignedEquality("1.0", n(1.0), "b", v("b")) + } + + @Test + fun `equality # function call with Decimal equality`() { + assertSignedEquality("1.0", listOf(n(1.0)), "b()", listOf(f("b"), `(`, `)`)) + } + + @Test + fun `equality # function call with variable equality`() { + assertSignedEquality("a", listOf(v("a")), "b()", listOf(f("b"), `(`, `)`)) + } + + @Test + fun `equality # Str literal equality`() { + assertUnsignedEquality("'a'", s("a"), "'a'", s("a")) + assertUnsignedEquality("'abc'", s("abc"), "'abc'", s("abc")) + assertUnsignedEquality("'ab'", s("ab"), "'abc'", s("abc")) + } + + @Test + fun `comparison # Int literal`() { + assertSignedComparison("1", n(1), "1", n(1)) + assertSignedComparison("15", n(15), "19", n(19)) + assertSignedComparison("154", n(154), "192", n(192)) + } + + @Test + fun `comparison # Decimal literal`() { + assertSignedComparison("1.0", n(1.0), "1.0", n(1.0)) + assertSignedComparison("15.000", n(15.0), "19.0000", n(19.0)) + assertSignedComparison("154.00", n(154.0), "192.0", n(192.0)) + } + + @Test + fun `comparison # Str literal`() { + assertUnsignedComparison("'a'", s("a"), "'a'", s("a")) + assertUnsignedComparison("'abc'", s("abc"), "'abc'", s("abc")) + assertUnsignedComparison("'ab'", s("ab"), "'abc'", s("abc")) + } + + @Test + fun `comparison # variables`() { + assertSignedComparison("a", v("a"), "b", v("b")) + } + @Test + fun `comparison # function call`() { + assertSignedComparison("a()", listOf(f("a"), `(`, `)`), "b()", listOf(f("b"), `(`, `)`)) + } + + @Test + fun `comparison # Int with Decimal literal`() { + assertSignedComparison("1", n(1), "1.0", n(1.0)) + assertSignedComparison("15.000", n(15.0), "19", n(19)) + assertSignedComparison("154", n(154), "192.00", n(192.0)) + } + + @Test + fun `comparison # Int with Str literal`() { + assertSignedComparison("1", n(1), "'str'", s("str")) + assertUnsignedComparison("-1", n(-1), "'str'", s("str")) + assertUnsignedComparison("+1", listOf(uplus, n(1)), "'str'", listOf(s("str"))) + } + + @Test + fun `comparison # Int with variable`() { + assertSignedComparison("1", n(1), "a", v("a")) + assertSignedComparison("b", v("b"), "19", n(19)) + assertSignedComparison("154", n(154), "c", v("c")) + } + + @Test + fun `comparison # Int with function call`() { + assertSignedComparison("1", listOf(n(1)), "a()", listOf(f("a"), `(`, `)`)) + } + + @Test + fun `comparison # Decimal with Str`() { + assertUnsignedComparison("1.0", n(1.0), "'str'", s("str")) + assertUnsignedComparison("-1.0", n(-1.0), "'str'", s("str")) + assertUnsignedComparison("+1.0", listOf(uplus, n(1.0)), "'str'", listOf(s("str"))) + } + + @Test + fun `comparison # Decimal with variable`() { + assertSignedComparison("1.0", n(1.0), "a", v("a")) + assertSignedComparison("b", v("b"), "19.000", n(19.0)) + assertSignedComparison("154.00", n(154.0), "c", v("c")) + } + + @Test + fun `comparison # Decimal with function call`() { + assertSignedComparison("1.0", listOf(n(1.0)), "a()", listOf(f("a"), `(`, `)`)) + } + + @Test + fun `comparison # variable with function call`() { + assertSignedComparison("var", listOf(v("var")), "a()", listOf(f("a"), `(`, `)`)) + } + + @Test + fun `logical # unary not`() { + assertExpression("!var", not, v("var")) + } + + @Test + fun `logical # and with variables`() { + assertOperation("var_1", listOf(v("var_1")), "&&", and, "var_2", listOf(v("var_2"))) + assertOperation("!var_1", listOf(not, v("var_1")), "&&", and, "var_2", listOf(v("var_2"))) + assertOperation("!var_1", listOf(not, v("var_1")), "&&", and, "!var_2", listOf(not, v("var_2"))) + } + + @Test + fun `logical # or with variables`() { + assertOperation("var_1", listOf(v("var_1")), "||", or, "var_2", listOf(v("var_2"))) + assertOperation("!var_1", listOf(not, v("var_1")), "||", or, "var_2", listOf(v("var_2"))) + assertOperation("!var_1", listOf(not, v("var_1")), "||", or, "!var_2", listOf(not, v("var_2"))) + } + + @Test(expected = EvaluableException::class) + fun `unsupported # Power operator is unsupported`() { + assertExpression("^") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # Semicolon is unsupported`() { + assertExpression(";") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # and symbol is unsupported`() { + assertExpression("&") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # or symbol is unsupported`() { + assertExpression("|") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # sharp symbol is unsupported`() { + assertExpression("#") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # dollar symbol is unsupported`() { + assertExpression("$") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # slash symbol is unsupported`() { + assertExpression("\\") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # tilda symbol is unsupported`() { + assertExpression("~") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # back tick symbol is unsupported`() { + assertExpression("`") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # dot symbol is unsupported`() { + assertExpression(".") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # simple curly brackets`() { + assertExpression("{") + assertExpression("}") + assertExpression("{}") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # simple square brackets`() { + assertExpression("[") + assertExpression("]") + assertExpression("[]") + } + + @Test(expected = EvaluableException::class) + fun `unsupported # function name with dot`() { + assertExpression("_A.b()", f("_A.b()"), `(`, `)`) + } + + @Test(expected = EvaluableException::class) + fun `errors # String without closing quote`() { + assertExpression("'string", s("string")) + } + + private fun assertUnsignedNumberLiteral(expr: String, value: Number) = assertExpression(expr, n(value)) + private fun assertPositiveNumberLiteral(expr: String, value: Number) = assertExpression(expr, uplus, n(value)) + private fun assertNegativeNumberLiteral(expr: String, value: Int) = assertExpression(expr, n(value)) + private fun assertNegativeNumberLiteral(expr: String, value: Double) = assertExpression(expr, n(value)) + private fun assertStringLiteral(expr: String, expected: String) = assertExpression(expr, s(expected)) + private fun assertVariableName(expr: String, expected: String) = assertExpression(expr, v(expected)) + private fun assertFunctionName(expr: String, expected: String) = assertExpression(expr, f(expected), `(`, `)`) + private fun assertSignedEquality(leftExpr: String, leftExpected: Token, + rightExpr: String, rightExpected: Token) = + assertSignedEquality(leftExpr, listOf(leftExpected), rightExpr, listOf(rightExpected)) + + private fun assertSignedEquality(leftExpr: String, leftExpected: List, + rightExpr: String, rightExpected: List) { + assertSignedOperation(leftExpr, leftExpected, "==", equal, rightExpr, rightExpected) + assertSignedOperation(leftExpr, leftExpected, "!=", notEqual, rightExpr, rightExpected) + } + + private fun assertUnsignedEquality(leftExpr: String, leftExpected: Token, + rightExpr: String, rightExpected: Token) = + assertUnsignedEquality(leftExpr, listOf(leftExpected), rightExpr, listOf(rightExpected)) + + private fun assertUnsignedEquality(leftExpr: String, leftExpected: List, + rightExpr: String, rightExpected: List) { + assertOperation(leftExpr, leftExpected, "==", equal, rightExpr, rightExpected) + assertOperation(leftExpr, leftExpected, "!=", notEqual, rightExpr, rightExpected) + } + + private fun assertSignedOperation(leftExpr: String, leftExpected: List, + operationExpr: String, operationExpected: Token, + rightExpr: String, rightExpected: List) { + assertOperation(leftExpr, leftExpected, operationExpr, operationExpected, rightExpr, rightExpected) + assertOperation( + "+$leftExpr", listOf(uplus) + leftExpected, + operationExpr, operationExpected, + rightExpr, rightExpected + ) + assertOperation( + leftExpr, leftExpected, + operationExpr, operationExpected, + "+$rightExpr", listOf(uplus) + rightExpected + ) + assertOperation( + "+$leftExpr", listOf(uplus) + leftExpected, + operationExpr, operationExpected, + "+$rightExpr", listOf(uplus) + rightExpected + ) + + val leftExpectedWithUnaryMinus = addUnaryMinus(leftExpected) + val rightExpectedWithUnaryMinus = addUnaryMinus(rightExpected) + + assertOperation( + "-$leftExpr", leftExpectedWithUnaryMinus, + operationExpr, operationExpected, + rightExpr, rightExpected + ) + assertOperation( + leftExpr, leftExpected, + operationExpr, operationExpected, + "-$rightExpr", rightExpectedWithUnaryMinus + ) + assertOperation( + "-$leftExpr", leftExpectedWithUnaryMinus, + operationExpr, operationExpected, + "-$rightExpr", rightExpectedWithUnaryMinus + ) + + assertOperation( + "-$leftExpr", leftExpectedWithUnaryMinus, + operationExpr, operationExpected, + "+$rightExpr", listOf(uplus) + rightExpected + ) + assertOperation( + "+$leftExpr", listOf(uplus) + leftExpected, + operationExpr, operationExpected, + "-$rightExpr", rightExpectedWithUnaryMinus + ) + } + + private fun addUnaryMinus(tokens: List) : List { + val first = tokens.firstOrNull() + ?: throw EvaluableException("Non empty token list is expected for adding unary minus.") + return if (first is Token.Operand.Literal.Num) { + val negativeValue = when (first.value) { + is Int -> first.value.unaryMinus() + is Double -> first.value.unaryMinus() + else -> throw EvaluableException("Unknown type of number ${first.value}.") + } + listOf(n(negativeValue)) + tokens.drop(1) + } else { + listOf(uminus) + tokens + } + } + + private fun assertOperation(leftExpr: String, leftExpected: List, + operationExpr: String, operationExpected: Token, + rightExpr: String, rightExpected: List) { + assertExpression("$leftExpr $operationExpr $rightExpr", (leftExpected + operationExpected + rightExpected)) + assertExpression("$rightExpr $operationExpr $leftExpr", (rightExpected + operationExpected + leftExpected)) + } + + private fun assertSignedComparison(leftExpr: String, leftExpected: Token, + rightExpr: String, rightExpected: Token) = + assertSignedComparison(leftExpr, listOf(leftExpected), rightExpr, listOf(rightExpected)) + + private fun assertSignedComparison(leftExpr: String, leftExpected: List, + rightExpr: String, rightExpected: List) { + assertSignedOperation(leftExpr, leftExpected, "<", less, rightExpr, rightExpected) + assertSignedOperation(leftExpr, leftExpected, "<=", lessOrEqual, rightExpr, rightExpected) + assertSignedOperation(leftExpr, leftExpected, ">", greater, rightExpr, rightExpected) + assertSignedOperation(leftExpr, leftExpected, ">=", greaterOrEqual, rightExpr, rightExpected) + } + + private fun assertUnsignedComparison(leftExpr: String, leftExpected: Token, + rightExpr: String, rightExpected: Token) = + assertUnsignedComparison(leftExpr, listOf(leftExpected), rightExpr, listOf(rightExpected)) + + private fun assertUnsignedComparison(leftExpr: String, leftExpected: List, + rightExpr: String, rightExpected: List) { + assertOperation(leftExpr, leftExpected, "<", less, rightExpr, rightExpected) + assertOperation(leftExpr, leftExpected, "<=", lessOrEqual, rightExpr, rightExpected) + assertOperation(leftExpr, leftExpected, ">", greater, rightExpr, rightExpected) + assertOperation(leftExpr, leftExpected, ">=", greaterOrEqual, rightExpr, rightExpected) + } + + private fun assertExpression(expr: String, vararg expected: Token) { + assertExpression(expr, expected.toList()) + } + + private fun assertExpression(expr: String, expected: List) { + val actual = expr("@{$expr}") + assertEquals(expected.toList(), actual) + } + + private fun assertTemplate(tmpl: String, vararg expected: Token) { + val actual = expr(tmpl) + assertEquals(expected.toList(), actual) + } + + private fun expr(expr: String): List { + return Tokenizer.tokenize(expr) + } + + companion object { + private val ternIf = Token.Operator.TernaryIf + private val ternElse = Token.Operator.TernaryElse + + private val plus = Token.Operator.Binary.Sum.Plus + private val minus = Token.Operator.Binary.Sum.Minus + private val mul = Token.Operator.Binary.Factor.Multiplication + private val mod = Token.Operator.Binary.Factor.Modulo + private val div = Token.Operator.Binary.Factor.Division + + private val uplus = Token.Operator.Unary.Plus + private val uminus = Token.Operator.Unary.Minus + private val not = Token.Operator.Unary.Not + + private val and = Token.Operator.Binary.Logical.And + private val or = Token.Operator.Binary.Logical.Or + + private val equal = Token.Operator.Binary.Equality.Equal + private val notEqual = Token.Operator.Binary.Equality.NotEqual + + private val less = Token.Operator.Binary.Comparison.Less + private val lessOrEqual = Token.Operator.Binary.Comparison.LessOrEqual + private val greater = Token.Operator.Binary.Comparison.Greater + private val greaterOrEqual = Token.Operator.Binary.Comparison.GreaterOrEqual + + private val `,` = Token.Function.ArgumentDelimiter + private val `(` = Token.Bracket.LeftRound + private val `)` = Token.Bracket.RightRound + private val `@{` = Token.StringTemplate.StartOfExpression + private val `}` = Token.StringTemplate.EndOfExpression + + private val ts = Token.StringTemplate.Start + private val te = Token.StringTemplate.End + + private fun n(value: Number) = Token.Operand.Literal.Num(value) + private fun s(value: String) = Token.Operand.Literal.Str(value) + private fun b(value: Boolean) = Token.Operand.Literal.Bool(value) + private fun v(value: String) = Token.Operand.Variable(value) + private fun f(value: String) = Token.Function(value) + } +} \ No newline at end of file diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/function/FunctionValidatorTest.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/function/FunctionValidatorTest.kt new file mode 100644 index 000000000..17c56796c --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/function/FunctionValidatorTest.kt @@ -0,0 +1,167 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableException +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.FunctionArgument +import org.junit.Assert.assertEquals +import org.junit.Test + +class FunctionValidatorTest { + + @Test(expected = EvaluableException::class) + fun `multiple variadic arguments causes exception`() { + val function = FunctionImpl( + name = "func", + declaredArgs = listOf( + FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true), + FunctionArgument(type = EvaluableType.STRING, isVariadic = true) + ) + ) + + FunctionValidator.validateFunction(function) + } + + @Test(expected = EvaluableException::class) + fun `non-trailing variadic arguments causes exception`() { + val function = FunctionImpl( + name = "func", + declaredArgs = listOf( + FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true), + FunctionArgument(type = EvaluableType.STRING) + ) + ) + + FunctionValidator.validateFunction(function) + } + + @Test + fun `function with different argument does not conflict with known one`() { + val function1 = FunctionImpl( + name = "func", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + ) + + val function2 = FunctionImpl( + name = "func", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.NUMBER)) + ) + + assertEquals(function1, FunctionValidator.validateOverloading(function1, listOf(function2))) + } + + @Test + fun `function with different name does not conflict with known one`() { + val function1 = FunctionImpl( + name = "func1", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + ) + + val function2 = FunctionImpl( + name = "func2", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + ) + + assertEquals(function1, FunctionValidator.validateOverloading(function1, listOf(function2))) + } + + @Test(expected = EvaluableException::class) + fun `functions with the same arguments conflicts with known one`() { + val function1 = FunctionImpl( + name = "func", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + ) + + val function2 = FunctionImpl( + name = "func", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER)) + ) + + FunctionValidator.validateOverloading(function1, listOf(function2)) + } + + @Test(expected = EvaluableException::class) + fun `variadic function conflicts with explicit argument function`() { + val function1 = FunctionImpl( + name = "func", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true)) + ) + + val function2 = FunctionImpl( + name = "func", + declaredArgs = listOf( + FunctionArgument(type = EvaluableType.INTEGER) + ) + ) + + FunctionValidator.validateOverloading(function1, listOf(function2)) + } + + @Test(expected = EvaluableException::class) + fun `variadic function conflicts with multiple explicit argument function`() { + val function1 = FunctionImpl( + name = "func", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true)) + ) + + val function2 = FunctionImpl( + name = "func", + declaredArgs = listOf( + FunctionArgument(type = EvaluableType.INTEGER), + FunctionArgument(type = EvaluableType.INTEGER) + ) + ) + + FunctionValidator.validateOverloading(function1, listOf(function2)) + } + + @Test(expected = EvaluableException::class) + fun `variadic function conflicts with variadic function`() { + val function1 = FunctionImpl( + name = "func", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true)) + ) + + val function2 = FunctionImpl( + name = "func", + declaredArgs = listOf( + FunctionArgument(type = EvaluableType.INTEGER), + FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true) + ) + ) + + FunctionValidator.validateOverloading(function1, listOf(function2)) + } + + @Test(expected = EvaluableException::class) + fun `variadic function conflicts with empty argument list function`() { + val function1 = FunctionImpl( + name = "func", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true)) + ) + + val function2 = FunctionImpl( + name = "func", + declaredArgs = emptyList() + ) + + FunctionValidator.validateOverloading(function1, listOf(function2)) + } + + @Test + fun `variadic function does not conflict with multiple explicit argument function`() { + val function1 = FunctionImpl( + name = "func", + declaredArgs = listOf(FunctionArgument(type = EvaluableType.INTEGER, isVariadic = true)) + ) + + val function2 = FunctionImpl( + name = "func", + declaredArgs = listOf( + FunctionArgument(type = EvaluableType.INTEGER), + FunctionArgument(type = EvaluableType.NUMBER) + ) + ) + + assertEquals(function1, FunctionValidator.validateOverloading(function1, listOf(function2))) + } +} diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/function/FunctoinImpl.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/function/FunctoinImpl.kt new file mode 100644 index 000000000..11c514e16 --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/function/FunctoinImpl.kt @@ -0,0 +1,16 @@ +package com.yandex.div.evaluable.function + +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.Function + +internal class FunctionImpl( + override val name: String, + override val declaredArgs: List, + override val isPure: Boolean = true +) : Function() { + + override val resultType = EvaluableType.BOOLEAN + + override fun evaluate(args: List) = true +} diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/EvaluableMultiplatformTest.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/EvaluableMultiplatformTest.kt new file mode 100644 index 000000000..aca23bfdd --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/EvaluableMultiplatformTest.kt @@ -0,0 +1,200 @@ +package com.yandex.div.evaluable.multiplatform + +import com.yandex.div.evaluable.Evaluable +import com.yandex.div.evaluable.EvaluableException +import com.yandex.div.evaluable.Evaluator +import com.yandex.div.evaluable.VariableProvider +import com.yandex.div.evaluable.function.BuiltinFunctionProvider +import com.yandex.div.evaluable.multiplatform.MultiplatformTestUtils.isForAndroidPlatform +import com.yandex.div.evaluable.multiplatform.MultiplatformTestUtils.parsePlatform +import com.yandex.div.evaluable.multiplatform.MultiplatformTestUtils.toListOfJSONObject +import com.yandex.div.evaluable.types.Color +import com.yandex.div.evaluable.types.DateTime +import org.json.JSONException +import org.json.JSONObject +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.io.File + +@RunWith(Parameterized::class) +class EvaluableMultiplatformTest(private val caseOrError: TestCaseOrError) { + + private val variableProvider = mock() + private val evaluator = Evaluator(variableProvider, BuiltinFunctionProvider()) + private lateinit var testCase: ExpressionTestCase + + @Before + fun setUp() { + testCase = caseOrError.getCaseOrThrow() + for (variable in testCase.variables) { + whenever(variableProvider.get(variable.name)).thenReturn(variable.value) + } + } + + @Test + fun runExpressionTestCase() { + val expectedValue = testCase.expectedValue + if (expectedValue is Exception) { + val actualValue = evalExpression() + Assert.assertTrue(actualValue is Exception) + val expectedMessage = expectedValue.message.takeIf { it?.isNotEmpty() == true } ?: return + Assert.assertEquals(expectedMessage, (actualValue as Throwable).message) + } else { + val evalExpression = evalExpression() + if (evalExpression is Throwable) { + throw AssertionError( + "Expecting '${testCase.expectedValue}' at expression '${testCase.expression}' " + + "but got exception instead!", evalExpression) + } + Assert.assertEquals("expression: '${testCase.expression}'", testCase.expectedValue, evalExpression) + } + } + + private fun evalExpression(): Any { + return try { + evaluator.eval(Evaluable.prepare(testCase.expression)) + } catch (e: EvaluableException) { + e + } + } + + data class ExpressionTestCase( + val name: String, + val expression: String, + val variables: List, + val platform: List, + val expectedValue: Any + ) { + override fun toString(): String { + return name + } + } + + data class TestVariable(val name: String, val value: Any) + + data class TestUrl(val value: String) { + override fun toString() = value + } + + data class TestDate(val value: String) { + private val dateTime = DateTime.parseAsUTC(value) + + override fun equals(other: Any?): Boolean { + return dateTime == other + } + + override fun hashCode(): Int { + return dateTime.hashCode() + } + + override fun toString() = value + } + + companion object { + private const val TEST_CASES_FILE_PATH = "expression_test_data" + + private const val CASES_FIELD = "cases" + private const val CASE_NAME_FIELD = "name" + private const val CASE_VARIABLES_FIELD = "variables" + private const val CASE_VARIABLE_NAME_FIELD = "name" + private const val CASE_EXPECTED_VALUE_FIELD = "expected" + private const val CASE_EXPRESSION_VALUE_FIELD = "expression" + private const val TYPE_FIELD = "type" + private const val VALUE_FIELD = "value" + + private const val VALUE_TYPE_STRING = "string" + private const val VALUE_TYPE_INTEGER = "integer" + private const val VALUE_TYPE_DECIMAL = "number" + private const val VALUE_TYPE_BOOLEAN = "boolean" + private const val VALUE_TYPE_BOOL_INT = "bool_int" + private const val VALUE_TYPE_DATE_TIME = "datetime" + private const val VALUE_TYPE_URL = "url" + private const val VALUE_TYPE_COLOR = "color" + private const val VALUE_TYPE_UNIT = "unit" + private const val VALUE_TYPE_ERROR = "error" + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun cases(): List> { + val cases = mutableListOf>() + val errors = MultiplatformTestUtils.walkJSONs(TEST_CASES_FILE_PATH) { file, json -> + val newCases = json.optJSONArray(CASES_FIELD).toListOfJSONObject() + .map { parseTestCase(file, it) } + .filter { it.error != null || isForAndroidPlatform(it.testCase?.platform) } + cases.addAll(newCases) + + } + return errors.map { TestCaseOrError(it) } + cases + } + + private fun parseTestCase(file: File, json: JSONObject): TestCaseOrError { + val name = try { + json.getString(CASE_NAME_FIELD) + } catch (e: JSONException) { + return TestCaseOrError(TestCaseParsingError("???", file, json, e)) + } + + try { + val testCase = ExpressionTestCase( + name, + json.getString(CASE_EXPRESSION_VALUE_FIELD), + json.optJSONArray(CASE_VARIABLES_FIELD)?.let { array -> + val result = mutableListOf() + for (i in 0 until array.length()) { + val variable = array.getJSONObject(i) + val value: Any = parseValue(variable) + result.add( + TestVariable( + variable.getString(CASE_VARIABLE_NAME_FIELD), + value + ) + ) + } + result + } ?: emptyList(), + parsePlatform(json), + json.getJSONObject(CASE_EXPECTED_VALUE_FIELD).let { expected -> + parseValue(expected) + } + ) + + return TestCaseOrError(testCase) + } catch (e: JSONException) { + return TestCaseOrError(TestCaseParsingError(name, file, json, e)) + } + } + + private fun parseValue(json: JSONObject): Any { + val value: Any = when (val type = json.getString(TYPE_FIELD)) { + VALUE_TYPE_STRING -> json.getString(VALUE_FIELD) + VALUE_TYPE_URL -> TestUrl(json.getString(VALUE_FIELD)) + VALUE_TYPE_COLOR -> Color.parse(json.getString(VALUE_FIELD)) + VALUE_TYPE_INTEGER -> json.getInt(VALUE_FIELD) + VALUE_TYPE_DECIMAL -> json.getDouble(VALUE_FIELD) + VALUE_TYPE_BOOLEAN, + VALUE_TYPE_BOOL_INT -> { + val value = json.get(VALUE_FIELD) + return when { + value is Number -> value.toInt() == 1 + value == java.lang.Boolean.FALSE -> false + value == java.lang.Boolean.TRUE -> true + value is String && "true".equals(value, true) -> true + value is String && "false".equals(value, true) -> false + else -> throw IllegalAccessException("Unknown variable value: $value") + } + } + VALUE_TYPE_DATE_TIME -> TestDate(json.getString(VALUE_FIELD)) + VALUE_TYPE_UNIT -> Unit + VALUE_TYPE_ERROR -> EvaluableException(json.optString(VALUE_FIELD)) + else -> throw IllegalAccessException("Unknown variable type: $type") + } + return value + } + } +} + diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/MultiplatformTestUtils.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/MultiplatformTestUtils.kt new file mode 100644 index 000000000..2103fed98 --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/MultiplatformTestUtils.kt @@ -0,0 +1,77 @@ +package com.yandex.div.evaluable.multiplatform + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.File + +private const val PLATFORM_FIELD = "platforms" +private const val VALUE_PLATFORM_ANDROID = "android" +private const val JSON_EXTENSION = "json" +private const val DIV2_JSON_PATH = "../../../test_data/" + +object MultiplatformTestUtils { + fun walkJSONs( + relativePath: String, + parseAction: (file: File, json: JSONObject) -> Unit + ): List { + val errors = mutableListOf() + getFiles(File(DIV2_JSON_PATH, relativePath)) + .forEach { file -> + val json = try { + JSONObject(file.readText(Charsets.UTF_8)) + } catch (e: Exception) { + errors.add( + TestCaseParsingError(name = null, file = file, json = null, error = e) + ) + return@forEach + } + + try { + parseAction(file, json) + } catch (e: JSONException) { + errors.add( + TestCaseParsingError(name = null, file = file, json = null, error = e) + ) + } + } + + return errors + } + + fun parsePlatform(json: JSONObject): List { + return json.getJSONArray(PLATFORM_FIELD).toListOfString() + } + + fun isForAndroidPlatform(platform: List?): Boolean { + return platform?.contains(VALUE_PLATFORM_ANDROID) == true + } + + fun JSONArray?.toListOfJSONObject(): List { + if (this == null) { + return emptyList() + } + val result = mutableListOf() + for (i in 0 until this.length()) { + result.add(this.getJSONObject(i)) + } + return result + } + + private fun getFiles(dir: File): List { + val (directories, files) = dir.listFiles().orEmpty() + .partition { it.isDirectory } + return arrayListOf().apply { + addAll(files.filter { file -> file.extension == JSON_EXTENSION }) + addAll(directories.flatMap { getFiles(it) }) + } + } + + private fun JSONArray.toListOfString(): List { + val result = mutableListOf() + for (i in 0 until this.length()) { + result.add(this.getString(i)) + } + return result + } +} diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/SignaturesMultiplatformTest.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/SignaturesMultiplatformTest.kt new file mode 100644 index 000000000..e47912d24 --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/SignaturesMultiplatformTest.kt @@ -0,0 +1,104 @@ +package com.yandex.div.evaluable.multiplatform + +import com.yandex.div.evaluable.EvaluableException +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.FunctionArgument +import com.yandex.div.evaluable.function.BuiltinFunctionProvider +import com.yandex.div.evaluable.multiplatform.MultiplatformTestUtils.isForAndroidPlatform +import com.yandex.div.evaluable.multiplatform.MultiplatformTestUtils.parsePlatform +import com.yandex.div.evaluable.multiplatform.MultiplatformTestUtils.toListOfJSONObject +import org.json.JSONException +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import java.io.File +import java.lang.RuntimeException + +@RunWith(Parameterized::class) +class SignaturesMultiplatformTest(caseOrError: TestCaseOrError) { + private val functionProvider = BuiltinFunctionProvider() + private val signature = caseOrError.getCaseOrThrow() + + @Test + fun runSignatureTests() { + try { + functionProvider.ensureFunctionRegistered( + signature.functionName, + signature.arguments, + signature.resultType + ) + } catch (e: EvaluableException) { + throw RuntimeException("Test for signature \"$signature\" failed.", e) + } + } + + data class SignatureTestCase( + val name: String, + val functionName: String, + val arguments: List, + val resultType: EvaluableType, + ) { + override fun toString(): String { + return name + } + } + + companion object { + private const val SIGNATURES_FILE_PATH = "expression_test_data" + + private const val SIGNATURE_FIELD = "signatures" + private const val SIGNATURE_NAME_FIELD = "name" + private const val SIGNATURE_FUNCTION_NAME = "function_name" + private const val SIGNATURE_ARGUMENTS_FIELD = "arguments" + private const val SIGNATURE_RESULT_TYPE_FIELD = "result_type" + private const val ARGUMENT_TYPE_FIELD = "type" + private const val ARGUMENT_VARARG_FIELD = "vararg" + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun signatures(): List> { + val cases = mutableListOf>() + val errors = MultiplatformTestUtils.walkJSONs(SIGNATURES_FILE_PATH) { file, json -> + val newCases = json.optJSONArray(SIGNATURE_FIELD).toListOfJSONObject() + .filter { isForAndroidPlatform(parsePlatform(it)) } + .map { parseSignature(file, it) } + .filter { it.error != null } + cases.addAll(newCases) + + } + return errors.map { TestCaseOrError(it) } + cases + } + + private fun parseSignature(file: File, json: JSONObject): TestCaseOrError { + val name = try { + json.getString(SIGNATURE_NAME_FIELD) + } catch (e: JSONException) { + return TestCaseOrError(TestCaseParsingError("???", file, json, e)) + } + + try { + return TestCaseOrError(SignatureTestCase( + name, + json.getString(SIGNATURE_FUNCTION_NAME), + json.optJSONArray(SIGNATURE_ARGUMENTS_FIELD)?.let { array -> + val result = mutableListOf() + for (i in 0 until array.length()) { + val argument = array.getJSONObject(i) + val type = EvaluableType.valueOf( + argument.getString(ARGUMENT_TYPE_FIELD).uppercase() + ) + val vararg = argument.optBoolean(ARGUMENT_VARARG_FIELD) + result.add(FunctionArgument(type, vararg)) + } + result + } ?: emptyList(), + EvaluableType.valueOf(json.getString(SIGNATURE_RESULT_TYPE_FIELD).uppercase()), + )) + + } catch (e: JSONException) { + return TestCaseOrError(TestCaseParsingError(name, file, json, e)) + } + } + } +} diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/TestCaseOrError.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/TestCaseOrError.kt new file mode 100644 index 000000000..ecf5dda8b --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/TestCaseOrError.kt @@ -0,0 +1,20 @@ +package com.yandex.div.evaluable.multiplatform + +class TestCaseOrError private constructor( + val testCase: T?, + val error: TestCaseParsingError?, +) { + fun getCaseOrThrow(): T { + error?.throwError() + return testCase!! + } + + override fun toString(): String { + return testCase?.toString() ?: error?.toString() ?: "???" + } + + companion object { + operator fun invoke(testCase: T) = TestCaseOrError(testCase, null) + operator fun invoke(error: TestCaseParsingError) = TestCaseOrError(null, error) + } +} \ No newline at end of file diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/TestCaseParsingError.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/TestCaseParsingError.kt new file mode 100644 index 000000000..b79f0dd65 --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/multiplatform/TestCaseParsingError.kt @@ -0,0 +1,25 @@ +package com.yandex.div.evaluable.multiplatform + +import org.json.JSONException +import org.json.JSONObject +import java.io.File + +class TestCaseParsingError( + private val name: String?, + private val file: File, + private val json: JSONObject?, + private val error: Exception, +) { + override fun toString() = name ?: "${file.name}/${error.message?:"?"}" + + fun throwError(): Nothing { + if (json == null) { + throw JSONException("$file parsing failed!", error) + } + val caseNameOrEmpty = name ?: "" + throw JSONException( + "Case $caseNameOrEmpty parsing failed! (file: $file , json: $json)", + error + ) + } +} \ No newline at end of file diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/repl/Command.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/repl/Command.kt new file mode 100644 index 000000000..041c0a288 --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/repl/Command.kt @@ -0,0 +1,47 @@ +package com.yandex.div.evaluable.repl + +import kotlin.system.exitProcess + +internal enum class Command( + val commandName: String, + val commandDescription: String, + val actionOnCommand: (command: String) -> Unit, + val regex: Regex = commandName.toRegex() +) { + CLEAR( + commandName = "clear", + commandDescription = "clears info about all variables", + actionOnCommand = { EvaluableReplRuntime.clearVariables() } + ), + EXIT( + commandName = "exit", + commandDescription = "exits program", + actionOnCommand = { exitProcess(0) } + ), + HELP( + commandName = "help", + commandDescription = "prints this message", + actionOnCommand = { EvaluableReplRuntime.printHelpMessage() } + ), + VARIABLES( + commandName = "variables", + commandDescription = "prints list of assigned variables", + actionOnCommand = { EvaluableReplRuntime.printVariables() } + ), + ASSIGN_VARIABLE( + commandName = "variableName: type = value", + regex = EvaluableReplRuntime.variableAssigningRegex, + commandDescription = "example: 'integerVar: integer = 14', 'stringVar: string = hello world'. Assigns variable to value of type ", + actionOnCommand = { EvaluableReplRuntime.parseAssignment(it) } + ), + EXPRESSION( + commandName = "@{expression}", + regex = EvaluableReplRuntime.expressionRegex, + commandDescription = "example: '@{sum(1, 2, 3)}', '@{toNumber(5) / 2.0}'. Evaluates expression ", + actionOnCommand = { EvaluableReplRuntime.evaluateExpression(it) } + ); + + fun canBeExecutedWith(command: String): Boolean = regex.matches(command) + + fun execute(command: String) = actionOnCommand(command) +} diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/repl/EvaluableRepl.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/repl/EvaluableRepl.kt new file mode 100644 index 000000000..12e13a700 --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/repl/EvaluableRepl.kt @@ -0,0 +1,22 @@ +@file:JvmName("EvaluableRepl") + +package com.yandex.div.evaluable.repl + +import com.yandex.div.evaluable.repl.EvaluableReplRuntime.readNewCommand + +fun main() { + println("Evaluable expressions REPL v0.0.1\n") + println("Type 'help' to get list of available commands.\n") + var command = readNewCommand() + while (true) { + val recognizedCommand = Command.values().find { + it.canBeExecutedWith(command) + } + if (recognizedCommand != null) { + recognizedCommand.execute(command) + } else { + println("Unknown command. Type 'help' to get list of available commands.") + } + command = readNewCommand() + } +} diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/repl/EvaluableReplRuntime.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/repl/EvaluableReplRuntime.kt new file mode 100644 index 000000000..a47985d21 --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/repl/EvaluableReplRuntime.kt @@ -0,0 +1,137 @@ +package com.yandex.div.evaluable.repl + +import com.yandex.div.evaluable.Evaluable +import com.yandex.div.evaluable.EvaluableType +import com.yandex.div.evaluable.Evaluator +import com.yandex.div.evaluable.VariableProvider +import com.yandex.div.evaluable.function.BuiltinFunctionProvider +import com.yandex.div.evaluable.internal.Parser +import com.yandex.div.evaluable.internal.Token +import com.yandex.div.evaluable.internal.Tokenizer +import com.yandex.div.evaluable.types.DateTime + + +internal object EvaluableReplRuntime { + private val variableProvider = VariableProvider { variableName -> variableList[variableName] } + private val functionProvider = BuiltinFunctionProvider() + private val evaluator = Evaluator(variableProvider, functionProvider) + + private val variableList = mutableMapOf() + + val expressionRegex = Regex("@\\{(?.+)}") + + val variableAssigningRegex = Regex( + "(?\\w+)\\s?:\\s?(?\\w+)\\s?=\\s?(?.+)" + ) + + private val helpMessage = Command.values().joinToString( + prefix = "Available commands:\n", + separator = "\n" + ) { "- ${it.commandName} [${it.commandDescription}]" } + + fun readNewCommand(): String { + print(">") + return readLine()!!.trim().minifySpaces() + } + + fun clearVariables() { + variableList.clear() + println("Variables cleared.") + } + + fun printVariables() { + if (variableList.isEmpty()) { + println("There are no variables be assigned.") + return + } + println("Variables:") + variableList.forEach { + if (it.value != null) { + println("${it.key}=${it.value}") + } + } + } + + fun printHelpMessage() { + println(helpMessage) + } + + fun evaluateExpression(expression: String) { + val evaluable = parseEvaluable(expression)?: return + try { + evaluator.eval(evaluable).also { println(it) } + } catch (t: Throwable) { + println("Error evaluating '$expression'.") + t.printStackTrace(System.out) + } + } + + fun parseAssignment(command: String) { + try { + val (name, value) = command.getNewVariableValue() + variableList[name] = value + println("Variable '$name' is set to value [$value].") + } catch (t: Throwable) { + println("Error assigning variable.") + t.printStackTrace(System.out) + } + } + + private fun Token.asString() = when (this) { + is Token.Operator.Binary -> { + "Binary.${this::class.simpleName}" + } + is Token.Operator.Unary -> { + "Unary.${this::class.simpleName}" + } + is Token.Operand, is Token.Function -> { + toString() + } + else -> { + this::class.simpleName + } + } + + private fun String.minifySpaces() = replace(Regex("\\s+"), " ") + + private fun String.getNewVariableValue(): Pair { + val (_, name, type, value) = variableAssigningRegex.matchEntire(this.minifySpaces())!!.groupValues + val evaluableType = EvaluableType.values().find { it.typeName.lowercase() == type.lowercase() } + ?: throw RuntimeException("Unknown type $type.") + return try { + val convertedValue = when (evaluableType) { + EvaluableType.INTEGER -> value.toInt() + EvaluableType.NUMBER -> value.toDouble() + EvaluableType.BOOLEAN -> value.toBoolean() + EvaluableType.STRING -> value + EvaluableType.COLOR -> value + EvaluableType.DATETIME -> DateTime.parseAsUTC(value) + } + name to convertedValue + } catch (t: Throwable) { + throw RuntimeException( + "Can't cast value '$value' to ${evaluableType.typeName} type.", + t + ) + } + } + + private fun parseEvaluable(command: String): Evaluable? { + val tokens = try { + Tokenizer.tokenize(command) + } catch (t: Throwable) { + println("Error tokenizing '$command'.") + t.printStackTrace(System.out) + return null + } + return try { + Parser.parse(tokens, command) + } catch (t: Throwable) { + val tokensString = + tokens.joinToString(prefix = "[", postfix = "]") { "'${it.asString()}'" } + println("Error evaluating '$command'. Parsed tokens: $tokensString.") + t.printStackTrace(System.out) + null + } + } +} diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/types/ColorTest.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/types/ColorTest.kt new file mode 100644 index 000000000..b52f816f6 --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/types/ColorTest.kt @@ -0,0 +1,54 @@ +package com.yandex.div.evaluable.types + +import org.junit.Assert +import org.junit.Test + +class ColorTest { + + private val testColor9Digits = "#FF001122" + private val testColor7Digits = "#001122" + private val testColor5Digits = "#F012" + private val testColor4Digits = "#012" + private val invalidColorStringText = "invalid color string" + private val invalidColorStringWithoutSharp = "FF001122" + private val invalidColorStringWrongCharacters = "#FF0011QQ" + private val testColorInt = 0xFF001122.toInt() + + @Test + fun `check parseColor`() { + Assert.assertEquals(testColorInt, Color.parse(testColor9Digits).value) + Assert.assertEquals(testColorInt, Color.parse(testColor7Digits).value) + Assert.assertEquals(testColorInt, Color.parse(testColor5Digits).value) + Assert.assertEquals(testColorInt, Color.parse(testColor4Digits).value) + } + + @Test + fun `check components functions 9 digits`() { + Assert.assertEquals(0xff, Color(testColorInt).alpha()) + Assert.assertEquals(0x00, Color(testColorInt).red()) + Assert.assertEquals(0x11, Color(testColorInt).green()) + Assert.assertEquals(0x22, Color(testColorInt).blue()) + } + + @Test + fun `check create functions`() { + Assert.assertEquals(testColorInt, Color.argb(0xff, 0x00, 0x11, 0x22).value) + Assert.assertEquals(0, Color.argb(0x00, 0x00, 0x00, 0x00).value) + Assert.assertEquals(testColorInt, Color.rgb(0x00, 0x11, 0x22).value) + } + + @Test(expected = IllegalArgumentException::class) + fun `error parsing invalid color string`() { + Color.parse(invalidColorStringText) + } + + @Test(expected = IllegalArgumentException::class) + fun `error parsing invalid color without #`() { + Color.parse(invalidColorStringWithoutSharp) + } + + @Test(expected = NumberFormatException::class) + fun `error parsing invalid color with wrong character`() { + Color.parse(invalidColorStringWrongCharacters) + } +} \ No newline at end of file diff --git a/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/types/DateTimeTest.kt b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/types/DateTimeTest.kt new file mode 100644 index 000000000..2ba069264 --- /dev/null +++ b/client/android/div-evaluable/src/test/java/com/yandex/div/evaluable/types/DateTimeTest.kt @@ -0,0 +1,43 @@ +package com.yandex.div.evaluable.types + +import org.junit.Assert +import org.junit.Test + +/** + * Tests for [DateTime]. + */ +class DateTimeTest { + @Test + fun `dates compared by timestamp in UTC timezone`() { + Assert.assertTrue( + DateTime(timestampMillis = 0, timezoneMinutes = +2 * 60) > + DateTime(timestampMillis = 0, timezoneMinutes = +3 * 60) + ) + } + + @Test + fun `dates equality`() { + Assert.assertEquals( + DateTime(timestampMillis = 101010, +3 * 60), + DateTime(timestampMillis = 101010, +3 * 60)) + } + + @Test + fun `parsing initial date from string`() { + val dateTime = DateTime.parseAsUTC("1970-01-01 00:00:00") + Assert.assertEquals(0, dateTime.timezoneMinutes) + Assert.assertEquals(0, dateTime.timestampMillis) + } + + @Test + fun `timestamp stored in millis`() { + val dateTime = DateTime.parseAsUTC("1970-01-01 00:00:01") + Assert.assertEquals(1000, dateTime.timestampMillis) + } + + @Test + fun `string representation does not include timezone in it`() { + val dateTime = DateTime(timestampMillis = 5000, timezoneMinutes = +1 * 60) + Assert.assertEquals("1970-01-01 00:00:05", dateTime.toString()) + } +} diff --git a/client/android/div-histogram/YAOWNERS b/client/android/div-histogram/YAOWNERS new file mode 100644 index 000000000..99f2994a5 --- /dev/null +++ b/client/android/div-histogram/YAOWNERS @@ -0,0 +1,13 @@ +gulevsky@yandex-team.ru +tayrinn@yandex-team.ru +pkurchatov@yandex-team.ru +ttok@yandex-team.ru +i-ts@yandex-team.ru +jelyscs@yandex-team.ru +syntezzz@yandex-team.ru +anarkh@yandex-team.ru +ov-mm@yandex-team.ru +geaden@yandex-team.ru + +set noparent +set lock diff --git a/client/android/div-histogram/build.gradle b/client/android/div-histogram/build.gradle new file mode 100644 index 000000000..c04cb50d1 --- /dev/null +++ b/client/android/div-histogram/build.gradle @@ -0,0 +1,14 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +dependencies { + implementation project(path: ':assertion') + implementation project(path: ':div-core') + implementation project(path: ':logging') + + implementation "com.google.dagger:dagger:$versions.dagger" + kapt "com.google.dagger:dagger-compiler:$versions.dagger" + + implementation "androidx.core:core-ktx:$versions.androidx.core" +} diff --git a/client/android/div-histogram/jacoco.excludes b/client/android/div-histogram/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-histogram/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-histogram/proguard-rules.pro b/client/android/div-histogram/proguard-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/client/android/div-histogram/src/main/AndroidManifest.xml b/client/android/div-histogram/src/main/AndroidManifest.xml new file mode 100644 index 000000000..1a239eb7c --- /dev/null +++ b/client/android/div-histogram/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/Div2ViewHistogramReporter.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/Div2ViewHistogramReporter.kt new file mode 100644 index 000000000..957098515 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/Div2ViewHistogramReporter.kt @@ -0,0 +1,134 @@ +package com.yandex.div.histogram + +import android.os.SystemClock +import android.view.View +import androidx.annotation.MainThread +import com.yandex.div.core.util.KAssert +import com.yandex.div.histogram.metrics.RenderMetrics +import com.yandex.div.histogram.reporter.HistogramReporter + +@MainThread +class Div2ViewHistogramReporter( + private val histogramReporter: () -> HistogramReporter, + private val renderConfig: () -> RenderConfiguration, +) { + + var component: String? = null + private var renderStarted = false + + private var bindingStartedTime: Long? = null + private var rebindingStartedTime: Long? = null + + private var measureStartedTime: Long? = null + private var layoutStartedTime: Long? = null + private var drawStartedTime: Long? = null + + private val renderMetrics by lazy(LazyThreadSafetyMode.NONE, ::RenderMetrics) + + private val currentUptime: Long + get() = SystemClock.uptimeMillis() + + fun onRenderStarted() { + renderStarted = true + } + + fun onBindingStarted() { + bindingStartedTime = currentUptime + } + + fun onBindingFinished() { + reportHistogram(bindingStartedTime, DIV_BINDING_HISTOGRAM, renderMetrics::binding) + bindingStartedTime = null + } + + fun onRebindingStarted() { + rebindingStartedTime = currentUptime + } + + fun onRebindingFinished() { + reportHistogram(rebindingStartedTime, DIV_REBINDING_HISTOGRAM, renderMetrics::rebinding) + rebindingStartedTime = null + } + + fun onMeasureStarted() { + measureStartedTime = currentUptime + } + + fun onMeasureFinished() { + measureStartedTime?.toTimePassed()?.let(renderMetrics::addMeasure) + } + + fun onLayoutStarted() { + layoutStartedTime = currentUptime + } + + fun onLayoutFinished() { + layoutStartedTime?.toTimePassed()?.let(renderMetrics::addLayout) + } + + fun onDrawStarted() { + drawStartedTime = currentUptime + } + + fun onDrawFinished() { + drawStartedTime?.toTimePassed()?.let(renderMetrics::addDraw) + if (renderStarted) { + reportRenderMetrics(renderMetrics) + } + resetRenderMetrics() + } + + private inline fun reportHistogram( + startTime: Long?, + histogramName: String, + onDuration: (Long) -> Unit + ) { + if (startTime == null) { + KAssert.fail { "start time of $histogramName is null" } + return + } + val duration = currentUptime - startTime + onDuration(duration) + histogramReporter().reportDuration(histogramName, duration, component) + } + + private fun reportRenderMetrics(renderMetrics: RenderMetrics) { + with(histogramReporter()) { + val renderConfig = renderConfig() + reportDuration( + DIV_RENDER_TOTAL, + renderMetrics.totalMs, + component, + filter = renderConfig.totalFilter + ) + reportDuration( + DIV_RENDER_MEASURE, + renderMetrics.measureMs, + component, + filter = renderConfig.measureFilter + ) + reportDuration( + DIV_RENDER_LAYOUT, + renderMetrics.layoutMs, + component, + filter = renderConfig.layoutFilter + ) + reportDuration( + DIV_RENDER_DRAW, + renderMetrics.drawMs, + component, + filter = renderConfig.drawFilter + ) + } + } + + private fun resetRenderMetrics() { + renderStarted = false + layoutStartedTime = null + measureStartedTime = null + drawStartedTime = null + renderMetrics.reset() + } + + private fun Long.toTimePassed(): Long = currentUptime - this +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/DivParsingHistogramReporter.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/DivParsingHistogramReporter.kt new file mode 100644 index 000000000..f45485fa7 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/DivParsingHistogramReporter.kt @@ -0,0 +1,116 @@ +package com.yandex.div.histogram + +import android.os.SystemClock +import com.yandex.div.core.annotations.PublicApi +import com.yandex.div.histogram.reporter.HistogramReporter +import com.yandex.div.histogram.util.HistogramUtils +import org.json.JSONObject +import java.util.concurrent.Executor + +/** + * Histogram reporter to measure and report Div related parsing. + */ +@PublicApi +interface DivParsingHistogramReporter { + + /** + * Measure DivData parsing, i.e. taken time to invoke DivData.fromJson. + * + * @param parse function calling DivData.fromJson. + */ + fun measureDataParsing(json: JSONObject, componentName: String?, parse: () -> D): D + + /** + * Measure Div templates parsing, i.e. taken time to invoke ParsingEnvironment.parseTemplates. + * + * @param parse function calling ParsingEnvironment.parseTemplates + */ + fun measureTemplatesParsing(json: JSONObject, componentName: String?, parse: () -> T): T + + /** + * Measure Div input to JSON parsing, i.e. taken time to fulfill JSONObject from given input. + * + * @param parse function that creates JSONObject from input. + */ + fun measureJsonParsing(componentName: String?, parse: () -> JSONObject): JSONObject + + companion object { + val DEFAULT: DivParsingHistogramReporter by lazy(::DefaultDivParsingHistogramReporter) + } +} + +private class DefaultDivParsingHistogramReporter : DivParsingHistogramReporter { + override fun measureDataParsing( + json: JSONObject, + componentName: String?, + parse: () -> D + ): D = parse() + + override fun measureTemplatesParsing( + json: JSONObject, + componentName: String?, + parse: () -> T + ): T = parse() + + override fun measureJsonParsing( + componentName: String?, + parse: () -> JSONObject + ): JSONObject = parse() +} + +class DivParsingHistogramReporterImpl( + private val histogramReporter: () -> HistogramReporter, + private val calculateSizeExecutor: () -> Executor +) : DivParsingHistogramReporter { + + private val currentUptime: Long + get() = SystemClock.uptimeMillis() + + override fun measureDataParsing( + json: JSONObject, + componentName: String?, + parse: () -> D + ): D = doMeasure(DIV_PARSING_DATA, json, componentName, parse) + + override fun measureTemplatesParsing( + json: JSONObject, + componentName: String?, + parse: () -> T + ): T = doMeasure(DIV_PARSING_TEMPLATES, json, componentName, parse) + + override fun measureJsonParsing( + componentName: String?, + parse: () -> JSONObject + ): JSONObject = doMeasure(DIV_PARSING_JSON, null, componentName, parse) + + private inline fun doMeasure( + histogramName: String, + json: JSONObject?, + componentName: String?, + parse: () -> D + ): D { + val startTime = currentUptime + return try { + parse() + } finally { + val duration = currentUptime - startTime + reportHistogram(histogramName, duration, componentName, json) + } + } + + private fun reportHistogram( + histogramName: String, + duration: Long, + componentName: String?, + json: JSONObject? + ) { + histogramReporter().reportDuration(histogramName, duration, componentName) + if (json == null) { + return + } + calculateSizeExecutor().execute { + val size = HistogramUtils.calculateUtf8JsonByteSize(json) + histogramReporter().reportSize(histogramName, size, componentName) + } + } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramCallType.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramCallType.kt new file mode 100644 index 000000000..61b6ee383 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramCallType.kt @@ -0,0 +1,19 @@ +package com.yandex.div.histogram + +import androidx.annotation.StringDef + +@StringDef( + value = [ + HistogramCallType.CALL_TYPE_COLD, + HistogramCallType.CALL_TYPE_COOL, + HistogramCallType.CALL_TYPE_WARM, + ] +) +annotation class HistogramCallType { + + companion object { + const val CALL_TYPE_COLD = "Cold" + const val CALL_TYPE_COOL = "Cool" + const val CALL_TYPE_WARM = "Warm" + } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramCallTypeChecker.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramCallTypeChecker.kt new file mode 100644 index 000000000..a1c904a4e --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramCallTypeChecker.kt @@ -0,0 +1,15 @@ +package com.yandex.div.histogram + +import java.util.concurrent.ConcurrentHashMap + +abstract class HistogramCallTypeChecker { + + private val reportedHistograms by lazy { ConcurrentHashMap() } + + fun addReported(histogramName: String): Boolean { + if (reportedHistograms.containsKey(histogramName)) { + return false + } + return reportedHistograms.putIfAbsent(histogramName, Unit) == null + } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramCallTypeProvider.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramCallTypeProvider.kt new file mode 100644 index 000000000..20a815368 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramCallTypeProvider.kt @@ -0,0 +1,29 @@ +package com.yandex.div.histogram + +import com.yandex.div.histogram.HistogramCallType.Companion.CALL_TYPE_COLD +import com.yandex.div.histogram.HistogramCallType.Companion.CALL_TYPE_COOL +import com.yandex.div.histogram.HistogramCallType.Companion.CALL_TYPE_WARM +import javax.inject.Provider + +class HistogramCallTypeProvider( + private val histogramColdTypeChecker: () -> HistogramColdTypeChecker +) : HistogramCallTypeChecker() { + + /** + * @return histogram call type name: + * [CALL_TYPE_COLD] - if it is the first reporting in application lifetime. + * [CALL_TYPE_COOL] - if it is the first reporting in [Div2Component] lifetime. + * [CALL_TYPE_WARM] - all other cases. + */ + @HistogramCallType + fun getHistogramCallType(histogramName: String): String { + if (histogramColdTypeChecker().addReported(histogramName)) { + addReported(histogramName) + return CALL_TYPE_COLD + } + if (addReported(histogramName)) { + return CALL_TYPE_COOL + } + return CALL_TYPE_WARM + } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramColdTypeChecker.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramColdTypeChecker.kt new file mode 100644 index 000000000..8d90ca49f --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramColdTypeChecker.kt @@ -0,0 +1,7 @@ +package com.yandex.div.histogram + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HistogramColdTypeChecker @Inject constructor() : HistogramCallTypeChecker() diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramConfiguration.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramConfiguration.kt new file mode 100644 index 000000000..e8679fde2 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramConfiguration.kt @@ -0,0 +1,45 @@ +package com.yandex.div.histogram + +import com.yandex.div.core.annotations.PublicApi +import com.yandex.div.core.histogram.CpuUsageHistogramReporter +import com.yandex.div.core.histogram.HistogramBridge +import com.yandex.div.core.histogram.NoOpHistogramBridge +import javax.inject.Provider + +@PublicApi +interface HistogramConfiguration : HistogramRecordConfiguration { + + val histogramBridge: Provider + val cpuUsageHistogramReporter: Provider + val isReportingEnabled: Boolean + val taskExecutorProvider: Provider + + open class DefaultHistogramConfiguration : HistogramConfiguration { + + override val histogramBridge: Provider = + DoubleCheckProvider(::NoOpHistogramBridge) + override val cpuUsageHistogramReporter: Provider = + DoubleCheckProvider { CpuUsageHistogramReporter.NoOp() } + override val isReportingEnabled: Boolean = false + override val isColdRecordingEnabled: Boolean = false + override val isCoolRecordingEnabled: Boolean = false + override val isWarmRecordingEnabled: Boolean = false + override val isSizeRecordingEnabled: Boolean = false + override val taskExecutorProvider: Provider = + DoubleCheckProvider(::DefaultTaskExecutor) + override val renderConfiguration: Provider = + DoubleCheckProvider(::RenderConfiguration) + } + + companion object { + @JvmField + val DEFAULT: HistogramConfiguration = DefaultHistogramConfiguration() + } +} + +private class DoubleCheckProvider(init: () -> T) : Provider { + + private val value: T by lazy(init) + + override fun get(): T = value +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramFilter.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramFilter.kt new file mode 100644 index 000000000..c72d5c0c4 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramFilter.kt @@ -0,0 +1,19 @@ +package com.yandex.div.histogram + +import com.yandex.div.core.annotations.PublicApi + +/** + * Filter for reporting histogram. + */ +@PublicApi +fun interface HistogramFilter { + /** + * If histogram with the given [componentName] is reported. + */ + fun report(componentName: String?): Boolean + + companion object { + val ON = HistogramFilter { true } + val OFF = HistogramFilter { false } + } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramNames.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramNames.kt new file mode 100644 index 000000000..b5a8a00b4 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramNames.kt @@ -0,0 +1,34 @@ +package com.yandex.div.histogram + +private const val DIV_RENDER_PREFIX = "Div.Render" +private const val DIV_PARSING_PREFIX = "Div.Parsing" + +/** + * Histogram name for reporting time in milliseconds taken by [Div2View.onLayout]. + */ +internal const val DIV_RENDER_LAYOUT = "$DIV_RENDER_PREFIX.Layout" + +/** + * Histogram name for reporting time in milliseconds taken by [Div2View.onMeasure]. + */ +internal const val DIV_RENDER_MEASURE = "$DIV_RENDER_PREFIX.Measure" + +/** + * Histogram name for reporting time in milliseconds taken by [Div2View.onDraw]. + */ +internal const val DIV_RENDER_DRAW = "$DIV_RENDER_PREFIX.Draw" + +/** + * Histogram name for reporting time in milliseconds taken to render [Div2View]. + */ +internal const val DIV_RENDER_TOTAL = "$DIV_RENDER_PREFIX.Total" + +internal const val DIV_PARSING_DATA = "$DIV_PARSING_PREFIX.Data" +internal const val DIV_PARSING_TEMPLATES = "$DIV_PARSING_PREFIX.Templates" +internal const val DIV_PARSING_JSON = "$DIV_PARSING_PREFIX.JSON" + +internal const val DIV_BINDING_HISTOGRAM = "Div.Binding" +internal const val DIV_REBINDING_HISTOGRAM = "Div.Rebinding" + +const val DIV_CONTEXT_CREATE_HISTOGRAM = "Div.Context.Create" +const val DIV_VIEW_CREATE_HISTOGRAM = "Div.View.Create" diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramRecordConfiguration.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramRecordConfiguration.kt new file mode 100644 index 000000000..94ea56014 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/HistogramRecordConfiguration.kt @@ -0,0 +1,13 @@ +package com.yandex.div.histogram + +import com.yandex.div.core.annotations.PublicApi +import javax.inject.Provider + +@PublicApi +interface HistogramRecordConfiguration { + val isColdRecordingEnabled: Boolean + val isCoolRecordingEnabled: Boolean + val isWarmRecordingEnabled: Boolean + val isSizeRecordingEnabled: Boolean + val renderConfiguration: Provider +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/RenderConfiguration.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/RenderConfiguration.kt new file mode 100644 index 000000000..97dda2984 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/RenderConfiguration.kt @@ -0,0 +1,26 @@ +package com.yandex.div.histogram + +import com.yandex.div.core.annotations.PublicApi + +/** + * Holds configuration for rendering histograms. + */ +@PublicApi +class RenderConfiguration @JvmOverloads constructor( + /** + * [HistogramFilter] for measure histogram. + */ + val measureFilter: HistogramFilter = HistogramFilter.OFF, + /** + * [HistogramFilter] for layout histogram. + */ + val layoutFilter: HistogramFilter = HistogramFilter.OFF, + /** + * [HistogramFilter] for draw histogram. + */ + val drawFilter: HistogramFilter = HistogramFilter.OFF, + /** + * [HistogramFilter] for total rendering histogram. + */ + val totalFilter: HistogramFilter = HistogramFilter.ON +) diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/TaskExecutor.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/TaskExecutor.kt new file mode 100644 index 000000000..6cb0b32e8 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/TaskExecutor.kt @@ -0,0 +1,35 @@ + +package com.yandex.div.histogram + +import android.os.Handler +import android.os.Looper +import androidx.annotation.AnyThread +import com.yandex.div.core.annotations.PublicApi + +/** + * Executor for histograms reporting. + */ +@PublicApi +interface TaskExecutor { + /** + * Posts given [task] for execution. + */ + @AnyThread + fun post(task: () -> Unit) +} + +/** + * Implementation of [TaskExecutor] that delegates execution to main thread [Handler]. + */ +class DefaultTaskExecutor : TaskExecutor { + + private val handler = Handler(Looper.getMainLooper()) + + override fun post(task: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + task() + } else { + handler.post(task) + } + } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/metrics/RenderMetrics.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/metrics/RenderMetrics.kt new file mode 100644 index 000000000..28a4951a5 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/metrics/RenderMetrics.kt @@ -0,0 +1,48 @@ +package com.yandex.div.histogram.metrics + +import kotlin.math.max + +internal class RenderMetrics { + + var bindingMs: Long = 0 + private set + var rebindingMs: Long = 0 + private set + var measureMs: Long = 0 + private set + var layoutMs: Long = 0 + private set + var drawMs: Long = 0 + private set + + val totalMs: Long + get() = max(bindingMs, rebindingMs) + measureMs + layoutMs + drawMs + + fun binding(duration: Long) { + bindingMs = duration + } + + fun rebinding(duration: Long) { + rebindingMs = duration + } + + fun addMeasure(duration: Long) { + measureMs += duration + } + + fun addLayout(duration: Long) { + layoutMs += duration + } + + fun addDraw(duration: Long) { + drawMs += duration + } + + fun reset() { + measureMs = 0 + layoutMs = 0 + drawMs = 0 + bindingMs = 0 + rebindingMs = 0 + } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/reporter/HistogramReporter.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/reporter/HistogramReporter.kt new file mode 100644 index 000000000..50a976e58 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/reporter/HistogramReporter.kt @@ -0,0 +1,63 @@ +package com.yandex.div.histogram.reporter + +import androidx.annotation.AnyThread +import com.yandex.div.core.annotations.Mockable +import com.yandex.div.histogram.HistogramCallType +import com.yandex.div.histogram.HistogramFilter + +/** + * Reporter of Div histograms. + */ +@AnyThread +@Mockable +class HistogramReporter( + private val histogramReporterDelegate: HistogramReporterDelegate +) { + + /** + * Reports [duration] for the given [histogramName], [componentName] if provided and + * [forceCallType] if provided. + * + * @param filter [HistogramFilter] to filter duration histogram reporting. + */ + fun reportDuration( + histogramName: String, + duration: Long, + componentName: String? = null, + @HistogramCallType forceCallType: String? = null, + filter: HistogramFilter = HistogramFilter.ON + ) { + withComponentHistogram(histogramName, componentName) { histogram, component -> + if (filter.report(component)) { + histogramReporterDelegate.reportDuration(histogram, duration, forceCallType) + } + } + } + + /** + * Reports [size] for the given [histogramName] and [componentName] if provided. + * + * @param filter [HistogramFilter] to filter size histogram reporting. + */ + fun reportSize( + histogramName: String, + size: Int, + componentName: String? = null, + filter: HistogramFilter = HistogramFilter.ON + ) { + withComponentHistogram(histogramName, componentName) { histogram, component -> + if (filter.report(component)) { + histogramReporterDelegate.reportSize(histogram, size) + } + } + } +} + +private inline fun withComponentHistogram( + baseHistogramName: String, + componentName: String?, + block: (histogram: String, component: String?) -> Unit +) { + block(baseHistogramName, null) + componentName?.let { block("$it.$baseHistogramName", it) } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/reporter/HistogramReporterDelegate.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/reporter/HistogramReporterDelegate.kt new file mode 100644 index 000000000..dd97b69cb --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/reporter/HistogramReporterDelegate.kt @@ -0,0 +1,31 @@ +package com.yandex.div.histogram.reporter + +import com.yandex.div.histogram.HistogramCallType + +/** + * Delegate to report histograms. + */ +interface HistogramReporterDelegate { + + /** + * Reports [duration] for the given [histogramName] and [forceCallType]. + */ + fun reportDuration(histogramName: String, + duration: Long, + @HistogramCallType forceCallType: String? = null) + + /** + * Reports [size] for the given [histogramName]. + */ + fun reportSize(histogramName: String, size: Int) + + /** + * No op implementation of [HistogramReporterDelegate]. + */ + object NoOp : HistogramReporterDelegate { + override fun reportDuration(histogramName: String, + duration: Long, + @HistogramCallType forceCallType: String?) = Unit + override fun reportSize(histogramName: String, size: Int) = Unit + } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/reporter/HistogramReporterDelegateImpl.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/reporter/HistogramReporterDelegateImpl.kt new file mode 100644 index 000000000..f580a2373 --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/reporter/HistogramReporterDelegateImpl.kt @@ -0,0 +1,50 @@ +package com.yandex.div.histogram.reporter + +import com.yandex.div.core.histogram.HistogramRecorder +import com.yandex.div.histogram.HistogramCallType +import com.yandex.div.histogram.HistogramCallTypeProvider +import com.yandex.div.histogram.HistogramRecordConfiguration +import com.yandex.div.histogram.TaskExecutor +import com.yandex.div.histogram.util.HistogramUtils +import com.yandex.div.histogram.util.MIN_DURATION_HISTOGRAM_VALUE +import com.yandex.div.histogram.util.MIN_SIZE_HISTOGRAM_VALUE +import java.util.concurrent.TimeUnit +import javax.inject.Provider + +/** + * Delegate to record histograms via [histogramRecorder]. + * Adds appropriate suffix according to a call type. + */ +class HistogramReporterDelegateImpl( + private val histogramRecorder: Provider, + private val histogramCallTypeProvider: HistogramCallTypeProvider, + private val histogramRecordConfig: HistogramRecordConfiguration, + private val taskExecutor: Provider +) : HistogramReporterDelegate { + + override fun reportDuration(histogramName: String, + duration: Long, + @HistogramCallType forceCallType: String?) { + val providedCallType = histogramCallTypeProvider.getHistogramCallType(histogramName) + val callType = forceCallType ?: providedCallType + if (!HistogramUtils.shouldRecordHistogram(callType, histogramRecordConfig)) { + return + } + taskExecutor.get().post { + histogramRecorder.get().recordShortTimeHistogram( + "$histogramName.$callType", + duration.coerceAtLeast(MIN_DURATION_HISTOGRAM_VALUE), + TimeUnit.MILLISECONDS + ) + } + } + + override fun reportSize(histogramName: String, size: Int) { + taskExecutor.get().post { + histogramRecorder.get().recordCount100KHistogram( + "$histogramName.Size", + size.coerceAtLeast(MIN_SIZE_HISTOGRAM_VALUE) + ) + } + } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/util/Consts.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/util/Consts.kt new file mode 100644 index 000000000..2162e612f --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/util/Consts.kt @@ -0,0 +1,4 @@ +package com.yandex.div.histogram.util + +internal const val MIN_DURATION_HISTOGRAM_VALUE: Long = 1 +internal const val MIN_SIZE_HISTOGRAM_VALUE: Int = 1 diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/util/HistogramUtils.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/util/HistogramUtils.kt new file mode 100644 index 000000000..3e53f030b --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/util/HistogramUtils.kt @@ -0,0 +1,73 @@ +package com.yandex.div.histogram.util + +import com.yandex.div.core.util.KAssert +import com.yandex.div.histogram.HistogramCallType +import com.yandex.div.histogram.HistogramRecordConfiguration +import org.json.JSONObject + +object HistogramUtils { + + /** + * Calculates the size in bytes of string encoded to utf-8. + * @see https://www.rfc-editor.org/rfc/rfc3629#section-3 + */ + fun calculateUtf8StringByteSize(str: String): Int { + var byteSize = 0 + for (ch in str) { + byteSize += getUtf8CharByteSize(ch) + } + return byteSize + } + + /** + * Gets the number of bytes that character takes when encoded to utf-8. + * @see https://www.rfc-editor.org/rfc/rfc3629#section-3 + */ + fun getUtf8CharByteSize(ch: Char): Int { + return when { + /** + * Characters whose code points are greater than U+FFFF are called + * supplementary characters. + * The Java platform uses the UTF-16 representation in char arrays and in + * the String and StringBuffer classes. In this representation, + * supplementary characters are represented as a pair of char values, + * the first from the high-surrogates range, (\uD800-\uDBFF), + * the second from the low-surrogates range (\uDC00-\uDFFF). + */ + ch.isHighSurrogate() -> 4 + ch.isLowSurrogate() -> 0 + ch.code < 0x80 -> 1 + ch.code < 0x800 -> 2 + ch.code < 0x10000 -> 3 + else -> { + KAssert.fail { "Unsupported character: '$ch'" } + 4 + } + } + } + + /** + * Determines whether histogram should be recorded. + */ + fun shouldRecordHistogram( + @HistogramCallType callType: String, + configuration: HistogramRecordConfiguration + ): Boolean { + return when (callType) { + HistogramCallType.CALL_TYPE_COLD -> configuration.isColdRecordingEnabled + HistogramCallType.CALL_TYPE_COOL -> configuration.isCoolRecordingEnabled + HistogramCallType.CALL_TYPE_WARM -> configuration.isWarmRecordingEnabled + else -> { + KAssert.fail { "Unknown histogram call type: $callType" } + false + } + } + } + + /** + * Calculates the size in bytes of json encoded to utf-8. + */ + fun calculateUtf8JsonByteSize(json: JSONObject): Int { + return JSONUtf8BytesCalculator.calculateUtf8JsonBytes(json) + } +} diff --git a/client/android/div-histogram/src/main/java/com/yandex/div/histogram/util/JSONUtf8BytesCalculator.kt b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/util/JSONUtf8BytesCalculator.kt new file mode 100644 index 000000000..5182fcecc --- /dev/null +++ b/client/android/div-histogram/src/main/java/com/yandex/div/histogram/util/JSONUtf8BytesCalculator.kt @@ -0,0 +1,138 @@ +package com.yandex.div.histogram.util + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Util class for calculating the byte size of [JSONObject] without serializing it to string. + */ +internal class JSONUtf8BytesCalculator { + + private var bytesSize = 0 + + /** + * Adds the size of array brackets. + */ + private fun array(): JSONUtf8BytesCalculator { + bytesSize += ARRAY_BRACKETS_BYTES + return this + } + + /** + * Adds the size of object brackets. + */ + private fun `object`(): JSONUtf8BytesCalculator { + bytesSize += OBJECT_BRACES_BYTES + return this + } + + /** + * Adds size of value according to value type. + */ + private fun value(value: Any?): JSONUtf8BytesCalculator { + if (value is JSONArray) { + calculateUtf8JsonArrayBytes(value, this) + return this + } else if (value is JSONObject) { + calculateUtf8JsonBytes(value, this) + return this + } + if (value == null || value === JSONObject.NULL) { + bytesSize += NULL_BYTES + } else if (value is Boolean) { + bytesSize += if (value) TRUE_BYTES else FALSE_BYTES + } else if (value is Number) { + bytesSize += HistogramUtils.calculateUtf8StringByteSize(JSONObject.numberToString(value)) + } else { + string(value.toString()) + } + return this + } + + /** + * Adds the size of string literal. + */ + private fun string(value: String) { + bytesSize += QUOTES_BYTES + for (ch in value) { + /* + * From RFC 4627, "All Unicode characters may be placed within the + * quotation marks except for the characters that must be escaped: + * quotation mark, reverse solidus, and the control characters + * (U+0000 through U+001F)." + */ + bytesSize += when (ch) { + '"', '\\', '/', '\t', '\b', '\n', '\r' -> ESCAPED_CHARACTERS_BYTES + else -> if (ch.code <= 0x1F) { + HistogramUtils.calculateUtf8StringByteSize(String.format("\\u%04x", ch.code)) + } else { + HistogramUtils.getUtf8CharByteSize(ch) + } + } + } + } + + /** + * Adds the size of key. + */ + private fun key(name: String): JSONUtf8BytesCalculator { + string(name) + return this + } + + /** + * Adds the size of key-value separator. + */ + private fun keyValueSeparator(): JSONUtf8BytesCalculator { + bytesSize += KEY_VALUE_SEPARATOR_BYTES + return this + } + + /** + * Adds the size of entries separators. + */ + private fun entriesSeparator(entries: Int): JSONUtf8BytesCalculator { + if (entries <= 1) { + return this + } + bytesSize += ENTRIES_SEPARATOR_BYTES * (entries - 1) + return this + } + + internal companion object { + + private const val OBJECT_BRACES_BYTES = 2 // {} + private const val ARRAY_BRACKETS_BYTES = 2 // [] + private const val QUOTES_BYTES = 2 // "" + private const val ENTRIES_SEPARATOR_BYTES = 1 // , + private const val KEY_VALUE_SEPARATOR_BYTES = 1 // : + private const val NULL_BYTES = 4 // null + private const val ESCAPED_CHARACTERS_BYTES = 2 // '"', '\\', '/', '\t', '\b', '\n', '\r' + private const val TRUE_BYTES = 4 // true + private const val FALSE_BYTES = 5 // false + + fun calculateUtf8JsonBytes(json: JSONObject): Int { + val jsonUtf8BytesCalculator = JSONUtf8BytesCalculator() + calculateUtf8JsonBytes(json, jsonUtf8BytesCalculator) + return jsonUtf8BytesCalculator.bytesSize; + } + + private fun calculateUtf8JsonBytes(json: JSONObject, + jsonUtf8BytesCalculator: JSONUtf8BytesCalculator + ) { + jsonUtf8BytesCalculator.`object`().entriesSeparator(json.length()) + json.keys().forEach { + jsonUtf8BytesCalculator.key(it).keyValueSeparator().value(json.get(it)) + } + } + + private fun calculateUtf8JsonArrayBytes(json: JSONArray, + jsonUtf8BytesCalculator: JSONUtf8BytesCalculator + ) { + jsonUtf8BytesCalculator.array().entriesSeparator(json.length()) + for (i in 0 until json.length()) { + jsonUtf8BytesCalculator.value(json.get(i)) + } + } + } +} diff --git a/client/android/div-histogram/src/test/java/com/yandex/div/histogram/Div2ViewHistogramReporterTest.kt b/client/android/div-histogram/src/test/java/com/yandex/div/histogram/Div2ViewHistogramReporterTest.kt new file mode 100644 index 000000000..f2da10bce --- /dev/null +++ b/client/android/div-histogram/src/test/java/com/yandex/div/histogram/Div2ViewHistogramReporterTest.kt @@ -0,0 +1,177 @@ +package com.yandex.div.histogram + +import android.os.SystemClock +import com.yandex.div.histogram.reporter.HistogramReporter +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class Div2ViewHistogramReporterTest { + private val renderConfig = RenderConfiguration( + measureFilter = { true }, + layoutFilter = { true }, + drawFilter = { true }, + totalFilter = { true }, + ) + private val histogramReporter = mock() + + private val underTest = Div2ViewHistogramReporter({ histogramReporter }) { renderConfig } + .also { it.component = TEST_COMPONENT } + + @Test + fun `report rendering on binding`() { + verifyReportRendering(isRebinding = false) + } + + @Test + fun `report rendering on rebinding`() { + verifyReportRendering(isRebinding = true) + } + + @Test + fun `accumulate measure durations on report rendering`() { + underTest.onRenderStarted() + + SystemClock.setCurrentTimeMillis(100L) + underTest.onMeasureStarted() + SystemClock.setCurrentTimeMillis(200L) + underTest.onMeasureFinished() + + underTest.onMeasureStarted() + SystemClock.setCurrentTimeMillis(300L) + underTest.onMeasureFinished() + + underTest.onDrawFinished() + + verify(histogramReporter).reportDuration( + DIV_RENDER_MEASURE, + 200L, + TEST_COMPONENT, + filter = renderConfig.measureFilter + ) + } + + @Test + fun `accumulate layout durations on report rendering`() { + underTest.onRenderStarted() + + SystemClock.setCurrentTimeMillis(100L) + underTest.onLayoutStarted() + SystemClock.setCurrentTimeMillis(200L) + underTest.onLayoutFinished() + + underTest.onLayoutStarted() + SystemClock.setCurrentTimeMillis(300L) + underTest.onLayoutFinished() + + underTest.onDrawFinished() + + verify(histogramReporter).reportDuration( + DIV_RENDER_LAYOUT, + 200L, + TEST_COMPONENT, + filter = renderConfig.layoutFilter + ) + } + + @Test + fun `redraw does not cause metrics reporting`() { + verifyReportRendering(isRebinding = false) + clearInvocations(histogramReporter) + + SystemClock.setCurrentTimeMillis(2000L) + underTest.onMeasureStarted() + SystemClock.setCurrentTimeMillis(2100L) + underTest.onMeasureFinished() + + underTest.onLayoutStarted() + SystemClock.setCurrentTimeMillis(2300L) + underTest.onLayoutFinished() + + underTest.onDrawStarted() + SystemClock.setCurrentTimeMillis(2600L) + underTest.onDrawFinished() + + verify(histogramReporter, never()).reportDuration( + any(), + any(), + eq(TEST_COMPONENT), + any(), + any() + ) + } + + + private fun verifyReportRendering(isRebinding: Boolean) { + underTest.onRenderStarted() + + SystemClock.setCurrentTimeMillis(100L) + underTest.run { if (isRebinding) onRebindingStarted() else onBindingStarted() } + SystemClock.setCurrentTimeMillis(200L) + underTest.run { + if (isRebinding) { + onRebindingFinished() + verify(histogramReporter).reportDuration( + DIV_REBINDING_HISTOGRAM, + 100L, + TEST_COMPONENT + ) + } else { + onBindingFinished() + verify(histogramReporter).reportDuration( + DIV_BINDING_HISTOGRAM, + 100L, + TEST_COMPONENT + ) + } + } + + underTest.onMeasureStarted() + SystemClock.setCurrentTimeMillis(400L) + underTest.onMeasureFinished() + + underTest.onLayoutStarted() + SystemClock.setCurrentTimeMillis(700L) + underTest.onLayoutFinished() + + underTest.onDrawStarted() + SystemClock.setCurrentTimeMillis(1100L) + underTest.onDrawFinished() + + verify(histogramReporter).reportDuration( + DIV_RENDER_TOTAL, + 1000L, + TEST_COMPONENT, + filter = renderConfig.totalFilter + ) + verify(histogramReporter).reportDuration( + DIV_RENDER_MEASURE, + 200L, + TEST_COMPONENT, + filter = renderConfig.measureFilter + ) + verify(histogramReporter).reportDuration( + DIV_RENDER_LAYOUT, + 300L, + TEST_COMPONENT, + filter = renderConfig.layoutFilter + ) + verify(histogramReporter).reportDuration( + DIV_RENDER_DRAW, + 400L, + TEST_COMPONENT, + filter = renderConfig.drawFilter + ) + } + + private companion object { + private const val TEST_COMPONENT = "TestComponent" + } +} diff --git a/client/android/div-histogram/src/test/java/com/yandex/div/histogram/DivParsingHistogramReporterImplTest.kt b/client/android/div-histogram/src/test/java/com/yandex/div/histogram/DivParsingHistogramReporterImplTest.kt new file mode 100644 index 000000000..b22a0398f --- /dev/null +++ b/client/android/div-histogram/src/test/java/com/yandex/div/histogram/DivParsingHistogramReporterImplTest.kt @@ -0,0 +1,114 @@ +package com.yandex.div.histogram + +import com.yandex.div.histogram.reporter.HistogramReporter +import com.yandex.div.histogram.util.HistogramUtils +import org.intellij.lang.annotations.Language +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.Executor + +@RunWith(RobolectricTestRunner::class) +class DivParsingHistogramReporterImplTest { + + private val histogramReporter = mock() + private val underTest = + DivParsingHistogramReporterImpl({ histogramReporter }) { Executor(Runnable::run) } + + @Test + fun `report data parsing histograms without component`() { + verifyReportParsing("Div.Parsing.Data", componentName = null) { json, componentName -> + measureDataParsing(json, componentName) {} + } + } + + @Test + fun `report data parsing histograms with component`() { + verifyReportParsing( + "Div.Parsing.Data", + componentName = TEST_COMPONENT + ) { json, componentName -> + measureDataParsing(json, componentName) {} + } + } + + @Test + fun `report templates parsing without component`() { + verifyReportParsing("Div.Parsing.Templates", componentName = null) { json, componentName -> + measureTemplatesParsing(json, componentName) {} + } + } + + @Test + fun `report templates parsing with component`() { + verifyReportParsing( + "Div.Parsing.Templates", + componentName = TEST_COMPONENT + ) { json, componentName -> + measureTemplatesParsing(json, componentName) {} + } + } + + private fun verifyReportParsing( + histogramName: String, + componentName: String? = null, + block: DivParsingHistogramReporter.(JSONObject, String?) -> Unit + ) { + val data = JSONObject(TEST_DIV) + + underTest.block(data, componentName) + + verifyReportDuration(histogramName, componentName) + verifyReportSize( + histogramName, + HistogramUtils.calculateUtf8JsonByteSize(data), + componentName + ) + } + + private fun verifyReportDuration(histogramName: String, componentName: String?) { + verify(histogramReporter).reportDuration( + eq(histogramName), + any(), + eq(componentName), + eq(null), + eq(HistogramFilter.ON) + ) + } + + private fun verifyReportSize( + histogramName: String, + size: Int, + componentName: String?, + ) { + verify(histogramReporter).reportSize( + eq(histogramName), + eq(size), + eq(componentName), + eq(HistogramFilter.ON) + ) + } + + private companion object { + private const val TEST_COMPONENT = "SomeComponent" + + @Language("json") + private val TEST_DIV = """{ + "log_id": "foo", + "states": [ + { + "state_id": 0, + "div": { + "type": "text", + "text": "foo" + } + } + ] + }""".trimIndent() + } +} diff --git a/client/android/div-histogram/src/test/java/com/yandex/div/histogram/reporter/HistogramReporterDelegateImplTest.kt b/client/android/div-histogram/src/test/java/com/yandex/div/histogram/reporter/HistogramReporterDelegateImplTest.kt new file mode 100644 index 000000000..5af35f0bf --- /dev/null +++ b/client/android/div-histogram/src/test/java/com/yandex/div/histogram/reporter/HistogramReporterDelegateImplTest.kt @@ -0,0 +1,63 @@ +package com.yandex.div.histogram.reporter + +import com.yandex.div.core.histogram.HistogramRecorder +import com.yandex.div.histogram.HistogramCallTypeProvider +import com.yandex.div.histogram.HistogramColdTypeChecker +import com.yandex.div.histogram.HistogramRecordConfiguration +import com.yandex.div.histogram.TaskExecutor +import org.junit.Assert +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.security.SecureRandom +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class HistogramReporterDelegateImplTest { + + private val coldTypeChecker = HistogramColdTypeChecker() + private val callTypeProvider = HistogramCallTypeProvider { coldTypeChecker } + private val taskExecutor: TaskExecutor = object : TaskExecutor { + override fun post(task: () -> Unit) = Unit + } + private val histogramRecorder = mock() + private val histogramRecordConfig = mock { + on { isColdRecordingEnabled } doReturn true + on { isCoolRecordingEnabled } doReturn true + on { isWarmRecordingEnabled } doReturn true + } + + private val underTest = HistogramReporterDelegateImpl( + { histogramRecorder }, + callTypeProvider, + histogramRecordConfig, + { taskExecutor } + ) + + private val latch = CountDownLatch(TEST_NUMBER) + + @Test + fun `thread safe report duration`() { + repeat(TEST_NUMBER) { + Thread { + underTest.reportDuration(randomHistogramName(), 100L) + latch.countDown() + }.start() + } + Assert.assertTrue( + "Failed to report all durations. Left ${latch.count}", + latch.await(1, TimeUnit.MINUTES) + ) + } + + private fun randomHistogramName(): String { + val random = SecureRandom() + val bytes = ByteArray(20) + random.nextBytes(bytes) + return String(bytes) + } + + private companion object { + const val TEST_NUMBER = 10000 + } +} diff --git a/client/android/div-histogram/src/test/java/com/yandex/div/histogram/reporter/HistogramReporterTest.kt b/client/android/div-histogram/src/test/java/com/yandex/div/histogram/reporter/HistogramReporterTest.kt new file mode 100644 index 000000000..c98fe06ab --- /dev/null +++ b/client/android/div-histogram/src/test/java/com/yandex/div/histogram/reporter/HistogramReporterTest.kt @@ -0,0 +1,123 @@ +package com.yandex.div.histogram.reporter + +import com.yandex.div.core.histogram.HistogramRecorder +import com.yandex.div.histogram.HistogramCallTypeProvider +import com.yandex.div.histogram.HistogramColdTypeChecker +import com.yandex.div.histogram.HistogramRecordConfiguration +import com.yandex.div.histogram.TaskExecutor +import org.junit.Test +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import java.util.concurrent.TimeUnit + +class HistogramReporterTest { + + private val histogramRecorder = mock() + private val coldTypeChecker = HistogramColdTypeChecker() + private val histogramCallTypeProvider = HistogramCallTypeProvider { coldTypeChecker } + private val histogramRecordConfig = mock { + on { isColdRecordingEnabled } doReturn true + on { isCoolRecordingEnabled } doReturn true + on { isWarmRecordingEnabled } doReturn true + } + private val underTest = HistogramReporter( + HistogramReporterDelegateImpl( + { histogramRecorder }, + histogramCallTypeProvider, + histogramRecordConfig, + ) { ImmediateTaskExecutor() } + ) + + @Test + fun `report duration histogram without component`() { + underTest.reportDuration(TEST_HISTOGRAM, 100) + + verify(histogramRecorder).recordShortTimeHistogram( + "$TEST_HISTOGRAM.Cold", + 100, + TimeUnit.MILLISECONDS + ) + } + + @Test + fun `report duration histogram with component`() { + underTest.reportDuration(TEST_HISTOGRAM, 100, TEST_COMPONENT) + + verify(histogramRecorder).recordShortTimeHistogram( + "$TEST_HISTOGRAM.Cold", + 100, + TimeUnit.MILLISECONDS + ) + verify(histogramRecorder).recordShortTimeHistogram( + "$TEST_COMPONENT.$TEST_HISTOGRAM.Cold", + 100, + TimeUnit.MILLISECONDS + ) + } + + @Test + fun `report size histogram without component`() { + underTest.reportSize(TEST_HISTOGRAM, 100) + + verify(histogramRecorder).recordCount100KHistogram("$TEST_HISTOGRAM.Size", 100) + } + + @Test + fun `report size histogram with component`() { + underTest.reportSize(TEST_HISTOGRAM, 100, TEST_COMPONENT) + + verify(histogramRecorder).recordCount100KHistogram( + "$TEST_COMPONENT.$TEST_HISTOGRAM.Size", + 100 + ) + } + + @Test + fun `report warm histogram without component and cold with component`() { + underTest.reportDuration(TEST_HISTOGRAM, 100) + + clearInvocations(histogramRecorder) + + underTest.reportDuration(TEST_HISTOGRAM, 100, TEST_COMPONENT) + + verify(histogramRecorder).recordShortTimeHistogram( + "$TEST_HISTOGRAM.Warm", 100, + TimeUnit.MILLISECONDS + ) + verify(histogramRecorder).recordShortTimeHistogram( + "$TEST_COMPONENT.$TEST_HISTOGRAM.Cold", + 100, + TimeUnit.MILLISECONDS + ) + } + + @Test + fun `do not report general but report component`() { + underTest.reportDuration(TEST_HISTOGRAM, 100, TEST_COMPONENT, + filter = { + when (it) { + null -> false + else -> true + } + } + ) + + verify(histogramRecorder).recordShortTimeHistogram( + "$TEST_COMPONENT.$TEST_HISTOGRAM.Cold", 100, + TimeUnit.MILLISECONDS + ) + verifyNoMoreInteractions(histogramRecorder) + } + + private class ImmediateTaskExecutor : TaskExecutor { + override fun post(task: () -> Unit) = task() + } + + private companion object { + private const val TEST_COMPONENT = "TestComponent" + private const val TEST_HISTOGRAM = "Div.Test" + } +} diff --git a/client/android/div-histogram/src/test/java/com/yandex/div/histogram/util/HistogramUtilsTest.kt b/client/android/div-histogram/src/test/java/com/yandex/div/histogram/util/HistogramUtilsTest.kt new file mode 100644 index 000000000..d369ec294 --- /dev/null +++ b/client/android/div-histogram/src/test/java/com/yandex/div/histogram/util/HistogramUtilsTest.kt @@ -0,0 +1,148 @@ +package com.yandex.div.histogram.util + +import com.yandex.div.histogram.util.HistogramUtils.calculateUtf8JsonByteSize +import com.yandex.div.histogram.util.HistogramUtils.calculateUtf8StringByteSize +import org.json.JSONObject +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class HistogramUtilsTest { + + @Test + fun testCalculateUtf8StringByteSize() { + var str = "" + Assert.assertEquals(str.utf8BytesSize(), calculateUtf8StringByteSize(str)) + + str = '\u0000'.toString() + Assert.assertEquals(str.utf8BytesSize(), calculateUtf8StringByteSize(str)) + + str = '\u0080'.toString() + Assert.assertEquals(str.utf8BytesSize(), calculateUtf8StringByteSize(str)) + + str = '\u0800'.toString() + Assert.assertEquals(str.utf8BytesSize(), calculateUtf8StringByteSize(str)) + + str = '\uFFFF'.toString() + Assert.assertEquals(str.utf8BytesSize(), calculateUtf8StringByteSize(str)) + + str = "${'\uD800'}${'\uDC00'}" + Assert.assertEquals(str.utf8BytesSize(), calculateUtf8StringByteSize(str)) + + str = "मेरा नाम" + Assert.assertEquals(str.utf8BytesSize(), calculateUtf8StringByteSize(str)) + } + + @Test + fun testCalculateUtf8JsonByteSize() { + var json = JSONObject() + Assert.assertEquals(json.toString().utf8BytesSize(), calculateUtf8JsonByteSize(json)) + + json = JSONObject( + """ + { + "key1":[true,false,true], + "key2":null, + "key3":"मेरा नाम ${'$'}dfg $\\", + "key4":true, + "key5":[1,2,3,4,5], + "key6":{ + "key1":[true,false,true], + "key2":null, + "key3":"मेरा नाम", + "key4":true, + "key5":[1,2,3,4,5] + } + } + """ + ) + Assert.assertEquals(json.toString().utf8BytesSize(), calculateUtf8JsonByteSize(json)) + + json = JSONObject(REAL_DIV_JSON) + Assert.assertEquals(json.toString().utf8BytesSize(), calculateUtf8JsonByteSize(json)) + } + + private fun String.utf8BytesSize() = this.toByteArray(Charsets.UTF_8).size + + private companion object { + private const val REAL_DIV_JSON = """ + { + "log_id" : "header", + "states" : [ + { + "div" : { + "background" : [ + { + "color" : "#FFF", + "type" : "solid" + } + ], + "content_alignment_vertical" : "center", + "items" : [ + { + "custom_type" : "bender_profile_button", + "height" : { + "type" : "fixed", + "value" : 50 + }, + "type" : "custom", + "width" : { + "type" : "fixed", + "value" : 64 + } + }, + { + "alignment_horizontal" : "center", + "height" : { + "type" : "fixed", + "value" : 52 + }, + "image_url" : "https://yastatic.net/s3/home/div/bottomsheet/bender/ya.2.png", + "preview" : "iVBORw0KGgoAAAANSUhEUgAAAIQAAACECAMAAABmmnOVAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAE+UExURQAAAP9AEP9AIPdAGPdAIP84IP9AGP9AIPpAG/pAIPtAHP89Hfw9HfxAHfw+HvpAG/pAHfw9G/0+Hvo9G/o9Hvo+HvpAG/pAHv0+HP0+Hvs+HPs+HvtAHPtAHv0+Hfs+HPtAHP0+Hvs+HPw/Hf0/Hfo+HfpAHfw/Hf0/Hfo+Hfw/Hfs+Hfw/Hfs+Hfw/HPw/Hfw/Hf0/HP0/Hvw/Hv0/HP0/Hvw/Hfw/Hfw/Hfw/Hvw/Hfw/Hvw/Hfw/Hfw/HvxAHvxLK/xXOfxXOvxYOvxjR/xjSPxvVvxvV/yHcv1jR/1vVf17Y/17ZP18ZP2Hcf2Hcv2TgP2fjv2rnP6TgP6fjf6fjv6rnP63qf63qv7CuP7DuP7Duf7Oxv7Pxv7Px/7a0/7b1P7b1f7n4//b1P/m4v/n4v/n4//z8f/+/v///500LPsAAAA9dFJOUwAQECAgICAgMDBAT1BQX2BgYG9wcHBwcH9/gICAgI6Pj4+Qnp6fn5+foK6vr7C+vr/Ozs/Pz97f7u7v7/6U5WZQAAAFRUlEQVR42sWba1fbRhCG17ITEYjBMfSSlsSEQuoKN7ZbunJA21ghoQm9YNoUXFqnCU1s9P//QI0wx6yuM7uz+P3IWeTnzDuavY0YU5Ndrq43Wq4rQrluq7FeLc+xG5NdrW+LFG3XF82TFJbqOyJHbmPxtkGCaksA1TLEMQ8muFSjTB6E+zsCLU4aDiWEMD3qt2eNEIoGQwvhIhor+unoCm3xRT0nGoJEOp6UdwSRlINReCQI9bCgwlB0Bam4giWLO4JYHbQlnwt6efdxDF8LI3qIScmWMCQHnJ6FbWFM7cLsGaAUZhmAFA1hWM18hkfCuL6aRX3A1osK+old/+V+sjIoMmtnEVOr/VfHp2ejIE3nWRU8Yx4pgOes7qt++s/nQwhe0E7K/VyCPIj0Ag5MiP1BAFE2hFfWWUD4MIQ8CNFJNuQbCEMvCGgghKNqRncQkEF48wkQADP8YUAHIbhSqUQx5EOILxWysotiAEDEchOQlf3kHzt7//bv/qWQENFQFJXei9Hb3uvudMQzLEQkFPmB8GNFctjrykPQEHIoirtoM0Z/xobgIaRQ5NcIPxoGXxBASKFwsYFIYlCB6EwZ7uaPHuUzqEBcK5v5a9vf5EAcCiKI6aq3iK0RQ0EG4RXgU9cQEAg1iOUJRAv5bowEHcSVHwA3fpEef0oJMfGjgi3ZR6QQ96D7vt+lx/9MCSE2oKsZ+eU4IIUI69UcYOCpQQjvFnBtOTAJsQxcZJuMRJgUPwhsThzSQrTHEAIN0aOF8GB5KY6kx/eJIW5BpvHoJPofMcQCW4GMeyHP5F1aiGW2DjqOCEzNoheqAQ/r3kvPH9BCbDHYCbI8eaRUClWIJoOdEB0EgFCoQnAghDgDhEIdAjjwyMySf1KtoMd1kV3gu6TXVB5CDxENRWLZNA4RO53ozwAi+oKMHfFvHkIcB3mbYmUIxO3nu9ghSY8EgmMgEg7O+nskEJiLP/9DjGL4qz5EE3fblEARHD3ThdiCTeWZFMM9TYgabFGTTRH0ftSBGC9q7gokxb8JFP3nOhALrCSwOk6gGOhAzDGGhhC98xjEoQ7EeN/xPZ4ilhj/6OREG3rXkr3wDXwdiItt4IoCRGRef6PzioaXpPjMFHvyr33QqhNe2MC5izZjmGiGIgSHX8BlrP/f6JXtrRBiCcnwU4oZahCTe3NLz4wDTQgbepiaflYRnGhO5VeH259gGCJH7R99TYirLgaMH9HV1aHuyspmDO1HhhlKEN9OOz9JzFCCWJhe/exSmKECcf2q+gs1M/7S3ndcb66xdlXMGGlvflybYUMRNeMP7W1gXbohBoWin1qvVSFs+bIcEIpekDJ5KkPUI10D+aF4PkqbPFUhXDvaQJEbikGeGWiIT+OtJFzXDCwET2qYx6zogmOhDZHYgOdk/Ufk7fzY1YaoJ/ZZWU/hx2bJp9sYCG6ntO6DzTgR2hCp3ZA1qBm+NsRaaheixXXMwEBwK70hs5SYFv65rJO0R8vDMhg6Nro99sWBrG7qUacklYS41Kq4AT3Ia1d+bJ5hLb9xu2mawQF0sFvfmWVoW2zmFDAGsxRQhjGFY4rhCZghq4Dr6THuo59Vjx7Be8CQqjylZuhU8B+ClTgtA7dVPomzaoSWeGsWU1OFLBgdjQ+KS5skwfAci+mowvUx+Lz2t7OfaXrSWaX4iFjLk86qxWhU2lQ0hQ4hxFDIDY8W4XJX4ngIDs97Ms9MqFRpwjjGBFWLGVOp4vBsEM/rbCwZJJjoTmWz7XlxlIu/tTeqNrsx3SlXa06Tcy8U502nVi2r/v7/Oq93SChTFkEAAAAASUVORK5CYII=", + "type" : "image", + "width" : { + "type" : "fixed", + "value" : 52 + } + }, + { + "action" : { + "log_id" : "open_alice", + "url" : "dialog://?type=alice" + }, + "alignment_horizontal" : "right", + "height" : { + "type" : "fixed", + "value" : 32 + }, + "image_url" : "https://yastatic.net/s3/home/div/bottomsheet/bender/alice.2.png", + "preview" : "iVBORw0KGgoAAAANSUhEUgAAAFQAAABUCAYAAAAcaxDBAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAABO5SURBVHja3Z1djF3XVcd/a09sJ2ptjwMSpFPwNWmcMS9xqOqWJsjjxDygCiXzUKkP2JPAQ1URU4cXhCI044eU9gUnLSgUHjohSDyOQZRIKCQT3lIV2RVIseK0uansCpAgU7tV45ncs3g4+2Ptffa5tuMZ+4o7subeOZ93nfXxX/+11rYwAa+TszpgxFwjDFTZCwwQBk27ea+2v0UVENYU1lQZqrAGfA84p8rwry/Iudv9XeS2CHCg09t2sNAocwpzquxGEAW88AjvVVEEacx7VTTu6z/7fdZEWFXlzMjx+vJ5Gf6/FejJgU7v2MFC0/C4CHMKNEFAfp+mFGbxGQFVUFCvrWKP8edQFLQV9KoTlkfrvL48vDXC3XKB/tEndG7KsTCCxwWmNQmlI7zib6p4rfUaibT3W26LD8WcO5wjHNU0LDPFqa3W2i0T6DOzOseIRYS5IMSOFiqqSUhRUFFoXtMotNkIX7OHk7YnLc3PiSrLsoWC3XSBLs3qYNRwWuHximZRalWpmQ2okG8zgk+a6bWv8r7PfbT7pIe7LOuc2mxX4DZVmPt1URvOCjwmgBNwgnhpBquM/8rP0t6QUGyzN1ru5z+LSNxXM01RVIK0/W+/7QndzjvH9+vixGno0qwOpGEFOKhj/GJmul7rbIDR5BkkaFRTHteDAq43kGlxDu8qhu4qRzZDW29aQ5+9X7881WrlwagxQSta842agqLOec0SxHmtEq/FAlHRwm9/gxr3LTVbMk2N14z7KtqaCGKuk52Lhr3Nds7+zv365duqoV+5T083wslCy0CgiVGj8H/dQFSN+BTvm0oAC5psr63d7fn5TICsWVIDS3/7lpy6pQJdGuj0jm2sNMKc9pmi2b+pmR959KaOS5NQTIBTcixqYJR1GaWbyf/uH7qW1xBAeX1qg/nloaxtuUD/tE0TV5DWX5a40GLBEhdSj9zVz32ROmi5RQ6NxZzhb2kf1eSL0336fcQ/oPA+PHyBc84xf6Pw6oYEenpWBxvKa6oMtLi5oB32C/eaXT8ODUEiaKTUAtu44/tcRvFQiNg0aKWBYAa3DqfWbyxYXXdQOj3Q6Q8aVlAGpACCS09FBMSZIGPtPgsSIXi0+2n4MQEsnF+LIKLZ+SpBJgRF6A9i/j5ViFFQIMGreB1l7wfbWHlioNObLtBmBysCB6XAeJmqpy+ieBwqoKJGaJK+oxeYVIQTFEqK6+XIwSAKMBChxK1WwAmHRrQRcK0VuLTIQpxwcLSNlU0V6J/t10WUuQKWaFU7jPZGTRPzLwk6iE2tIIL2uz7tS0JWSQ+ofWhNfJCSCTVYTHDt/npOjO0nrVQL2/z9zR27X09vikCfb7HZovnCEp+0IP4GrPkkYdsvkvAgaMqiXNKIzKy1KTSx6zJEkqpl2lYzdboPGsI1tLAUTX83mn3y+HXgVLlWEJKGswrTAtr0Ew4ag4nNw4soikTmR8PVmzKwWUyZsikq+JPG7u+hURbFi6CYZVWK4lrSurK/1qCXwtrUFL82LvKP1VDX8JpTdgfLCBrpCo0S8aLSZErWHEkByDivtH9Hg4JpuvZ60pMZOfJsK/O5VutqGt5qYub/yywtu7fWZe1pRuP9aa9Av7FfF11biogRMfonqdy0tk5cgrmKj/jeE4TAgLaCCkIVUH9+LYICKNz7GeThBXj0KfiVQ0b96sSKRQA1N4H1zSSUokZZ8rS0OF6Eg8f369INmfzpWR1sG/EDHZMBjcOWCGKynjwt7aZ6RDdg8ODeQ/DICdh3KL+39y7BK9+A765cM82s4thrJhyVBKJCwKy5dR6s4dM7agLd3rAYtMczNJH7FkWbYMqazMFELGk85IgWJZk/VXxA81JQ9ZqiIHfuhMMn4NcX6hqwZwY+/1X42AH45z+Hn13OFCMKVjpKhkryt96Ucj9v4Zk3LbHprjnhtG7nNDB/TQ19YVYHNLzTWm4USsqCvAlol4wYq7khdxaBUQpK2Tl2z8AX/gJ+8cD1Yb73LsE3j8P/XvT312MJnXs0hb8On9CTooaAajRbxfHI8nlZHetDpWExjx0ZVFLr+2pYFEvm5sEAD/AjTjSQS/fMoE+8dP3CDNr6xb+Bu2e835WqlqhUAoxNClwRMG2WRooNqk16Lk4QHbE4VkNfmNWBa3in44tSbSe9T6Zf9aG1z4aMyPzb7hk49hJMz3w4yuy9S/DNY/A/FyN8SzDK1KUs4dKUlmdgVMXPt2R3hVnDccRqqStg0pIhh3MgjyFp/Ykr4FltdJUEN7IobNJO9sygNyPMqKkvwc993JDIErVWXE/EjlBNY5QvM7YgJMFosrOW0LBQ1dAXfGRvjK9ozE2oqSpGH+qZGgvybV2cVMbIKpjBB+3YhfzemVZDN+P1ozfhL4+h71+pRuYOk9WhHosIr5UIX/HNa6yzL3Cn8eFtHzGH5KmZAC73m9j3BOZGMqcslm3SgFUlYdGQuv7GU5snTGgj/2PPeO2Xjg9tPXiTA/6SgOmQNcnCtLDIcN7d7Eha6oyqLtggk6lvAPeWelMPyH2gqeXQzhAiBPbGb3v4D5BPLWx+XfyT8/Dw8YwfSEoiKduTOkuWwGCqlqpXX+mp1orA45nJf6tl4X/Q9HRgVEsbqW5UmkyWg9dI4F0z8MVXt7Zj5a+Owfe/M5bI7t53l7y2oKXsaMn6App17l4eyprzwegwOctCJTjhQIML0ETESrlflrcXDNCdO+ELL219j9Hnvwp37eylF+N9l/0CHe6g0hNg8/5IN+7giWjy2vYdJbLVAN3ClKX4u1jKzfKa4oXvigaEz57YXL85LvL/5omc2S/vu7znGrNfuD/NytE291cOR4EKzNFDFldq3pYowPpQUslRo/CTf5VfPgSfXOCWvR5agHsPGb6WDruUlz8S6K93n5TsVUABVoYv7dcHFM4qrbO28KisPha9RWM76EwSQOPv7Xdfbf3nrXy9dwm+/hj6sys9EKrx0E+7JeqsbF0W88qExSFTV9nn3IhB9CeSmXQHJkDys9l27aZ3xj2IAJ85ceuFGUz/4YXI6Gd+MER+urypiBRlGFtZkPhZTSlFR9uYc80UD0ZCWCM3SXkRK/TO9sLfOtNTI9L6zAcXuG2vhxbgrp2GHSvclauwRH3NbJIXBoO7CFnZwDl4IKaSEkpFPjCN8avOVjx98LFcmXhGSRr00ydgx87bJ9A7d8HRpxIGznApeeLiJAlYesiVAneL2W+vA3YrSClA+hqrzHYneTnDmTw3HL/z48iBeW7766EnWvO3nAPaZepNdhc00QosVhUoCnmujUEDJ7Cvk1pWqKiqyjfeRSTzUSnKCJ8+wcS8Hj2RaZW4IBhDJ5oML2OpHaleL6aCW/jZgRNfhIsONvzWLNWkpr2EQldg872mhqC2awZm5ydHoL/6KNy1q3BdRiBZvanJinZZz0AMUJrV8tt2TRGmnWm0isHGdUB8p9XF+ZSuUx30bz81QdoZfOlnj1exKFl/QUIEeX3exg1JNXwX8Lawzwf41odm/rLJPztzUmcieXmc12oV4GOHmLjXQwv1uOCKzCjJzAQva96aBJ9xpSZdEosry6CkgcYKWLVJRa9aCWRwFHbOTJ5A79zly9FGGDXIJHkJRTMMapo7slYeRZ0UDV8xghtMGgON5mSCmpw2YNiQHNw/z8S+HnmqoOeCMIy5Y75zprm2mcNTkhGLCuLE8W4VaxpMSlEm7jQSGHAfHsLg6OQK9J4DMTjFAGNxtOnQy6ZYYvxIcErs/gJrLlJ1/h9jyBGX+8qMgLYnvucQE/26cxfccyCvbBYsfhCkWh8bIJbYzpmUNanAmhNlaH1jRu/Xc/RwtNTKJIDsPcrEvw4cNU3DOZrRIqPSCN5TZhWLdsZqRZWhE2EoZd2dvLpnIUMhRCvIcDP68wcmX6DTM2QNw870npi2S6nU7aUPx0451u5wMGxMZMfWnBWcS7VtZ0sJ5qkaECsN8NGZyRfoPQcS96makz+h2c1nStJQbQJLrY+pted7dzTKu541iEV+MaVi2+2mkuE0WxqJPKNT9KMzt2cO/0Y11LXtRoHoTSlooViO7uCERV3R2QpnnZtiNaSPtm0xVDo7ubwJYFXKSyZfmFTAu5O8F9Xm9pbBL2OLrZJqw7tu/rwMBdYoOE5nfakllCX9WP7UaSJJ1q9MviTfv5w4hwj5pFviCFMiRXdMnF4xcHHtuQtyLhT0Vjv8pwXrFcLZpRJAB5f+5NLkC/S/zqeRGtHeJgbo9ttnQD6wTyr8a8w6VXgdySJ9zHgsy00uWbFlZQzrdPGVyRfof75pCu491GXWiSeZqecMVXv8ShQoV1m2verOZDyS4zN1hQ+VHIupAG++CJNs9muX4LvLmUWqhX2xNTLX3GyaGcv8A3es83oU6PxQ1mhYLaKJhNzcJVUXe3Io5oH8k9u4DP/27IT6zivwyrOw9qMsiZFY+i5ad6CTy2ulB3/1a7493BnpnTF9S7HK6fLBYquJnXTV0Hv6/RVYeQR+OkH+9MeX4O+OoW+9ko1CSq0ZIlKURUSvjUPeISx3qhwrA52e2s47wHSt/ye2MTqk0RyTxbq2b8INvfPh/b3z8MAJ+MhtAvw/fAP+YwX+3Q/E1JYzKtsZzfY0l5/aHNskJgTmdfYFDc009x/u028JLJjpD236+yjb7cXwfwDJtnkgfIFfOAS/dLT9ffcWp6eXL8Lb/wIXXoEfvlH0enZ7R7WYoq7O+xffLySJL37lLXmyVofjnz6hh0eO1UwjPeAtuknCDdI3ypIJHTMQ4M/1kZlWqHtmW3Zq+264e/bDCe/KRfjv83D5Elx8A734nXY6JFuiKKSVxZJHY4YtQkqaJv4qXXwjo501spp/vF9fo+GwHTO0plwzGck1uSPIbMSw+3Cw19g5g27bhWzbCdt3odt3Ru4gCuLyJbh6Ga5eaf1iUpx4/Ww+qlw4BrqLbVkXFjquxbs3+x2L+15+1mgnVOaUdMQp53jVjPJ06y8pzw+DC+KM7wlB0ufKYqcuGskriWrYfwf6k0uIXsxImNSrWs5zmkxFtVifKFYqWqzY5KPlee4eiCFN+4frlrMGGBJpY51TNV4je/3227Iqwt9LKuB3h/mlKCcX2m6iv9ixQ0y7ZK2wF1ib7JzamfPM5OaMORe9Wdk0dGdU0ebuUvIeHXAfv3cofaAsf60ySVed9bwqPC2wlk1+FNmDgRfaKYlI1lNv8SvFnJPtRW01ualMktB9uM4I0T4kl9eKLBsfH75zCSoZnNkhScpUNFDNTmFqg+rKOVWBzp+XIcLXbWXQCrA4MPTfY4Zoe1n/+BC0KPTltakq4Q15Y1d1CQ1XHY7NCHK7MkRlfadEkuT1o1aR2p9TSz3rkIyl2r69X8+iPKCV8ZQ4+FULMqnvMvq6ymx8Pq5TmY23ga0MjCUSKQdj0ZbrrC1GWJshaHqifWUscbj4luzrk9n4eXnHPMKPa6sjFO04mmmJFr313R7M5FNdZ+2PbiAoCIzsWmVfgNc+tc1s6R7ydfByd5JpPab30xTk3tN1joyV2biNv3VehihL1nRcvvxa9IG2j9T1rUqTm7AKHQIiDwgydrkLrRUPYzDUvEHYkXXSdVoTsXl9zoEKabSm19SvS6AAn7sgz4vyXG1QKtB4fc1kUvjdgq3proZjWSwtGmRz4pc4O1RZ8SGrHJheqw6DZhaQCa2LQXhZ54j3m39yQZ6/ZgngerORl+/T1xppG/PLxVAbb/blmiMeDZf+s9unb3xpyLjQ9rxVPiHs68/fpMVk4v1wA/6xb60900O/+swFOXI9crrudZve32Ae5Zw1KwtbfHSNAD5bwic3q9Tvbscei1KtIbzzCG3KL5lvzcclsxV6ytbMcqy7b6rOP81zGxtcd2PRDRXUXp7VgTa8BgyqS/faLCZoJ5XVcvJjpBqxNUaMLD3ELrXmtdUyXCOrnYaLaPo4CXKEUUx4DJt1jvzxVizVFoKUOI5ETU2lgdg1EutQmlVBhW6gilmNozuga9dksn44C+hGW22TmwX+ziIB6QS3znpPLmnuuRsV5g0LNAj16gZHRFn1AqSWThq2X53mywwFIRbr5nWXBbYBit6VxrQchbTmp7mL6W9ztyYvrP70Qwjzhk2+AvyXBBYzs6ysR0LJNtFZFSLwEImhCslBzyoLHXI4J1ESxcj1L/rqj33+Dy/IyQ8rk5taMvhzb8mSE05CC/4jDJFua2TNrC2k6WBCTYtVFW3aGQETNbvYr2+KpTM3kHDwmionb0aYN62hNlg1NlilAEVcSqgemNAx/GgYaxQD0bRndYUaHLvWaj3mWuc+2GD+6U1Y1HpT22a+3S5hvmTrTz6njhG26cnVx5mz7QeKD6zYbrnWYPVNpXpgkUUDaw6e+/2bWHN5SwUatHXUsAQsZItb1/xkRcOqmpYWK+xdjYdKVYBCs+2xDZxxjqe/tMn/48KWNXa9PKuDjXaVneOYieSxLFXlfZOitZ2Krq3uXbqL0IaYColtoFvdUE6deDtfwGriBRrL0+1aUEsOjtd8XGCGAt1XRv5eX1gZRe9FGbCmcGbkePFL57dGkLdMoFawUyPmcCyotpxAc41cu+ZnYWylNXsIjbKK48zUVV588kMsoT7RAq1o7WEVHkeZa3xzRSm0jPyoFOlKUrpp2zJXVVjlFgrxtgu0I+D79OCoYSBTHER4QBum1TGgYbcKewo/OfTaN1QYOmGI493191l9cnjr/4ev8vV/YYptjXCnSd4AAAAASUVORK5CYII=", + "type" : "image", + "width" : { + "type" : "fixed", + "value" : 32 + } + } + ], + "orientation" : "overlap", + "paddings" : { + "bottom" : 14, + "left" : 25, + "right" : 25, + "top" : 14 + }, + "type" : "container", + "width" : { + "type" : "match_parent" + } + }, + "state_id" : 0 + } + ], + "type" : "div2" + } + """ + } +} diff --git a/client/android/div-histogram/src/test/resources/robolectric.properties b/client/android/div-histogram/src/test/resources/robolectric.properties new file mode 100644 index 000000000..932b01b9e --- /dev/null +++ b/client/android/div-histogram/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/client/android/div-json/build.gradle b/client/android/div-json/build.gradle new file mode 100644 index 000000000..2c33d43cc --- /dev/null +++ b/client/android/div-json/build.gradle @@ -0,0 +1,12 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +dependencies { + implementation project(path: ':assertion') + implementation project(path: ':div-evaluable') + implementation project(path: ':logging') + implementation project(path: ':utils') + + api "androidx.core:core:$versions.androidx.core" +} diff --git a/client/android/div-json/jacoco.excludes b/client/android/div-json/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-json/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-json/proguard-rules.pro b/client/android/div-json/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/client/android/div-json/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/client/android/div-json/src/main/AndroidManifest.xml b/client/android/div-json/src/main/AndroidManifest.xml new file mode 100644 index 000000000..dd3890233 --- /dev/null +++ b/client/android/div-json/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/JSONSerializable.java b/client/android/div-json/src/main/java/com/yandex/div/json/JSONSerializable.java new file mode 100644 index 000000000..e0e3c2569 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/JSONSerializable.java @@ -0,0 +1,11 @@ +package com.yandex.div.json; + +import androidx.annotation.NonNull; +import org.json.JSONException; +import org.json.JSONObject; + +public interface JSONSerializable { + + @NonNull + JSONObject writeToJSON() throws JSONException; +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/Json.kt b/client/android/div-json/src/main/java/com/yandex/div/json/Json.kt new file mode 100644 index 000000000..b802fe922 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/Json.kt @@ -0,0 +1,19 @@ +package com.yandex.div.json + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Wrapper class that can hold [JSONObject] or [JSONArray] instance. + */ +sealed class Json { + abstract fun dump(): String +} + +class JsonObject(val value: JSONObject) : Json() { + override fun dump() = value.toString() +} + +class JsonArray(val value: JSONArray) : Json() { + override fun dump() = value.toString() +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/JsonParser.java b/client/android/div-json/src/main/java/com/yandex/div/json/JsonParser.java new file mode 100644 index 000000000..7e66fe45e --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/JsonParser.java @@ -0,0 +1,1123 @@ +package com.yandex.div.json; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.div.json.expressions.ConstantExpressionsList; +import com.yandex.div.json.expressions.Expression; +import com.yandex.div.json.expressions.ExpressionsList; +import com.yandex.div.json.expressions.MutableExpressionsList; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import kotlin.jvm.functions.Function1; +import kotlin.jvm.functions.Function2; +import kotlin.jvm.internal.Intrinsics; +import org.json.JSONArray; +import org.json.JSONObject; + +import static com.yandex.div.json.ParsingExceptionKt.dependencyFailed; +import static com.yandex.div.json.ParsingExceptionKt.invalidValue; +import static com.yandex.div.json.ParsingExceptionKt.missingValue; +import static com.yandex.div.json.ParsingExceptionKt.typeMismatch; +import static com.yandex.div.json.TypeHelpersKt.TYPE_HELPER_STRING; + +/** + * A Java-version of JsonParser.kt that is faster because it generates less garbage during parsing + * and skips unnecessary checks (like nullability). + * + * NOTE! Please do not change Function1 and Function2 with readable java-interfaces. + * This will only make parsing slower. + */ +public class JsonParser { + @NonNull + private static final ValueValidator ALWAYS_VALID = (any) -> true; + @NonNull + private static final ValueValidator IS_STRING = (any) -> true; + @NonNull + private static final ListValidator ALWAYS_VALID_LIST = (any) -> true; + @NonNull + private static final Function1 AS_IS = (any) -> any; + private static final ExpressionsList EMPTY_EXPRESSIONS_LIST = new ConstantExpressionsList<>(Collections.emptyList()); + + @Nullable + public static Expression readOptionalExpression( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readOptionalExpression(jsonObject, key, doNotConvert(), validator, logger, env, typeHelper); + } + + @Nullable + public static Expression readOptionalExpression( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readOptionalExpression(jsonObject, key, converter, alwaysValid(), logger, env, typeHelper); + } + + @NonNull + public static Expression readExpression( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readExpression(jsonObject, key, doNotConvert(), IS_STRING, logger, env, typeHelper); + } + + @NonNull + public static Expression readExpression( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readExpression(jsonObject, key, doNotConvert(), validator, logger, env, typeHelper); + } + + @NonNull + public static Expression readExpression( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readExpression(jsonObject, key, converter, alwaysValid(), logger, env, typeHelper); + } + + @Nullable + public static Expression readOptionalExpression( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readOptionalExpression(jsonObject, key, converter, validator, logger, env, null, typeHelper); + } + + @Nullable + public static Expression readOptionalExpression( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @Nullable final Expression defaultValue, + @NonNull final TypeHelper typeHelper) { + return readOptionalExpression(jsonObject, key, converter, alwaysValid(), logger, env, defaultValue, typeHelper); + + } + + @Nullable + public static Expression readOptionalExpression( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @Nullable final Expression defaultValue, + @NonNull final TypeHelper typeHelper) { + final Object intermediate = optSafe(jsonObject, key); + + if (intermediate == null) { + return null; + } + + if (Expression.mayBeExpression(intermediate)) { + return new Expression.MutableExpression<>(key, intermediate.toString(), converter, validator, logger, + typeHelper, defaultValue); + } else { + T value; + try { + value = converter.invoke((R) intermediate); + } catch (ClassCastException castException) { + logger.logError(typeMismatch(jsonObject, key, intermediate)); + return null; + } catch (Exception e) { + logger.logError(invalidValue(jsonObject, key, intermediate, e)); + return null; + } + + if (value == null) { + logger.logError(invalidValue(jsonObject, key, intermediate)); + return null; + } + + try { + if (!validator.isValid(value)) { + logger.logError(invalidValue(jsonObject, key, intermediate)); + return null; + } + } catch (ClassCastException castException) { + logger.logError(typeMismatch(jsonObject, key, intermediate)); + return null; + } + return Expression.constant(value); + } + } + + @NonNull + public static Expression readExpression( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + final Object intermediate = optSafe(jsonObject, key); + if (intermediate == null) { + throw missingValue(jsonObject, key); + } + + if (Expression.mayBeExpression(intermediate)) { + return new Expression.MutableExpression<>(key, intermediate.toString(), converter, validator, logger, + typeHelper, null); + } else { + T value; + try { + value = converter.invoke((R) intermediate); + } catch (ClassCastException castException) { + throw typeMismatch(jsonObject, key, intermediate); + } catch (Exception e) { + throw invalidValue(jsonObject, key, intermediate, e); + } + if (value == null) { + throw invalidValue(jsonObject, key, intermediate); + } + + try { + if (!validator.isValid(value)) { + throw invalidValue(jsonObject, key, intermediate); + } + } catch (ClassCastException castException) { + throw typeMismatch(jsonObject, key, intermediate); + } + + return Expression.constant(value); + } + } + + @Nullable + public static T readOptional( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return readOptional(jsonObject, key, doNotConvert(), validator, logger, env); + } + + @Nullable + public static T readOptional( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return readOptional(jsonObject, key, doNotConvert(), alwaysValid(), logger, env); + } + + @Nullable + public static T readOptional( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return readOptional(jsonObject, key, converter, alwaysValid(), logger, env); + } + + @Nullable + public static T readOptional( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function2 creator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + JSONObject json = jsonObject.optJSONObject(key); + + if (json == null) { + return null; + } + + try { + return creator.invoke(env, json); + } catch (ParsingException e) { + logger.logError(e); + return null; + } + } + + @Nullable + public static T readOptional( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + final Object intermediate = optSafe(jsonObject, key); + if (intermediate == null) { + return null; + } + + T result = null; + try { + result = converter.invoke((R) intermediate); + } catch (ClassCastException castException) { + logger.logError(typeMismatch(jsonObject, key, intermediate)); + return null; + } catch (Exception e) { + logger.logError(invalidValue(jsonObject, key, intermediate, e)); + return null; + } + + if (result == null) { + logger.logError(ParsingExceptionKt.invalidValue(jsonObject, key, intermediate)); + return null; + } + + try { + if (!validator.isValid(result)) { + logger.logError(invalidValue(jsonObject, key, intermediate)); + return null; + } + } catch (ClassCastException castException) { + logger.logError(typeMismatch(jsonObject, key, intermediate)); + return null; + } + + return result; + } + + @Nullable + public static T readOptional( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function2 converter, + @NonNull final ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + + final JSONObject intermediate = jsonObject.optJSONObject(key); + if (intermediate == null) { + return null; + } + + T result; + try { + result = converter.invoke(env, intermediate); + } catch (ClassCastException castException) { + logger.logError(typeMismatch(jsonObject, key, intermediate)); + return null; + } catch (Exception e) { + logger.logError(invalidValue(jsonObject, key, intermediate, e)); + return null; + } + + if (result == null) { + logger.logError(invalidValue(jsonObject, key, intermediate)); + return null; + } + + try { + if (!validator.isValid(result)) { + logger.logError(invalidValue(jsonObject, key, intermediate)); + return null; + } + } catch (ClassCastException castException) { + logger.logError(typeMismatch(jsonObject, key, intermediate)); + return null; + } + + return result; + } + + @NonNull + public static T read( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return read(jsonObject, key, doNotConvert(), validator, logger, env); + } + + @NonNull + public static T read( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return read(jsonObject, key, doNotConvert(), alwaysValid(), logger, env); + } + + @NonNull + public static T read( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return read(jsonObject, key, converter, alwaysValid(), logger, env); + } + + @NonNull + public static T read( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function2 creator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return read(jsonObject, key, creator, alwaysValid(), logger, env); + } + + @NonNull + public static T read( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function2 creator, + @NonNull ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + JSONObject json = jsonObject.optJSONObject(key); + + if (json == null) { + throw missingValue(jsonObject, key); + } + + T result; + try { + result = creator.invoke(env, json); + } catch (ParsingException e) { + throw dependencyFailed(jsonObject, key, e); + } + + if (result == null) { + throw invalidValue(jsonObject, key, null); + } + + try { + if (!validator.isValid(result)) { + throw invalidValue(jsonObject, key, result); + } + } catch (ClassCastException castException) { + throw typeMismatch(jsonObject, key, result); + } + + return result; + } + + @NonNull + public static T read( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ValueValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + final Object intermediate = optSafe(jsonObject, key); + if (intermediate == null) { + throw missingValue(jsonObject, key); + } + + T result; + try { + result = converter.invoke((R) intermediate); + } catch (ClassCastException castException) { + throw typeMismatch(jsonObject, key, intermediate); + } catch (Exception e) { + throw invalidValue(jsonObject, key, intermediate, e); + } + + if (result == null) { + throw invalidValue(jsonObject, key, intermediate); + } + + try { + if (!validator.isValid(result)) { + throw invalidValue(jsonObject, key, result); + } + } catch (ClassCastException castException) { + throw typeMismatch(jsonObject, key, result); + } + + return result; + } + + @Nullable + public static List readOptionalList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function2 creator, + @NonNull final ListValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return readOptionalList(jsonObject, key, creator, validator, alwaysValid(), logger, env); + } + + @Nullable + public static List readOptionalList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ListValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return readOptionalList(jsonObject, key, converter, validator, alwaysValid(), logger, env); + } + + @Nullable + public static List readOptionalList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ListValidator validator, + @NonNull final ValueValidator itemValidator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + JSONArray optJSONArray = jsonObject.optJSONArray(key); + if (optJSONArray == null) { + return null; + } + + int length = optJSONArray.length(); + if (length == 0) { + return Collections.emptyList(); + } + List list = new ArrayList<>(length); + + for (int i = 0; i < length; i++) { + Object preResult = optJSONArray.opt(i); + final Object intermediate = Intrinsics.areEqual(preResult, JSONObject.NULL) ? null : preResult; + + if (intermediate == null) { + continue; + } + + T item; + try { + item = converter.invoke((R) intermediate); + } catch (ClassCastException castException) { + logger.logError(typeMismatch(optJSONArray, key, i, intermediate)); + continue; + } catch (Exception e) { + logger.logError(invalidValue(optJSONArray, key, i, intermediate, e)); + continue; + } + + if (item == null) { + continue; + } + + try { + if (!itemValidator.isValid(item)) { + logger.logError(invalidValue(optJSONArray, key, i, item)); + continue; + } + } catch (ClassCastException castException) { + logger.logError(typeMismatch(optJSONArray, key, i, item)); + continue; + } + + list.add(item); + } + + try { + if (!validator.isValid(list)) { + logger.logError(invalidValue(jsonObject, key, list)); + return null; + } + } catch (ClassCastException castException) { + logger.logError(typeMismatch(jsonObject, key, list)); + return null; + } + + return list; + } + + @Nullable + public static List readOptionalList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function2 creator, + @NonNull final ListValidator validator, + @NonNull final ValueValidator itemValidator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + JSONArray optJSONArray = jsonObject.optJSONArray(key); + if (optJSONArray == null) { + return null; + } + + int length = optJSONArray.length(); + if (length == 0) { + return Collections.emptyList(); + } + List list = new ArrayList<>(length); + + for (int i = 0; i < length; i++) { + final Object intermediate = optSafe(optJSONArray.optJSONObject(i)); + + if (intermediate == null) { + continue; + } + + T item = creator.invoke(env, (R) intermediate); + + if (item == null) { + continue; + } + + try { + if (!itemValidator.isValid(item)) { + logger.logError(invalidValue(optJSONArray, key, i, item)); + continue; + } + } catch (ClassCastException castException) { + logger.logError(typeMismatch(optJSONArray, key, i, item)); + continue; + } + + list.add(item); + } + + try { + if (!validator.isValid(list)) { + logger.logError(invalidValue(jsonObject, key, list)); + return null; + } + } catch (ClassCastException castException) { + logger.logError(typeMismatch(jsonObject, key, list)); + return null; + } + + return list; + } + + @NonNull + public static List readList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function2 creator, + @NonNull final ListValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return readList(jsonObject, key, creator, validator, alwaysValid(), logger, env); + } + + @NonNull + public static List readList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function2 creator, + @NonNull final ListValidator validator, + @NonNull final ValueValidator itemValidator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + JSONArray optJSONArray = jsonObject.optJSONArray(key); + if (optJSONArray == null) { + throw missingValue(jsonObject, key); + } + + int length = optJSONArray.length(); + if (length == 0) { + return Collections.emptyList(); + } + List list = new ArrayList<>(length); + + for (int i = 0; i < length; i++) { + final JSONObject intermediate = optSafe(optJSONArray.optJSONObject(i)); + + if (intermediate == null) { + continue; + } + + T item; + try { + item = creator.invoke(env, intermediate); + } catch (ClassCastException castException) { + logger.logError(typeMismatch(optJSONArray, key, i, intermediate)); + continue; + } catch (Exception e) { + logger.logError(invalidValue(optJSONArray, key, i, intermediate, e)); + continue; + } + + if (item == null) { + continue; + } + + try { + if (!itemValidator.isValid(item)) { + logger.logError(invalidValue(optJSONArray, key, i, item)); + continue; + } + } catch (ClassCastException castException) { + logger.logError(typeMismatch(optJSONArray, key, i, item)); + continue; + } + + list.add(item); + } + + try { + if (!validator.isValid(list)) { + throw invalidValue(jsonObject, key, list); + } + } catch (ClassCastException castException) { + throw typeMismatch(jsonObject, key, list); + } + + return list; + } + + @NonNull + public static List readList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final ListValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return readList(jsonObject, key, doNotConvert(), validator, IS_STRING, logger, env); + } + + @NonNull + public static List readList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ListValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return readList(jsonObject, key, converter, validator, alwaysValid(), logger, env); + } + + @NonNull + public static List readList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ListValidator validator, + @NonNull final ValueValidator itemValidator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + JSONArray optJSONArray = jsonObject.optJSONArray(key); + if (optJSONArray == null) { + throw missingValue(jsonObject, key); + } + + int length = optJSONArray.length(); + if (length == 0) { + return Collections.emptyList(); + } + List list = new ArrayList<>(length); + + for (int i = 0; i < length; i++) { + final Object intermediate = optSafe(optJSONArray.opt(i)); + + if (intermediate == null) { + continue; + } + + T item; + try { + item = converter.invoke((R) intermediate); + } catch (ClassCastException castException) { + logger.logError(typeMismatch(optJSONArray, key, i, intermediate)); + continue; + } catch (Exception e) { + logger.logError(invalidValue(optJSONArray, key, i, intermediate, e)); + continue; + } + + if (item == null) { + continue; + } + + try { + if (!itemValidator.isValid(item)) { + logger.logError(invalidValue(optJSONArray, key, i, item)); + continue; + } + } catch (ClassCastException castException) { + logger.logError(typeMismatch(optJSONArray, key, i, item)); + continue; + } + + list.add(item); + } + + try { + if (!validator.isValid(list)) { + throw invalidValue(jsonObject, key, list); + } + } catch (ClassCastException castException) { + throw typeMismatch(jsonObject, key, list); + } + + return list; + } + + @NonNull + public static ExpressionsList readExpressionsList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final ListValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readExpressionsList(jsonObject, key, doNotConvert(), validator, IS_STRING, logger, env, typeHelper); + } + + @NonNull + public static ExpressionsList readExpressionsList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final ListValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env) { + return readExpressionsList(jsonObject, key, doNotConvert(), validator, IS_STRING, logger, env, TYPE_HELPER_STRING); + } + + @NonNull + public static ExpressionsList readExpressionsList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ListValidator validator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readExpressionsList(jsonObject, key, converter, validator, alwaysValid(), logger, env, typeHelper); + } + + @Nullable + public static ExpressionsList readOptionalExpressionsList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ListValidator validator, + @NonNull final ValueValidator itemValidator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readExpressionsList( + jsonObject, key, converter, validator, itemValidator, logger, env, typeHelper, ErrorHandler.IGNORE); + } + + @NonNull + public static ExpressionsList readExpressionsList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ListValidator validator, + @NonNull final ValueValidator itemValidator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + ExpressionsList result = readExpressionsList( + jsonObject, key, converter, validator, itemValidator, logger, env, typeHelper, ErrorHandler.FAIL_FAST); + if (result == null) { + throw invalidValue(key, jsonObject); + } + + return result; + } + + @Nullable + private static ExpressionsList readExpressionsList( + @NonNull final JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull final ListValidator validator, + @NonNull final ValueValidator itemValidator, + @NonNull final ParsingErrorLogger logger, + @NonNull final ParsingEnvironment env, + @NonNull final TypeHelper typeHelper, + @NonNull final ErrorHandler errorHandler) { + JSONArray optJSONArray = jsonObject.optJSONArray(key); + if (optJSONArray == null) { + errorHandler.process(missingValue(jsonObject, key)); + return null; + } + + int length = optJSONArray.length(); + if (length == 0) { + return EMPTY_EXPRESSIONS_LIST; + } + + // This list may have content of mixed types (raw values and expressions) intentionally. + // At the end of this method we'll transform it's elements to single type. + List untypedList = new ArrayList(length); + boolean containsExpressions = false; + + for (int i = 0; i < length; i++) { + final Object intermediate = optSafe(optJSONArray.opt(i)); + + if (intermediate == null) { + continue; + } + + if (Expression.mayBeExpression(intermediate)) { + containsExpressions = true; + untypedList.add(new Expression.MutableExpression<>( + /*expressionKey*/ key + "[" + i + "]", + /*rawExpression*/ intermediate.toString(), + /*converter*/ converter, + /*valueValidator*/ itemValidator, + /*logger*/ logger, + /*typeHelper*/ typeHelper, + /*fieldDefaultValue*/ null)); + } else { + T item; + try { + item = converter.invoke((R) intermediate); + } catch (ClassCastException castException) { + logger.logError(typeMismatch(optJSONArray, key, i, intermediate)); + continue; + } catch (Exception e) { + logger.logError(invalidValue(optJSONArray, key, i, intermediate, e)); + continue; + } + + if (item == null) { + continue; + } + + try { + if (!itemValidator.isValid(item)) { + logger.logError(invalidValue(optJSONArray, key, i, item)); + continue; + } + } catch (ClassCastException castException) { + logger.logError(typeMismatch(optJSONArray, key, i, item)); + continue; + } + + untypedList.add(item); + } + } + + if (containsExpressions) { + for (int i = 0; i < untypedList.size(); i++) { + Object item = untypedList.get(i); + if (item instanceof Expression) { + continue; + } + + untypedList.set(i, Expression.constant(item)); + } + + List> list = (List>) untypedList; + return new MutableExpressionsList(key, list, validator, env.getLogger()); + } else { + List list = (List) untypedList; + try { + if (!validator.isValid(list)) { + errorHandler.process(invalidValue(jsonObject, key, list)); + return null; + } + } catch (ClassCastException castException) { + errorHandler.process(typeMismatch(jsonObject, key, list)); + return null; + } + + return new ConstantExpressionsList<>(list); + } + } + + @NonNull + public static List readStrictList( + @NonNull JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function2 creator, + @NonNull ListValidator listValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readStrictList(jsonObject, key, creator, listValidator, alwaysValid(), logger, env); + } + + @NonNull + public static List readStrictList( + @NonNull JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function2 creator, + @NonNull ListValidator listValidator, + @NonNull ValueValidator itemValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + JSONArray optJSONArray = jsonObject.optJSONArray(key); + if (optJSONArray == null) { + throw missingValue(jsonObject, key); + } + + int length = optJSONArray.length(); + if (length == 0) { + return Collections.emptyList(); + } + List list = new ArrayList<>(length); + + for (int i = 0; i < length; i++) { + JSONObject intermediate = optSafe(optJSONArray.optJSONObject(i)); + + if (intermediate == null) { + throw missingValue(optJSONArray, key, i); + } + + T item; + try { + item = creator.invoke(env, intermediate); + } catch (ClassCastException castException) { + throw typeMismatch(optJSONArray, key, i, intermediate); + } catch (Exception e) { + throw invalidValue(optJSONArray, key, i, intermediate, e); + } + + if (item == null) { + throw invalidValue(optJSONArray, key, i, intermediate); + } + + try { + if (!itemValidator.isValid(item)) { + throw invalidValue(optJSONArray, key, i, intermediate); + } + } catch (ClassCastException castException) { + throw typeMismatch(optJSONArray, key, i, item); + } + + list.add(item); + } + + + try { + if (!listValidator.isValid(list)) { + throw invalidValue(jsonObject, key, list); + } + } catch (ClassCastException castException) { + throw typeMismatch(jsonObject, key, list); + } + + return list; + } + + @NonNull + public static List readStrictList( + @NonNull JSONObject jsonObject, + @NonNull final String key, + @NonNull final Function1 converter, + @NonNull ListValidator listValidator, + @NonNull final ValueValidator itemValidator, + @NonNull ParsingErrorLogger logger) { + JSONArray optJSONArray = jsonObject.optJSONArray(key); + if (optJSONArray == null) { + throw missingValue(jsonObject, key); + } + + int length = optJSONArray.length(); + if (length == 0) { + return Collections.emptyList(); + } + List list = new ArrayList<>(length); + + for (int i = 0; i < length; i++) { + JSONObject intermediate = optSafe(optJSONArray.optJSONObject(i)); + + if (intermediate == null) { + throw missingValue(optJSONArray, key, i); + } + + T item; + try { + item = converter.invoke((R) intermediate); + } catch (ClassCastException castException) { + throw typeMismatch(optJSONArray, key, i, intermediate); + } catch (Exception e) { + throw invalidValue(optJSONArray, key, i, intermediate, e); + } + + if (item == null) { + throw invalidValue(optJSONArray, key, i, intermediate); + } + + try { + if (!itemValidator.isValid(item)) { + throw invalidValue(optJSONArray, key, i, item); + } + } catch (ClassCastException castException) { + throw typeMismatch(optJSONArray, key, i, item); + } + + list.add(item); + } + + + try { + if (!listValidator.isValid(list)) { + throw invalidValue(jsonObject, key, list); + } + } catch (ClassCastException castException) { + throw typeMismatch(jsonObject, key, list); + } + + return list; + } + + @NonNull + static ValueValidator alwaysValid() { + //noinspection unchecked + return (ValueValidator) ALWAYS_VALID; + } + + @NonNull + static ListValidator alwaysValidList() { + //noinspection unchecked + return (ListValidator) ALWAYS_VALID_LIST; + } + + @NonNull + static Function1 doNotConvert() { + //noinspection unchecked + return (Function1) AS_IS; + } + + @Nullable + private static T optSafe(@Nullable T json) { + if (json == null || json == JSONObject.NULL) { + return null; + } + + return json; + } + + @Nullable + private static Object optSafe(JSONObject json, String key) { + Object value = json.opt(key); + if (value == null || value == JSONObject.NULL) { + return null; + } + + return value; + } + + private interface ErrorHandler { + void process(ParsingException e); + + ErrorHandler FAIL_FAST = (e) -> { + throw e; + }; + + ErrorHandler IGNORE = (e) -> { /*Do nothing*/ }; + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/JsonParser.kt b/client/android/div-json/src/main/java/com/yandex/div/json/JsonParser.kt new file mode 100644 index 000000000..1f4035aec --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/JsonParser.kt @@ -0,0 +1,372 @@ +@file:Suppress("UNCHECKED_CAST", "NOTHING_TO_INLINE", "UNUSED_PARAMETER") + +package com.yandex.div.json + +import com.yandex.div.core.util.Assert +import com.yandex.div.json.expressions.ConstantExpressionsList +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionResolver +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.expressions.MutableExpressionsList +import com.yandex.div.util.whenNotEmpty +import org.json.JSONArray +import org.json.JSONObject + +fun JSONObject.read( + key: String, + validator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): T { + val value = optSafe(key) ?: throw missingValue(this, key) + val result = value as? T ?: throw typeMismatch(this, key, value) + + return if (validator.isValid(result)) { + result + } else { + throw invalidValue(this, key, result) + } +} + +inline fun JSONObject.read( + key: String, + converter: Converter, + validator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, + ): T { + val value = optSafe(key) ?: throw missingValue(this, key) + val intermediate = value as? R ?: throw typeMismatch(this, key, value) + + val result = converter.tryConvert(intermediate) + ?: throw invalidValue(this, key, intermediate) + + return if (validator.isValid(result)) { + result + } else { + throw invalidValue(this, key, result) + } +} + +fun JSONObject.read( + key: String, + logger: ParsingErrorLogger, +): String { + return optString(key) ?: throw missingValue(this, key) +} + +fun JSONObject.read( + key: String, + creator: Creator, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): T { + val json = optJSONObject(key) ?: throw missingValue(this, key) + val result = try { + creator(env, json) + } catch (e: ParsingException) { + throw dependencyFailed(this, key, e) + } + + return result +} + +fun JSONObject.readOptional( + key: String, + validator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): T? { + val value = optSafe(key) ?: return null + val result = value as? T + if (result == null) { + logger.logError(typeMismatch(this, key, value)) + return null + } + + return if (validator.isValid(result)) { + result + } else { + logger.logError(invalidValue(this, key, result)) + null + } +} + +fun JSONObject.readOptional( + key: String, + converter: Converter, + validator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): T? { + val value = optSafe(key) ?: return null + val intermediate = value as? R + if (intermediate == null) { + logger.logError(typeMismatch(this, key, value)) + return null + } + + val result = converter.tryConvert(intermediate) + if (result == null) { + logger.logError(invalidValue(this, key, intermediate)) + return null + } + + return if (validator.isValid(result)) { + result + } else { + logger.logError(invalidValue(this, key, result)) + null + } +} + +fun JSONObject.readOptional( + key: String, + creator: Creator, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): T? { + val json = optJSONObject(key) ?: return null + return creator.tryCreate(env, json, logger) +} + +fun JSONObject.readList( + key: String, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): List { + return getList(key, validator, logger) { jsonArray, i -> + val item = jsonArray.optSafe(i) as? T ?: return@getList null + item.takeIf { itemValidator.isValid(it) }.onNull { + logger.logError(invalidValue(jsonArray, key, i, item)) + } + } +} + +fun JSONObject.readList( + key: String, + converter: Converter, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): List { + return getList(key, validator, logger) { jsonArray, i -> + val rawItem = jsonArray.optSafe(i) as? R ?: return@getList null + val item = converter.tryConvert(rawItem).onNull { + logger.logError(invalidValue(this, key, rawItem)) + } ?: return@getList null + item.takeIf { itemValidator.isValid(it) }.onNull { + logger.logError(invalidValue(jsonArray, key, i, item)) + } + } +} + +@JvmName("readSerializableList") +fun JSONObject.readList( + key: String, + creator: Creator, + validator: ListValidator, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): List { + return getList(key, validator, logger) { jsonArray, i -> + val json = jsonArray.optJSONObject(i) ?: return@getList null + val item = creator.tryCreate(env, json, logger) ?: return@getList null + item + } +} + +fun JSONObject.readStrictList( + key: String, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger +): List { + return getList(key, validator, logger) { jsonArray, i -> + val rawItem = jsonArray.optSafe(i) ?: throw missingValue(jsonArray, key, i) + val item = rawItem as? T ?: throw typeMismatch(jsonArray, key, i, rawItem) + item.takeIf { itemValidator.isValid(it) } ?: throw invalidValue(jsonArray, key, i, item) + } +} + +fun JSONObject.readStrictList( + key: String, + converter: Converter, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger +): List { + return getList(key, validator, logger) { jsonArray, i -> + val rawItem = jsonArray.optSafe(i) ?: throw missingValue(jsonArray, key, i) + val intermediateItem = rawItem as? R ?: throw typeMismatch(jsonArray, key, i, rawItem) + val item = converter.tryConvert(intermediateItem) ?: throw invalidValue( + jsonArray, + key, + i, + intermediateItem + ) + item.takeIf { itemValidator.isValid(it) } ?: throw invalidValue(jsonArray, key, i, item) + } +} + +@JvmName("readStrictSerializableList") +fun JSONObject.readStrictList( + key: String, + creator: Creator, + validator: ListValidator, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): List { + return getList(key, validator, logger) { jsonArray, i -> + val json = jsonArray.optJSONObject(i) ?: throw missingValue(jsonArray, key, i) + val item = try { + creator(env, json) + } catch (e: ParsingException) { + throw dependencyFailed(jsonArray, key, i, e) + } + item + } +} + +fun JSONObject.readOptionalList( + key: String, + validator: ListValidator, + itemValidator: ValueValidator, + logger: ParsingErrorLogger, +): List? { + return optList(key, validator, logger) { jsonArray, i -> + val item = jsonArray.optSafe(i) as? T ?: return@optList null + item.takeIf { itemValidator.isValid(it) }.onNull { + logger.logError(invalidValue(jsonArray, key, i, item)) + } + } +} + +fun JSONObject.readOptionalList( + key: String, + converter: Converter, + validator: ListValidator, + itemValidator: ValueValidator, + logger: ParsingErrorLogger +): List? { + return optList(key, validator, logger) { jsonArray, i -> + val rawItem = jsonArray.optSafe(i) as? R ?: return@optList null + val item = converter.tryConvert(rawItem).onNull { + logger.logError(invalidValue(this, key, rawItem)) + } ?: return@optList null + item.takeIf { itemValidator.isValid(it) }.onNull { + logger.logError(invalidValue(jsonArray, key, i, item)) + } + } +} + +@JvmName("readOptionalSerializableList") +fun JSONObject.readOptionalList( + key: String, + creator: Creator, + validator: ListValidator, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): List? { + return optList(key, validator, logger) { jsonArray, i -> + val json = jsonArray.optJSONObject(i) ?: return@optList null + val item = creator.tryCreate(env, json, logger) ?: return@optList null + item.takeIf { itemValidator.isValid(it) }.onNull { + logger.logError(invalidValue(jsonArray, key, i, item)) + } + } +} + +fun JSONObject.write(key: String, value: T?, converter: Converter = { it }) { + if (value != null) { + put(key, converter(value)) + } +} + +inline fun JSONObject.write(key: String, value: T?) { + if (value != null) { + put(key, value.writeToJSON()) + } +} + +fun JSONObject.write(key: String, value: List?) { + value.whenNotEmpty { list -> + val item = list.first() + if (item is JSONSerializable) { + put(key, (list as List).toJsonArray()) + } else { + put(key, JSONArray(list)) + } + } +} + +fun JSONObject.write(key: String, value: List?, converter: Converter) { + value.whenNotEmpty { list -> + val item = list.first() + if (item is JSONSerializable) { + put(key, (list as List).toJsonArray()) + } else { + put(key, JSONArray(list.map { converter(it) })) + } + } +} + +fun JSONObject.writeExpression( + key: String, + value: Expression?, +) = this.writeExpression( + key, value, converter = { it } +) + +fun JSONObject.writeExpression( + key: String, + value: Expression?, + converter: Converter, +) { + if (value == null) { + return + } + + val rawValue = value.rawValue + val needsConversion = !Expression.mayBeExpression(rawValue) + + if (needsConversion) { + put(key, converter(rawValue as T)) + } else { + put(key, rawValue) + } +} + +fun JSONObject.writeExpressionsList( + key: String, + value: ExpressionsList?) = writeExpressionsList(key, value, converter = { it }) + +fun JSONObject.writeExpressionsList( + key: String, + value: ExpressionsList?, + converter: Converter, +) { + when (value) { + null -> { + return + } + is MutableExpressionsList -> { + val rawExpressions = value.expressionsList + if (rawExpressions.isEmpty()) { + return + } + + put(key, JSONArray(rawExpressions.map { it.rawValue })) + } + is ConstantExpressionsList -> { + put(key, JSONArray(value.evaluate(ExpressionResolver.EMPTY).map { converter(it) })) + } + else -> { + Assert.fail("Unknown list type: $value") + } + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/JsonParserInternals.kt b/client/android/div-json/src/main/java/com/yandex/div/json/JsonParserInternals.kt new file mode 100644 index 000000000..ed07bb0ff --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/JsonParserInternals.kt @@ -0,0 +1,100 @@ +@file:Suppress("UNCHECKED_CAST", "NOTHING_TO_INLINE") + +package com.yandex.div.json + +import org.json.JSONArray +import org.json.JSONObject + +typealias Creator = (env: ParsingEnvironment, value: T) -> R + +internal typealias ItemReader = (JSONArray, Int) -> T + +@PublishedApi +internal fun Creator.tryCreate( + env: ParsingEnvironment, + arg: T, + logger: ParsingErrorLogger, +): R? { + return try { + this(env, arg) + } catch (e: ParsingException) { + logger.logError(e) + return null + } +} + +@PublishedApi +internal fun JSONObject.optSafe(key: String): Any? { + val result = opt(key) + return if (result == JSONObject.NULL) null else result +} + +@PublishedApi +internal fun JSONArray.optSafe(index: Int): Any? { + val result = opt(index) + return if (result == JSONObject.NULL) null else result +} + +@PublishedApi +internal fun JSONObject.getList( + key: String, + validator: ListValidator, + logger: ParsingErrorLogger, + itemReader: ItemReader +): List { + val jsonArray = optJSONArray(key) ?: throw missingValue(this, key) + val length = jsonArray.length() + val list: MutableList = ArrayList(length) + + for (index in 0 until length) { + val item = itemReader(jsonArray, index) + if (item != null) list.add(item) + } + + return if (validator.isValid(list)) { + list + } else { + throw invalidValue(this, key, list) + } +} + +@PublishedApi +internal fun JSONObject.optList( + key: String, + validator: ListValidator, + logger: ParsingErrorLogger, + itemReader: ItemReader +): List? { + val jsonArray = optJSONArray(key) ?: return null + val length = jsonArray.length() + val list: MutableList = ArrayList(length) + + for (index in 0 until length) { + val item = itemReader(jsonArray, index) + if (item != null) list.add(item) + } + + return if (validator.isValid(list)) { + list + } else { + logger.logError(invalidValue(this, key, list)) + null + } +} + +@PublishedApi +internal fun List.toJsonArray(): JSONArray { + val jsonArray = JSONArray() + forEach { + jsonArray.put(it.writeToJSON()) + } + return jsonArray +} + +@PublishedApi +internal inline fun T.onNull(block: () -> Unit): T? { + if (this == null) { + block() + } + return this +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/JsonPrinter.kt b/client/android/div-json/src/main/java/com/yandex/div/json/JsonPrinter.kt new file mode 100644 index 000000000..9d7c5a8e5 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/JsonPrinter.kt @@ -0,0 +1,67 @@ +package com.yandex.div.json + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * Helper class that prints JSON to string. + * + * @param indentSpaces the number of spaces to indent for each level of nesting. + * @param nestingLimit the limit of nesting for [JSONObject]s and [JSONArray]s. + * These elements will be replaced with "..." string if the nesting level exceeds a limit. + */ +class JsonPrinter( + private val indentSpaces: Int, + private val nestingLimit: Int +) { + + /** + * Presents given [JSONObject] as a string. + */ + @Throws(JSONException::class) + fun print(json: JSONObject): String { + val copy = json.deepCopy(nestingLimit) + return if (indentSpaces == 0) copy.toString() else copy.toString(indentSpaces) + } + + /** + * Presents given [JSONArray] as a string. + */ + @Throws(JSONException::class) + fun print(json: JSONArray): String { + val copy = json.deepCopy(nestingLimit) + return if (indentSpaces == 0) copy.toString() else copy.toString(indentSpaces) + } + + private fun JSONObject.deepCopy(nestingLevel: Int): JSONObject { + val result = JSONObject() + forEachNullable { key: String, item: Any? -> + val element = when (item) { + is JSONObject -> if (nestingLevel == 0) ELLIPSIS else item.deepCopy(nestingLevel - 1) + is JSONArray -> if (nestingLevel == 0) ELLIPSIS else item.deepCopy(nestingLevel - 1) + else -> item + } + result.put(key, element) + } + return result + } + + private fun JSONArray.deepCopy(nestingLevel: Int): JSONArray { + val result = JSONArray() + forEachNullable { _, item: Any? -> + val element = when (item) { + is JSONObject -> if (nestingLevel == 0) ELLIPSIS else item.deepCopy(nestingLevel - 1) + is JSONArray -> if (nestingLevel == 0) ELLIPSIS else item.deepCopy(nestingLevel - 1) + else -> item + } + result.put(element) + } + return result + } + + private companion object { + + private const val ELLIPSIS = "..." + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/JsonTemplate.kt b/client/android/div-json/src/main/java/com/yandex/div/json/JsonTemplate.kt new file mode 100644 index 000000000..06923dd71 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/JsonTemplate.kt @@ -0,0 +1,8 @@ +package com.yandex.div.json + +import org.json.JSONObject + +interface JsonTemplate { + + fun resolve(env: ParsingEnvironment, data: JSONObject): T +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/JsonTemplateParser.java b/client/android/div-json/src/main/java/com/yandex/div/json/JsonTemplateParser.java new file mode 100644 index 000000000..01a8b706c --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/JsonTemplateParser.java @@ -0,0 +1,639 @@ +package com.yandex.div.json; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.div.json.expressions.Expression; +import com.yandex.div.json.expressions.ExpressionsList; +import com.yandex.div.json.schema.Field; +import com.yandex.div.json.schema.FieldKt; +import java.util.List; +import kotlin.PublishedApi; +import kotlin.jvm.functions.Function1; +import kotlin.jvm.functions.Function2; +import org.json.JSONObject; + +import static com.yandex.div.json.JsonParser.alwaysValid; +import static com.yandex.div.json.JsonParser.alwaysValidList; +import static com.yandex.div.json.JsonParser.doNotConvert; +import static com.yandex.div.json.JsonTemplateParserKt.suppressMissingValueOrThrow; + +/** + * A Java-version of JsonTemplateParser.kt that is faster because it generates less garbage during parsing + * and skips unnecessary checks (like nullability). + * + * NOTE! Please do not change Function1 and Function2 with readable java-interfaces. + * This will only make parsing slower. + */ +public class JsonTemplateParser { + private static final ValueValidator IS_NOT_EMPTY = value -> !value.isEmpty(); + + @NonNull + public static Field readOptionalField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readOptionalField(json, key, overridable, fallback, doNotConvert(), alwaysValid(), logger, env); + } + + @NonNull + public static Field readOptionalField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull ValueValidator validator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readOptionalField(json, key, overridable, fallback, doNotConvert(), validator, logger, env); + } + + @NonNull + public static Field readOptionalField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull Function1 converter, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readOptionalField(json, key, overridable, fallback, converter, alwaysValid(), logger, env); + } + + @NonNull + public static Field readOptionalField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull Function1 converter, + @NonNull ValueValidator validator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + T opt = JsonParser.readOptional(json, key, converter, validator, logger, env); + if (opt != null) { + return new Field.Value<>(overridable, opt); + } + String reference = readReference(json, key, logger, env); + if (reference != null) { + return new Field.Reference<>(overridable, reference); + } else if (fallback != null) { + return FieldKt.clone(fallback, overridable); + } else { + return Field.Companion.nullField(overridable); + } + } + + @NonNull + public static Field readOptionalField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull Function2 creator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readOptionalField(json, key, overridable, fallback, creator, alwaysValid(), logger, env); + } + + @NonNull + public static Field readOptionalField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull Function2 creator, + @NonNull ValueValidator validator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + T opt = JsonParser.readOptional(json, key, creator, validator, logger, env); + if (opt != null) { + return new Field.Value<>(overridable, opt); + } + String reference = readReference(json, key, logger, env); + if (reference != null) { + return new Field.Reference<>(overridable, reference); + } else if (fallback != null) { + return FieldKt.clone(fallback, overridable); + } else { + return Field.Companion.nullField(overridable); + } + } + + @NonNull + public static Field readField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readField(json, key, overridable, fallback, doNotConvert(), alwaysValid(), logger, env); + } + + @NonNull + public static Field readField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull ValueValidator valueValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readField(json, key, overridable, fallback, doNotConvert(), valueValidator, logger, env); + } + + @NonNull + public static Field readField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull Function1 converter, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readField(json, key, overridable, fallback, converter, alwaysValid(), logger, env); + + } + + @NonNull + public static Field readField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull Function1 converter, + @NonNull ValueValidator validator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + try { + T value = JsonParser.read(json, key, converter, validator, logger, env); + return new Field.Value<>(overridable, value); + } catch (ParsingException e) { + suppressMissingValueOrThrow(e); + String reference = readReference(json, key, logger, env); + Field referenceOrFallback = referenceOrFallback(overridable, reference, fallback); + if (referenceOrFallback != null) { + return referenceOrFallback; + } else { + throw e; + } + } + } + + @NonNull + public static Field readField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull Function2 creator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readField(json, key, overridable, fallback, creator, alwaysValid(), logger, env); + } + + @NonNull + public static Field readField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field fallback, + @NonNull Function2 creator, + @NonNull ValueValidator validator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + try { + T result = JsonParser.read(json, key, creator, validator, logger, env); + return new Field.Value<>(overridable, result); + } catch (ParsingException e) { + suppressMissingValueOrThrow(e); + String reference = readReference(json, key, logger, env); + Field referenceOrFallback = referenceOrFallback(overridable, reference, fallback); + if (referenceOrFallback != null) { + return referenceOrFallback; + } else { + throw e; + } + } + } + + @PublishedApi + @Nullable + public static String readReference(@NonNull JSONObject json, + @NonNull String key, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return JsonParser.readOptional(json, '$' + key, IS_NOT_EMPTY, logger, env); + } + + @NonNull + public static Field> readOptionalFieldWithExpression( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function1 converter, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env, + @NonNull TypeHelper typeHelper) { + return readOptionalFieldWithExpression( + json, key, overridable, fallback, converter, alwaysValid(), logger, env, typeHelper); + } + + @NonNull + public static Field> readOptionalFieldWithExpression( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull ValueValidator validator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env, + @NonNull TypeHelper typeHelper) { + return readOptionalFieldWithExpression( + json, key, overridable, fallback, doNotConvert(), validator, logger, env, typeHelper); + } + + @NonNull + public static Field> readOptionalFieldWithExpression( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function1 converter, + @NonNull ValueValidator validator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env, + @NonNull TypeHelper typeHelper) { + Expression opt = JsonParser.readOptionalExpression(json, key, converter, validator, logger, env, null, typeHelper); + if (opt != null) { + return new Field.Value<>(overridable, opt); + } else { + String reference = readReference(json, key, logger, env); + if (reference != null) { + return new Field.Reference<>(overridable, reference); + } else if (fallback != null) { + return FieldKt.clone(fallback, overridable); + } else { + return Field.Companion.nullField(overridable); + } + } + } + + @NonNull + public static Field> readListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function1 converter, + @NonNull ListValidator listValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + try { + List opt = JsonParser.readList(json, key, converter, listValidator, alwaysValid(), logger, env); + return new Field.Value<>(overridable, opt); + } catch (ParsingException e) { + suppressMissingValueOrThrow(e); + String reference = readReference(json, key, logger, env); + Field> result = referenceOrFallback(overridable, reference, fallback); + if (result != null) { + return result; + } else { + throw e; + } + } + } + + @NonNull + public static Field> readExpressionsListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function1 converter, + @NonNull ListValidator listValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + ExpressionsList opt = JsonParser.readOptionalExpressionsList(json, key, converter, listValidator, alwaysValid(), logger, env, typeHelper); + if (opt != null) { + return new Field.Value<>(overridable, opt); + } else { + String reference = readReference(json, key, logger, env); + if (reference != null) { + return new Field.Reference<>(overridable, reference); + } else if (fallback != null) { + return FieldKt.clone(fallback, overridable); + } else { + return Field.Companion.nullField(overridable); + } + } + } + + @SuppressWarnings("unused") + @NonNull + public static Field> readExpressionsListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull ListValidator listValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readExpressionsListField(json, key, overridable, fallback, doNotConvert(), listValidator, logger, env, + typeHelper); + } + + @NonNull + public static Field> readOptionalListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function1 converter, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readOptionalListField( + json, key, overridable, fallback, converter, alwaysValidList(), alwaysValid(), logger, env); + } + + @NonNull + public static Field> readOptionalListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function1 converter, + @NonNull ListValidator listValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readOptionalListField( + json, key, overridable, fallback, converter, listValidator, alwaysValid(), logger, env); + } + + @NonNull + public static Field> readOptionalListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function1 converter, + @NonNull ListValidator listValidator, + @NonNull ValueValidator itemValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + List opt = JsonParser.readOptionalList(json, key, converter, listValidator, itemValidator, logger, env); + if (opt != null) { + return new Field.Value<>(overridable, opt); + } else { + String reference = readReference(json, key, logger, env); + if (reference != null) { + return new Field.Reference<>(overridable, reference); + } else if (fallback != null) { + return FieldKt.clone(fallback, overridable); + } else { + return Field.Companion.nullField(overridable); + } + } + } + + @NonNull + public static Field> readOptionalListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function2 creator, + @NonNull ListValidator validator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + List opt = JsonParser.readOptionalList(json, key, creator, validator, logger, env); + if (opt != null) { + return new Field.Value<>(overridable, opt); + } else { + String reference = readReference(json, key, logger, env); + if (reference != null) { + return new Field.Reference<>(overridable, reference); + } else if (fallback != null) { + return FieldKt.clone(fallback, overridable); + } else { + return Field.Companion.nullField(overridable); + } + } + } + + @NonNull + public static Field> readOptionalListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull ListValidator validator, + @NonNull ValueValidator itemValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + List opt = JsonParser.readOptionalList(json, key, doNotConvert(), validator, itemValidator, logger, env); + if (opt != null) { + return new Field.Value<>(overridable, opt); + } else { + String reference = readReference(json, key, logger, env); + if (reference != null) { + return new Field.Reference<>(overridable, reference); + } else if (fallback != null) { + return FieldKt.clone(fallback, overridable); + } else { + return Field.Companion.nullField(overridable); + } + } + } + + @NonNull + public static Field> readStrictListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function2 creator, + @NonNull ListValidator listValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readStrictListField( + json, key, overridable, fallback, creator, listValidator, alwaysValid(), logger, env); + } + + @NonNull + public static Field> readListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function2 creator, + @NonNull ListValidator listValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + return readListField(json, key, overridable, fallback, creator, listValidator, alwaysValid(), logger, env); + } + + @NonNull + public static Field> readListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function2 creator, + @NonNull ListValidator listValidator, + @NonNull ValueValidator itemValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + try { + List opt = JsonParser.readList(json, key, creator, listValidator, itemValidator, logger, env); + return new Field.Value<>(overridable, opt); + } catch (ParsingException e) { + suppressMissingValueOrThrow(e); + String reference = readReference(json, key, logger, env); + Field> result = referenceOrFallback(overridable, reference, fallback); + if (result != null) { + return result; + } else { + throw e; + } + } + } + + @NonNull + public static Field> readStrictListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function2 creator, + @NonNull ListValidator listValidator, + @NonNull ValueValidator itemValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + try { + List opt = JsonParser.readStrictList(json, key, creator, listValidator, itemValidator, logger, env); + return new Field.Value<>(overridable, opt); + } catch (ParsingException e) { + suppressMissingValueOrThrow(e); + String reference = readReference(json, key, logger, env); + Field> result = referenceOrFallback(overridable, reference, fallback); + if (result != null) { + return result; + } else { + throw e; + } + } + } + + @NonNull + public static Field> readStrictListField( + @NonNull JSONObject json, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function1 converter, + @NonNull ListValidator listValidator, + @NonNull ValueValidator itemValidator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env) { + try { + List opt = JsonParser.readStrictList(json, key, converter, listValidator, itemValidator, logger); + return new Field.Value<>(overridable, opt); + } catch (ParsingException e) { + suppressMissingValueOrThrow(e); + String reference = readReference(json, key, logger, env); + Field> result = referenceOrFallback(overridable, reference, fallback); + if (result != null) { + return result; + } else { + throw e; + } + } + } + + + @PublishedApi + @Nullable + public static Field referenceOrFallback( + boolean overridable, + @Nullable String reference, + @Nullable Field fallback) { + if (reference != null) { + return new Field.Reference<>(overridable, reference); + } else if (fallback != null) { + return FieldKt.clone(fallback, overridable); + } else { + return overridable ? Field.Companion.nullField(overridable) : null; + } + } + + @NonNull + public static Field> readFieldWithExpression(@NonNull JSONObject jsonObject, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function1 converter, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readFieldWithExpression(jsonObject, key, overridable, fallback, converter, alwaysValid(), logger, env, typeHelper); + } + + @NonNull + public static Field> readFieldWithExpression(@NonNull JSONObject jsonObject, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readFieldWithExpression(jsonObject, key, overridable, fallback, doNotConvert(), alwaysValid(), logger, + env, typeHelper); + } + + @NonNull + public static Field> readFieldWithExpression(@NonNull JSONObject jsonObject, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull ValueValidator validator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + return readFieldWithExpression(jsonObject, key, overridable, fallback, doNotConvert(), validator, logger, env, typeHelper); + } + + @NonNull + public static Field> readFieldWithExpression(@NonNull JSONObject jsonObject, + @NonNull String key, + boolean overridable, + @Nullable Field> fallback, + @NonNull Function1 converter, + @NonNull ValueValidator validator, + @NonNull ParsingErrorLogger logger, + @NonNull ParsingEnvironment env, + @NonNull final TypeHelper typeHelper) { + try { + Expression expression = JsonParser.readExpression(jsonObject, key, converter, validator, logger, env, + typeHelper); + return new Field.Value<>(overridable, expression); + } catch (ParsingException e) { + suppressMissingValueOrThrow(e); + Field> referenceOrFallback = referenceOrFallback( + overridable, + readReference(jsonObject, key, logger, env), + fallback); + if (referenceOrFallback != null) { + return referenceOrFallback; + } else { + throw e; + } + } + } +} \ No newline at end of file diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/JsonTemplateParser.kt b/client/android/div-json/src/main/java/com/yandex/div/json/JsonTemplateParser.kt new file mode 100644 index 000000000..7f18b67f5 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/JsonTemplateParser.kt @@ -0,0 +1,510 @@ +package com.yandex.div.json + +import com.yandex.div.json.JsonParser.doNotConvert +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.schema.Field +import com.yandex.div.json.schema.clone +import org.json.JSONObject + +fun JSONObject.readField( + key: String, + overridable: Boolean, + fallback: Field?, + validator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field { + try { + read(key = key, validator = validator, logger = logger, env = env).let { + return Field.Value(overridable, it) + } + } catch (e: ParsingException) { + suppressMissingValueOrThrow(e) + val reference = readReference(key = key, logger = logger, env = env) + return referenceOrFallback(overridable, reference, fallback) ?: throw e + } +} + +inline fun JSONObject.readField( + key: String, + overridable: Boolean, + fallback: Field?, + converter: Converter, + validator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field { + try { + read(key = key, converter = converter, validator = validator, logger = logger, env = env).let { + return Field.Value(overridable, it) + } + } catch (e: ParsingException) { + suppressMissingValueOrThrow(e) + val reference = readReference(key = key, logger = logger, env = env) + return referenceOrFallback(overridable, reference, fallback) ?: throw e + } +} + + +@JvmName("readSerializableField") +fun JSONObject.readField( + key: String, + overridable: Boolean, + fallback: Field?, + creator: Creator, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field { + try { + read(key = key, creator = creator, logger = logger, env = env).let { + return Field.Value(overridable, it) + } + } catch (e: ParsingException) { + suppressMissingValueOrThrow(e) + val reference = readReference(key = key, logger = logger, env = env) + return referenceOrFallback(overridable, reference, fallback) ?: throw e + } +} + +fun JSONObject.readOptionalField( + key: String, + overridable: Boolean, + fallback: Field?, + validator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field { + readOptional(key = key, validator = validator, logger = logger, env = env)?.let { + return Field.Value(overridable, it) + } + + readReference(key = key, logger = logger, env = env)?.let { + return Field.Reference(overridable, it) + } + + fallback?.let { + return it.clone(overridable) + } + + return Field.nullField(overridable) +} + +fun JSONObject.readOptionalField( + key: String, + overridable: Boolean, + fallback: Field?, + converter: Converter, + validator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field { + readOptional(key = key, converter = converter, validator = validator, logger = logger, env = env)?.let { + return Field.Value(overridable, it) + } + + readReference(key = key, logger = logger, env = env)?.let { + return Field.Reference(overridable, it) + } + + fallback?.let { + return it.clone(overridable) + } + + return Field.nullField(overridable) +} + +@JvmName("readSerializableOptionalField") +fun JSONObject.readOptionalField( + key: String, + overridable: Boolean, + fallback: Field?, + creator: Creator, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field { + readOptional(key = key, creator = creator, logger = logger, env = env)?.let { + return Field.Value(overridable, it) + } + + readReference(key = key, logger = logger, env = env)?.let { + return Field.Reference(overridable, it) + } + + fallback?.let { + return it.clone(overridable) + } + + return Field.nullField(overridable) +} + +fun JSONObject.readListField( + key: String, + overridable: Boolean, + fallback: Field>?, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field> { + try { + readList( + key = key, + validator = validator, + itemValidator = itemValidator, + logger = logger, + env = env, + ).let { + return Field.Value(overridable, it) + } + } catch (e: ParsingException) { + suppressMissingValueOrThrow(e) + val reference = readReference(key = key, logger = logger, env = env) + return referenceOrFallback(overridable, reference, fallback) ?: throw e + } +} + +fun JSONObject.readListField( + key: String, + overridable: Boolean, + fallback: Field>?, + converter: Converter, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field> { + try { + readList( + key = key, + converter = converter, + validator = validator, + itemValidator = itemValidator, + logger = logger, + env = env, + ).let { + return Field.Value(overridable, it) + } + } catch (e: ParsingException) { + suppressMissingValueOrThrow(e) + val reference = readReference(key = key, logger = logger, env = env) + return referenceOrFallback(overridable, reference, fallback) ?: throw e + } +} + +@JvmName("readSerializableListField") +fun JSONObject.readListField( + key: String, + overridable: Boolean, + fallback: Field>?, + creator: Creator, + validator: ListValidator = ListValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field> { + try { + readList( + key = key, + creator = creator, + validator = validator, + logger = logger, + env = env, + ).let { + return Field.Value(overridable, it) + } + } catch (e: ParsingException) { + suppressMissingValueOrThrow(e) + val reference = readReference(key = key, logger = logger, env = env) + return referenceOrFallback(overridable, reference, fallback) ?: throw e + } +} + +fun JSONObject.readOptionalListField( + key: String, + overridable: Boolean, + fallback: Field>?, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field> { + readOptionalList( + key = key, + validator = validator, + itemValidator = itemValidator, + logger = logger + )?.let { + return Field.Value(overridable, it) + } + + readReference(key = key, logger = logger, env = env)?.let { + return Field.Reference(overridable, it) + } + + fallback?.let { + return it.clone(overridable) + } + + return Field.nullField(overridable) +} + +fun JSONObject.readOptionalListField( + key: String, + overridable: Boolean, + fallback: Field>?, + converter: Converter, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field> { + readOptionalList( + key = key, + converter = converter, + validator = validator, + itemValidator = itemValidator, + logger = logger + )?.let { + return Field.Value(overridable, it) + } + + readReference(key = key, logger = logger, env = env)?.let { + return Field.Reference(overridable, it) + } + + fallback?.let { + return it.clone(overridable) + } + + return Field.nullField(overridable) +} + +@JvmName("readSerializableOptionalListField") +fun JSONObject.readOptionalListField( + key: String, + overridable: Boolean, + fallback: Field>?, + creator: Creator, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field> { + readOptionalList( + key = key, + creator = creator, + validator = validator, + itemValidator = itemValidator, + logger = logger, + env = env, + )?.let { + return Field.Value(overridable, it) + } + + readReference(key = key, logger = logger, env)?.let { + return Field.Reference(overridable, it) + } + + fallback?.let { + return it.clone(overridable) + } + + return Field.nullField(overridable) +} + +fun JSONObject.readStrictListField( + key: String, + overridable: Boolean, + fallback: Field>?, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field> { + try { + readStrictList( + key = key, + validator = validator, + itemValidator = itemValidator, + logger = logger + ).let { + return Field.Value(overridable, it) + } + } catch (e: ParsingException) { + suppressMissingValueOrThrow(e) + val reference = readReference(key = key, logger = logger, env = env) + return referenceOrFallback(overridable, reference, fallback) ?: throw e + } +} + +fun JSONObject.readStrictListField( + key: String, + overridable: Boolean, + fallback: Field>?, + converter: Converter, + validator: ListValidator = ListValidator { true }, + itemValidator: ValueValidator = ValueValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field> { + try { + readStrictList( + key = key, + converter = converter, + validator = validator, + itemValidator = itemValidator, + logger = logger + ).let { + return Field.Value(overridable, it) + } + } catch (e: ParsingException) { + suppressMissingValueOrThrow(e) + val reference = readReference(key = key, logger = logger, env = env) + return referenceOrFallback(overridable, reference, fallback) ?: throw e + } +} + +@JvmName("readStrictSerializableListField") +fun JSONObject.readStrictListField( + key: String, + overridable: Boolean, + fallback: Field>?, + creator: Creator, + validator: ListValidator = ListValidator { true }, + logger: ParsingErrorLogger, + env: ParsingEnvironment, +): Field> { + try { + readStrictList( + key = key, + creator = creator, + validator = validator, + logger = logger, + env = env, + ).let { + return Field.Value(overridable, it) + } + } catch (e: ParsingException) { + suppressMissingValueOrThrow(e) + val reference = readReference(key = key, logger = logger, env = env) + return referenceOrFallback(overridable, reference, fallback) ?: throw e + } +} + +fun suppressMissingValueOrThrow(e: ParsingException) { + if (e.reason == ParsingExceptionReason.MISSING_VALUE) { + return + } + + throw e +} + +@PublishedApi +internal fun JSONObject.readReference(key: String, logger: ParsingErrorLogger, env: ParsingEnvironment): String? { + return readOptional(key = "$$key", validator = { it.isNotEmpty() }, logger = logger, env = env ) +} + +@PublishedApi +internal fun referenceOrFallback( + overridable: Boolean, + reference: String?, + fallback: Field?, +): Field? { + if (reference != null) return Field.Reference(overridable, reference) + if (fallback != null) return fallback.clone(overridable) + if (overridable) return Field.nullField(overridable) + return null +} + +fun JSONObject.writeField( + key: String, + field: Field?, + converter: Converter = { it } +) { + when (field) { + is Field.Value -> write(key, converter(field.value)) + is Field.Reference -> write("$$key", field.reference) + else -> Unit + } +} + +fun JSONObject.writeFieldWithExpression( + key: String, + field: Field>?, + converter: Converter, +) { + when (field) { + is Field.Value -> writeExpression(key, field.value, converter) + is Field.Reference -> write("$$key", field.reference) + else -> Unit + } +} + +@JvmName("writeSerializableField") +fun JSONObject.writeField( + key: String, + field: Field? +) { + when (field) { + is Field.Value -> write(key, field.value.writeToJSON()) + is Field.Reference -> write("$$key", field.reference) + else -> Unit + } +} + +fun JSONObject.writeFieldWithExpression( + key: String, + field: Field>? +) { + when (field) { + is Field.Value -> writeExpression(key, field.value) + is Field.Reference -> write("$$key", field.reference) + else -> Unit + } +} + +fun JSONObject.writeExpressionsListField( + key: String, + field: Field>?, + converter: Converter, +) { + when (field) { + is Field.Value -> writeExpressionsList(key, field.value, converter) + is Field.Reference -> write("$$key", field.reference) + else -> Unit + } +} + +@Suppress("unused") +fun JSONObject.writeExpressionsListField( + key: String, + field: Field>?, +) { + writeExpressionsListField(key, field, doNotConvert()) +} + +@JvmName("writeListField") +fun JSONObject.writeField( + key: String, + field: Field>? +) { + when (field) { + is Field.Value -> write(key, field.value) + is Field.Reference -> write("$$key", field.reference) + else -> Unit + } +} + +@JvmName("writeListField") +fun JSONObject.writeField( + key: String, + field: Field>?, + converter: Converter +) { + when (field) { + is Field.Value -> write(key, field.value, converter) + is Field.Reference -> write("$$key", field.reference) + else -> Unit + } +} + diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/JsonTopologicalSorting.kt b/client/android/div-json/src/main/java/com/yandex/div/json/JsonTopologicalSorting.kt new file mode 100644 index 000000000..b1f706547 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/JsonTopologicalSorting.kt @@ -0,0 +1,147 @@ +package com.yandex.div.json + +import android.annotation.SuppressLint +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +internal object JsonTopologicalSorting { + + /** + * Performs sorting of json objects that describes any of following relationship: + * + * + inheritance + * ``` + * "parent": { + * "type": "base" + * }, + * "child": { + * "type": "parent" + * } + * ``` + * + * + composition + * ``` + * "collection": { + * "type": "base" + * items: [ + * { + * "type": "item" + * }, + * ... + * ] + * }, + * "item": { + * "type": "base" + * } + * ``` + * + * @return linked map of names of a given json sorted in topological order along with their dependencies. + * @throws CyclicDependencyException if object dependencies forms cycle. + * @throws ParsingException if top level object has no parent reference or it is empty. + * @throws JSONException if json is malformed. + */ + @SuppressLint("NewApi") + @Throws(JSONException::class, ParsingException::class, CyclicDependencyException::class) + fun sort(json: JSONObject, logger: ParsingErrorLogger, env: ParsingEnvironment): Map> { + val types = parseTypeDependencies(json, logger, env) + val visited = LinkedHashSet() + val processed = LinkedHashSet() + val sorted = LinkedHashMap>() + + for (type in types.keys) { + processType(type, types, visited, processed, sorted) + } + return sorted + } + + private fun parseTypeDependencies(json: JSONObject, logger: ParsingErrorLogger, env: ParsingEnvironment): MutableMap> { + val result = LinkedHashMap>(json.length()) + json.forEach { key, entry -> + val dependencies = mutableListOf() + readObjectDependencies( + json = entry, + requireParent = true, + dependencies = dependencies, + logger = TemplateParsingErrorLogger(logger = logger, templateId = key), + env = env, + ) + result[key] = dependencies + } + return result + } + + private fun readObjectDependencies( + json: JSONObject, + requireParent: Boolean, + dependencies: MutableList, + logger: ParsingErrorLogger, + env: ParsingEnvironment, + ) { + val parent = if (requireParent) readParent(json, logger, env) else readOptionalParent(json, logger, env) + parent?.let { dependencies.add(it) } + + json.forEach { _, jsonObject -> + readObjectDependencies( + json = jsonObject, + requireParent = false, + dependencies = dependencies, + logger = logger, + env = env, + ) + } + json.forEach { _, jsonArray -> + jsonArray.forEach { _, item -> + readObjectDependencies( + json = item, + requireParent = false, + dependencies = dependencies, + logger = logger, + env = env, + ) + } + } + } + + private fun readParent(json: JSONObject, logger: ParsingErrorLogger, env: ParsingEnvironment): String? { + return json.read(key = "type", validator = { it.isNotEmpty() }, logger = logger, env = env) + } + + private fun readOptionalParent(json: JSONObject, logger: ParsingErrorLogger, env: ParsingEnvironment): String? { + return json.readOptional(key = "type", validator = { it.isNotEmpty() }, logger = logger, env = env) + } + + private fun processType( + type: String, + types: MutableMap>, + visited: MutableSet, + processed: MutableSet, + sorted: LinkedHashMap> + ) { + if (type in visited) throwCyclicDependency(visited.toList(), type) + if (type in processed) return + + val dependencies = types[type]?.filter { types.contains(it) } + if (!dependencies.isNullOrEmpty()) { + visited.add(type) + for (dependency in dependencies) { + processType(dependency, types, visited, processed, sorted) + } + visited.remove(type) + } + processed.add(type) + sorted[type] = dependencies.orEmpty().toSet() + } + + private fun throwCyclicDependency(visited: List, type: String): Nothing { + val cycleStart = visited.indexOf(type) + val output = StringBuilder() + for (i in cycleStart until visited.size) { + output.append(visited[i]).append(" -> ") + } + output.append(type) + throw CyclicDependencyException(output.toString()) + } +} + +internal class CyclicDependencyException(message: String) : Exception(message) diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/JsonUtils.kt b/client/android/div-json/src/main/java/com/yandex/div/json/JsonUtils.kt new file mode 100644 index 000000000..ab5d39dc4 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/JsonUtils.kt @@ -0,0 +1,85 @@ +package com.yandex.div.json + +import org.json.JSONArray +import org.json.JSONObject + +inline fun JSONObject.forEach(action: (String, T) -> Unit) { + val keys = keys() + for (key in keys) { + val value = get(key) + if (value is T) action(key, value) + } +} + + +inline fun JSONArray.forEach(action: (Int, T) -> Unit) { + val length = length() + for (i in 0 until length) { + val value = get(i) + if (value is T) action(i, value) + } +} + +inline fun JSONObject.forEachNullable(action: (String, T?) -> Unit) { + val keys = keys() + for (key in keys) { + val value = opt(key) + if (value is T?) action(key, value) + } +} + +inline fun JSONArray.forEachNullable(action: (Int, T?) -> Unit) { + val length = length() + for (i in 0 until length) { + val value = opt(i) + if (value is T?) action(i, value) + } +} + +inline fun JSONArray.map(mapping: (Any) -> R): List { + val length = length() + val result = ArrayList(length) + for (i in 0 until length) { + result.add(mapping(get(i))) + } + return result +} + +inline fun JSONArray.mapNotNull(mapping: (Any) -> R?): List { + val length = length() + val result = ArrayList(length) + for (i in 0 until length) { + mapping(get(i))?.let { result.add(it) } + } + return result +} + +@Suppress("UNCHECKED_CAST") +fun JSONArray.asList(): List = mapNotNull { it as? R } + +/** + * Gets optional string value for the given key. + * + * Unlike [JSONObject.optString] this method does not convert not string values to string. + */ +fun JSONObject.getStringOrEmpty(name: String): String { + val value = opt(name) + return if (value is String) value else "" +} + +fun JSONObject.getStringOrNull(key: String): String? { + val value = opt(key) + return if (value is String) value else null +} + +fun JSONObject.summary(indentSpaces: Int = 0): String { + return JsonPrinter(indentSpaces = indentSpaces, nestingLimit = 1).print(this) +} + +fun JSONArray.summary(indentSpaces: Int = 0): String { + return JsonPrinter(indentSpaces = indentSpaces, nestingLimit = 1).print(this) +} + +fun JSONObject.isEmpty(): Boolean = length() == 0 + +fun JSONArray.isEmpty(): Boolean = length() == 0 diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/ParsingConverters.kt b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingConverters.kt new file mode 100644 index 000000000..c91400e31 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingConverters.kt @@ -0,0 +1,53 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.yandex.div.json + +import android.net.Uri +import com.yandex.div.evaluable.types.Color +import java.lang.ClassCastException + +typealias Converter = (T) -> R + +@PublishedApi +internal inline fun Converter.tryConvert(value: T): R? { + return try { + this(value) + } catch (e: Exception) { + null + } +} + +val BOOLEAN_TO_INT: Converter = { value -> if (value) 1 else 0 } + +val COLOR_INT_TO_STRING: Converter = { value -> Color(value).toString() } +val STRING_TO_COLOR_INT: Converter = { value -> + when (value) { + is String -> Color.parse(value).value + is Color -> value.value + null -> null + else -> throw ClassCastException("Received value of wrong type") + } +} + +val URI_TO_STRING: Converter = { uri -> uri.toString() } +val STRING_TO_URI: Converter = { value -> Uri.parse(value) } + +val NUMBER_TO_BOOLEAN: Converter = { n -> n.toBoolean() } +val NUMBER_TO_DOUBLE: Converter = { n: Number -> n.toDouble() } +val NUMBER_TO_INT: Converter = { n: Number -> n.toInt() } + +fun Number.toBoolean(): Boolean? { + return when (toInt()) { + 0 -> false + 1 -> true + else -> null + } +} + +fun Int.toBoolean(): Boolean { + return when (this) { + 0 -> false + 1 -> true + else -> throw IllegalArgumentException("Unable to convert $this to boolean") + } +} \ No newline at end of file diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/ParsingEnvironment.kt b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingEnvironment.kt new file mode 100644 index 000000000..fb2f668d3 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingEnvironment.kt @@ -0,0 +1,9 @@ +package com.yandex.div.json + +import com.yandex.div.json.templates.TemplateProvider + +interface ParsingEnvironment { + + val templates: TemplateProvider> + val logger: ParsingErrorLogger +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/ParsingEnvironmentExtensions.kt b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingEnvironmentExtensions.kt new file mode 100644 index 000000000..e7af0fe17 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingEnvironmentExtensions.kt @@ -0,0 +1,36 @@ +package com.yandex.div.json + +import com.yandex.div.json.templates.TemplateProvider + +fun ParsingEnvironment.withLogger(logger: ParsingErrorLogger): ParsingEnvironmentWrapper { + return ParsingEnvironmentWrapper(this, logger) +} + +class ParsingEnvironmentWrapper( + base: ParsingEnvironment, + override val logger: ParsingErrorLogger +) : ParsingEnvironment { + + override val templates: TemplateProvider> + get() = _templates + + val requestedKeys: Set + get() = _templates.requestedKeys + + private val _templates = KeyWatchingTemplateProvider(base.templates) +} + +private class KeyWatchingTemplateProvider( + private val base: TemplateProvider> +): TemplateProvider> { + + val requestedKeys: Set + get() = _requestedKeys + + private val _requestedKeys = linkedSetOf() + + override fun get(templateId: String): JsonTemplate<*>? { + _requestedKeys.add(templateId) + return base[templateId] + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/ParsingEnvironmentImpl.kt b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingEnvironmentImpl.kt new file mode 100644 index 000000000..269414172 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingEnvironmentImpl.kt @@ -0,0 +1,11 @@ +package com.yandex.div.json + +import com.yandex.div.json.templates.TemplateProvider + +/** + * Simple implementation of [ParsingEnvironment]. + */ +internal class ParsingEnvironmentImpl( + override val templates: TemplateProvider>, + override val logger: ParsingErrorLogger +) : ParsingEnvironment diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/ParsingErrorLogger.java b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingErrorLogger.java new file mode 100644 index 000000000..0faba8261 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingErrorLogger.java @@ -0,0 +1,26 @@ +package com.yandex.div.json; + +import androidx.annotation.NonNull; +import com.yandex.div.core.util.Assert; +import com.yandex.div.core.util.Log; + +public interface ParsingErrorLogger { + + ParsingErrorLogger LOG = (e) -> { + if (Log.isEnabled()) { + Log.e("ParsingErrorLogger", "An error occurred during parsing process", e); + } + }; + + ParsingErrorLogger ASSERT = (e) -> { + if (Assert.isEnabled()) { + Assert.fail(e.getMessage(), e); + } + }; + + void logError(@NonNull Exception e); + + default void logTemplateError(@NonNull Exception e, @NonNull String templateId) { + logError(e); + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/ParsingException.kt b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingException.kt new file mode 100644 index 000000000..ee0c66e94 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingException.kt @@ -0,0 +1,205 @@ +package com.yandex.div.json + +import org.json.JSONArray +import org.json.JSONObject + +private const val MAX_TO_STRING_LENGTH = 100 + +class ParsingException( + val reason: ParsingExceptionReason, + message: String, + cause: Throwable? = null, + val source: Json? = null, + val jsonSummary: String? = null, +) : RuntimeException(message, cause) + +fun missingValue(json: JSONObject, key: String): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.MISSING_VALUE, + message = "Value for key '$key' is missing", + source = JsonObject(json), + jsonSummary = json.summary() + ) +} + +fun missingValue(json: JSONArray, key: String, index: Int): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.MISSING_VALUE, + message = "Value at $index position of '$key' is missing", + source = JsonArray(json), + jsonSummary = json.summary() + ) +} + +fun missingValue(key: String, path: String): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.MISSING_VALUE, + message = "Value for key '$key' at path '$path' is missing" + ) +} + +fun typeMismatch(json: JSONObject, key: String, value: Any): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.TYPE_MISMATCH, + message = "Value for key '$key' has wrong type ${value.javaClass.name}", + source = JsonObject(json), + jsonSummary = json.summary() + ) +} + +fun typeMismatch(json: JSONArray, key: String, index: Int, value: Any): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.TYPE_MISMATCH, + message = "Value at $index position of '$key' has wrong type ${value.javaClass.name}", + source = JsonArray(json), + jsonSummary = json.summary() + ) +} + +fun typeMismatch(path: String): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.TYPE_MISMATCH, + message = "Value at path '$path' has wrong type", + ) +} + +fun typeMismatch(expressionKey: String, + rawExpression: String, + wrongTypeValue: Any?, + cause: Throwable? = null): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.TYPE_MISMATCH, + message = "Expression \"$expressionKey\": \"$rawExpression\" received value of wrong type: '$wrongTypeValue'", + cause = cause, + ) +} + +fun templateNotFound(json: JSONObject, templateId: String): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.MISSING_TEMPLATE, + message = "Template '$templateId' is missing!", + source = JsonObject(json), + jsonSummary = json.summary() + ) + +} + +fun invalidValue(json: JSONObject, key: String, value: T): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.INVALID_VALUE, + message = "Value '${value.trimLength()}' for key '$key' is not valid", + source = JsonObject(json), + jsonSummary = json.summary() + ) +} + +private fun Any?.trimLength(): String { + val fullMessage = this.toString() + return if (fullMessage.length > MAX_TO_STRING_LENGTH) { + fullMessage.take(MAX_TO_STRING_LENGTH - 3) + "..." + } else { + fullMessage + } +} + + +fun invalidValue(json: JSONObject, key: String, value: T, cause: Throwable): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.INVALID_VALUE, + message = "Value '${value.trimLength()}' for key '$key' is not valid", + cause = cause, + source = JsonObject(json), + ) +} + +fun invalidValue(json: JSONArray, key: String, index: Int, value: T, cause: Throwable): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.INVALID_VALUE, + message = "Value '${value.trimLength()}' at $index position of '$key' is not valid", + cause = cause, + source = JsonArray(json), + ) +} + +fun invalidValue(json: JSONArray, key: String, index: Int, value: T): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.INVALID_VALUE, + message = "Value '${value.trimLength()}' at $index position of '$key' is not valid", + source = JsonArray(json), + jsonSummary = json.summary() + ) +} + +fun invalidValue(path: String, value: T): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.INVALID_VALUE, + message = "Value '${value.trimLength()}' at path '$path' is not valid" + ) +} + +fun resolveFailed(key: String, value: T, cause: Throwable? = null): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.INVALID_VALUE, + message = "Value '${value.trimLength()}' for key '$key' could not be resolved", + cause = cause + ) +} + +fun invalidValue(key: String, path: String, value: T): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.INVALID_VALUE, + message = "Value '${value.trimLength()}' for key '$key' at path '$path' is not valid" + ) +} + +fun missingVariable(key: String, expression: String, variableName: String, + cause: Throwable? = null) = + ParsingException(reason = ParsingExceptionReason.MISSING_VARIABLE, + "Undefined variable '$variableName' at \"$key\": \"$expression\"", cause = cause) + +fun missingVariable(variableName: String, cause: Throwable? = null) = + ParsingException(reason = ParsingExceptionReason.MISSING_VARIABLE, + "No variable could be resolved for '$variableName", cause = cause) + +fun dependencyFailed(json: JSONObject, key: String, cause: ParsingException): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.DEPENDENCY_FAILED, + message = "Value for key '$key' is failed to create", + cause = cause, + source = JsonObject(json), + jsonSummary = json.summary() + ) +} + +fun dependencyFailed(json: JSONArray, key: String, index: Int, cause: ParsingException): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.DEPENDENCY_FAILED, + message = "Value at $index position of '$key' is failed to create", + cause = cause, + source = JsonArray(json), + jsonSummary = json.summary() + ) +} + +fun dependencyFailed(path: String, cause: ParsingException): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.DEPENDENCY_FAILED, + message = "Value at path '$path' is failed to create", + cause = cause + ) +} + +fun dependencyFailed(key: String, path: String, cause: ParsingException): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.DEPENDENCY_FAILED, + message = "Value for key '$key' at path '$path' is failed to create", + cause = cause + ) +} + +fun invalidCondition(message: String, input: String): ParsingException { + return ParsingException( + reason = ParsingExceptionReason.INVALID_VALUE, message = message, + jsonSummary = input + ) +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/ParsingExceptionReason.kt b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingExceptionReason.kt new file mode 100644 index 000000000..94e28daa8 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingExceptionReason.kt @@ -0,0 +1,10 @@ +package com.yandex.div.json + +enum class ParsingExceptionReason { + MISSING_TEMPLATE, + MISSING_VALUE, + MISSING_VARIABLE, + TYPE_MISMATCH, + INVALID_VALUE, + DEPENDENCY_FAILED, +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/ParsingValidators.kt b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingValidators.kt new file mode 100644 index 000000000..89b44d89d --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/ParsingValidators.kt @@ -0,0 +1,16 @@ +package com.yandex.div.json + +import android.net.Uri +import java.util.regex.Pattern + +fun interface ValueValidator { + fun isValid(value: T): Boolean +} + +fun interface ListValidator { + fun isValid(value: List): Boolean +} + +fun String.doesMatch(regex: String) = Pattern.matches(regex, this) + +fun Uri.hasScheme(schemes: Collection): Boolean = scheme?.let { it in schemes } ?: false diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/TemplateParsingEnvironment.kt b/client/android/div-json/src/main/java/com/yandex/div/json/TemplateParsingEnvironment.kt new file mode 100644 index 000000000..7a0db0826 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/TemplateParsingEnvironment.kt @@ -0,0 +1,76 @@ +package com.yandex.div.json + +import com.yandex.div.json.templates.InMemoryTemplateProvider +import com.yandex.div.json.templates.MainTemplateProvider +import com.yandex.div.json.templates.TemplateProvider +import com.yandex.div.util.arrayMap +import org.json.JSONException +import org.json.JSONObject + +abstract class TemplateParsingEnvironment> @JvmOverloads constructor( + override val logger: ParsingErrorLogger, + private val mainTemplateProvider: MainTemplateProvider = MainTemplateProvider( + InMemoryTemplateProvider(), + TemplateProvider.empty(), + ), +) : ParsingEnvironment { + + abstract val templateFactory: TemplateFactory + + override val templates: TemplateProvider = mainTemplateProvider + + fun parseTemplates(json: JSONObject) { + val parsed = parseTemplatesWithResult(json) + mainTemplateProvider.putAll(parsed) + } + + /** + * Tries to convert json to a collection of templates and retain them for future usage. + * @return names of successfully parsed templates + */ + fun parseTemplatesWithResult(json: JSONObject): Map { + return parseTemplatesWithResultAndDependencies(json).parsedTemplates + } + + /** + * Parses json with templates memoizing dependencies between them along the way. + * @return object consisting of two maps with parsed templates and their dependencies. + */ + fun parseTemplatesWithResultAndDependencies(json: JSONObject): TemplateParsingResult { + val parsedTemplates = arrayMap() + val templateDependencies = arrayMap>() + try { + val orderedNamesWithDependencies = JsonTopologicalSorting.sort(json, logger, env = this) + mainTemplateProvider.takeSnapshot(parsedTemplates) + val tmpProvider = TemplateProvider.wrap(parsedTemplates) + + orderedNamesWithDependencies.forEach { (name, dependencies) -> + try { + val env = ParsingEnvironmentImpl(tmpProvider, TemplateParsingErrorLogger(logger, name)) + val template = templateFactory.create(env, topLevel = true, json.getJSONObject(name)) + parsedTemplates[name] = template + if (dependencies.isNotEmpty()) { + templateDependencies[name] = dependencies + } + } catch (e: ParsingException) { + logger.logTemplateError(e, name) + } + } + } catch (e: Exception) { + logger.logError(e) + } + + return TemplateParsingResult(parsedTemplates, templateDependencies) + } + + interface TemplateFactory { + + @Throws(JSONException::class) + fun create(env: ParsingEnvironment, topLevel: Boolean, json: JSONObject): T + } + + inner class TemplateParsingResult( + val parsedTemplates: Map, + val templateDependencies: Map> + ) +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/TemplateParsingErrorLogger.kt b/client/android/div-json/src/main/java/com/yandex/div/json/TemplateParsingErrorLogger.kt new file mode 100644 index 000000000..465a0bfcc --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/TemplateParsingErrorLogger.kt @@ -0,0 +1,9 @@ +package com.yandex.div.json + +internal class TemplateParsingErrorLogger( + private val logger: ParsingErrorLogger, + private val templateId: String +) : ParsingErrorLogger { + + override fun logError(e: Exception) = logger.logTemplateError(e, templateId) +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/TypeHelpers.kt b/client/android/div-json/src/main/java/com/yandex/div/json/TypeHelpers.kt new file mode 100644 index 000000000..d7bb39574 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/TypeHelpers.kt @@ -0,0 +1,56 @@ +package com.yandex.div.json + +import android.graphics.Color +import android.net.Uri + +interface TypeHelper { + val typeDefault: T + fun isTypeValid(value: Any): Boolean + + companion object { + fun from(default: T, validator: (Any) -> Boolean) = object : TypeHelper { + override val typeDefault: T = default + + override fun isTypeValid(value: Any): Boolean { + return validator.invoke(value) + } + + } + } +} + +@JvmField +val TYPE_HELPER_BOOLEAN = object : TypeHelper { + override val typeDefault = false + override fun isTypeValid(value: Any) = value is Boolean +} + +@JvmField +val TYPE_HELPER_INT = object : TypeHelper { + override val typeDefault = 0 + override fun isTypeValid(value: Any) = value is Int +} + +@JvmField +val TYPE_HELPER_STRING = object : TypeHelper { + override val typeDefault = "" + override fun isTypeValid(value: Any) = value is String +} + +@JvmField +val TYPE_HELPER_DOUBLE = object : TypeHelper { + override val typeDefault = 0f.toDouble() + override fun isTypeValid(value: Any) = value is Double +} + +@JvmField +val TYPE_HELPER_URI = object : TypeHelper { + override val typeDefault = Uri.EMPTY + override fun isTypeValid(value: Any) = value is Uri +} + +@JvmField +val TYPE_HELPER_COLOR = object : TypeHelper { + override val typeDefault = Color.BLACK + override fun isTypeValid(value: Any) = value is Int +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/expressions/Expression.kt b/client/android/div-json/src/main/java/com/yandex/div/json/expressions/Expression.kt new file mode 100644 index 000000000..6a29b91c7 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/expressions/Expression.kt @@ -0,0 +1,213 @@ +package com.yandex.div.json.expressions + +import com.yandex.div.core.CompositeDisposable +import com.yandex.div.core.Disposable +import com.yandex.div.core.plusAssign +import com.yandex.div.evaluable.Evaluable +import com.yandex.div.evaluable.EvaluableException +import com.yandex.div.json.Converter +import com.yandex.div.json.ParsingErrorLogger +import com.yandex.div.json.ParsingException +import com.yandex.div.json.TypeHelper +import com.yandex.div.json.ValueValidator +import com.yandex.div.json.resolveFailed +import com.yandex.div.json.typeMismatch +import java.util.concurrent.ConcurrentHashMap + +private const val EXPR_SYMBOL = '@' +private const val EXPR_OPEN_BRACKET = '{' +private const val EXPR_OPEN_SYMBOLS = "$EXPR_SYMBOL$EXPR_OPEN_BRACKET" + +/** + * Expression represents a string with special placeholders that may reference to + * variables or operations above set of variables. For example: + * "Profile: @{name} (@{email})" + * can be [evaluate]d into "Profile: John (john1@email.net)" + */ +abstract class Expression { + /** + * Allows to get the value of expression which is based on provided resolver. + * WARNING! Resolvers like [ExpressionResolver.EMPTY] will generate errors. + * So choose you resolver with care. + */ + abstract fun evaluate(resolver: ExpressionResolver): T + + /** + * Subscribes to expression value changes. + * WARNING! Resolvers like [ExpressionResolver.EMPTY] will generate errors. + * So choose you resolver with care. + * @param resolver a context for expression evaluation. + */ + abstract fun observe(resolver: ExpressionResolver, callback: (T) -> Unit): Disposable + + /** + * Subscribes to expression value changes and invokes callback immediately for current value. + * WARNING! Resolvers like [ExpressionResolver.EMPTY] will generate errors. + * So choose you resolver with care. + * @param resolver a context for expression evaluation. + */ + open fun observeAndGet(resolver: ExpressionResolver, callback: (T) -> Unit): Disposable { + val value: T? = try { + evaluate(resolver) + } catch (e: ParsingException) { + // Ok to ignore, tryResolveOrUseLast will log about exception. + null + } + value?.let { + callback.invoke(it) + } + return observe(resolver, callback) + } + + /** + * Value of expression is mutable so we cannot use it in equals/hashcode + * instead we'll use its raw value. + */ + // TODO: set internal after TriggersController fix + abstract val rawValue: Any + + override fun equals(other: Any?): Boolean { + if (other !is Expression<*>) { + return false + } + + return rawValue == other.rawValue + } + + override fun hashCode(): Int { + return rawValue.hashCode() * 16 + } + + class ConstantExpression(private val value: T) : Expression() { + override val rawValue + get() = value as Any + + override fun evaluate(resolver: ExpressionResolver): T = value + + override fun observe(resolver: ExpressionResolver, callback: (T) -> Unit): Disposable { + return Disposable.NULL + } + + override fun observeAndGet( + resolver: ExpressionResolver, + callback: (T) -> Unit + ): Disposable { + callback(value) + return Disposable.NULL + } + } + + /** + * @param fieldDefaultValue default value of field where this expression is used. Used for fallbacks. + */ + class MutableExpression( + private val expressionKey: String, + private val rawExpression: String, + private val converter: Converter?, + private val validator: ValueValidator, + private val logger: ParsingErrorLogger, + private val typeHelper: TypeHelper, + private val fieldDefaultValue: Expression? = null, + ) : Expression() { + override val rawValue = rawExpression + private var evaluable: Evaluable? = null + + override fun evaluate(resolver: ExpressionResolver): T = tryResolveOrUseLast(resolver) + + override fun observe(resolver: ExpressionResolver, callback: (T) -> Unit): Disposable { + val variablesName = try { + getEvaluable().variables + } catch (e: Exception) { + logError(resolveFailed(expressionKey, rawExpression, e), resolver) + return Disposable.NULL + } + return variablesName + .ifEmpty { return Disposable.NULL } + .fold(CompositeDisposable()) { subscription, variable -> + subscription += resolver.onChange(variable) { + callback(evaluate(resolver)) + } + subscription + } + } + + private fun tryResolveOrUseLast(resolver: ExpressionResolver): T { + try { + val newValue = tryResolve(resolver) + lastValidValue = newValue + return newValue + } catch (e: ParsingException) { + logError(e, resolver) + lastValidValue?.let { + return it + } + + try { + fieldDefaultValue?.evaluate(resolver)?.let { + lastValidValue = it + return it + } + } catch (e: ParsingException) { + logError(e, resolver) + throw e + } + + return typeHelper.typeDefault + } + } + + private fun logError(e: ParsingException, resolver: ExpressionResolver) { + logger.logError(e) + resolver.notifyResolveFailed(e) + } + + private var lastValidValue: T? = null + + private fun tryResolve(resolver: ExpressionResolver): T { + val value = resolver.get( + expressionKey, + rawExpression, + getEvaluable(), + converter, + validator, + typeHelper, + logger + ) ?: throw resolveFailed(expressionKey, rawExpression) + + if (!typeHelper.isTypeValid(value)) { + throw typeMismatch( + expressionKey = expressionKey, + rawExpression = rawExpression, + wrongTypeValue = value, + ) + } + + return value + } + + private fun getEvaluable(): Evaluable { + return evaluable ?: try { + Evaluable.lazy(rawExpression).also { + evaluable = it + } + } catch (e: EvaluableException) { + throw resolveFailed(expressionKey, rawExpression, e) + } + } + } + + companion object { + private val pool = ConcurrentHashMap>(1000) + + @JvmStatic + fun constant(value: T): Expression { + val unTypedExpression = pool.getOrPut(value) { ConstantExpression(value) } + return unTypedExpression as Expression + } + + @JvmStatic + fun mayBeExpression(value: Any?): Boolean = value is String && + value.contains(EXPR_OPEN_SYMBOLS) + } + +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/expressions/ExpressionResolver.kt b/client/android/div-json/src/main/java/com/yandex/div/json/expressions/ExpressionResolver.kt new file mode 100644 index 000000000..1005c7204 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/expressions/ExpressionResolver.kt @@ -0,0 +1,63 @@ +package com.yandex.div.json.expressions + +import com.yandex.div.core.Disposable +import com.yandex.div.evaluable.Evaluable +import com.yandex.div.json.Converter +import com.yandex.div.json.ParsingErrorLogger +import com.yandex.div.json.ParsingException +import com.yandex.div.json.TypeHelper +import com.yandex.div.json.ValueValidator + +interface ExpressionResolver { + + /** + * Expects string inside expression brackets ('sum(var1, 1)' for expression '@{sum(var1, 1)}') + * and returns real value of expression according to inner state of actual resolver. + */ + fun get( + expressionKey: String, + rawExpression: String, + evaluable: Evaluable, + converter: Converter?, + validator: ValueValidator, + fieldType: TypeHelper, + logger: ParsingErrorLogger, + ): T? + + /** + * Create subscription on expression value change. + * @param variableName string defined within brackets '@{...}'. For example for '@{var1}' it should be 'var1'. + * @param callback an action performed when expression changes its value. + * @return subscription to [variableName] change or [Disposable.NULL] if one isn't found. + */ + fun onChange(variableName: String, callback: (T?) -> Unit): Disposable + + fun notifyResolveFailed(e: ParsingException) = Unit + + companion object { + /** + * PLEASE AVOID USING EMPTY EXPRESSION RESOLVER! + * It will throw exception on trying to resolve first non-constant expression + * (for example with variables). + */ + @JvmField + val EMPTY = object : ExpressionResolver { + + override fun get( + expressionKey: String, + rawExpression: String, + evaluable: Evaluable, + converter: Converter?, + validator: ValueValidator, + fieldType: TypeHelper, + logger: ParsingErrorLogger + ): T? { + return null + } + + override fun onChange(variableName: String, callback: (T?) -> Unit): Disposable { + return Disposable.NULL + } + } + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/expressions/ExpressionsList.kt b/client/android/div-json/src/main/java/com/yandex/div/json/expressions/ExpressionsList.kt new file mode 100644 index 000000000..038cd3e4b --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/expressions/ExpressionsList.kt @@ -0,0 +1,130 @@ +package com.yandex.div.json.expressions + +import com.yandex.div.core.CompositeDisposable +import com.yandex.div.core.Disposable +import com.yandex.div.json.ListValidator +import com.yandex.div.json.ParsingErrorLogger +import com.yandex.div.json.ParsingException +import com.yandex.div.json.invalidValue + +/** + * A container for collection of [Expression]s with validation of evaluated values. + */ +interface ExpressionsList { + /** + * Allows to get list of values from list of expression which are based on provided resolver. + * WARNING! Resolvers like [ExpressionResolver.EMPTY] will generate errors. + * So choose you resolver with care. + */ + @Throws(ParsingException::class) + fun evaluate(resolver: ExpressionResolver): List + + /** + * Subscribes to expression value changes. + * WARNING! Resolvers like [ExpressionResolver.EMPTY] will generate errors. + * So choose you resolver with care. + * @param resolver a context for expression evaluation. + */ + fun observe(resolver: ExpressionResolver, callback: (List) -> Unit): Disposable + + /** + * Subscribes to expression value changes and invokes callback immediately for current value. + * WARNING! Resolvers like [ExpressionResolver.EMPTY] will generate errors. + * So choose you resolver with care. + * @param resolver a context for expression evaluation. + */ + fun observeAndGet(resolver: ExpressionResolver, callback: (List) -> Unit): Disposable +} + +class ConstantExpressionsList( + private val valuesList: List, +) : ExpressionsList { + + override fun evaluate(resolver: ExpressionResolver): List { + return valuesList + } + + override fun observe(resolver: ExpressionResolver, callback: (List) -> Unit): Disposable { + return Disposable.NULL + } + + override fun observeAndGet( + resolver: ExpressionResolver, + callback: (List) -> Unit + ): Disposable { + callback(valuesList) + return Disposable.NULL + } + + override fun equals(other: Any?): Boolean { + return other is ConstantExpressionsList<*> && valuesList == other.valuesList + } +} + +internal class MutableExpressionsList( + private val key: String, + internal val expressionsList: List>, + private val listValidator: ListValidator, + private val logger: ParsingErrorLogger, +) : ExpressionsList { + + private var lastValidValuesList: List? = null + + override fun evaluate(resolver: ExpressionResolver): List { + try { + val value = tryResolve(resolver) + lastValidValuesList = value + return value + } catch (e: ParsingException) { + logger.logError(e) + lastValidValuesList?.let { + return it + } + + throw e + } + } + + private fun tryResolve(resolver: ExpressionResolver): List { + val valuesList = expressionsList.map { it.evaluate(resolver) } + if (!listValidator.isValid(valuesList)) { + throw invalidValue(key, valuesList) + } + return valuesList + } + + override fun observe(resolver: ExpressionResolver, callback: (List) -> Unit): Disposable { + val itemCallback = { _: T -> callback(evaluate(resolver)) } + + if (expressionsList.size == 1) { + return expressionsList.first().observe(resolver, itemCallback) + } + + val disposable = CompositeDisposable() + expressionsList.forEach { + disposable.add(it.observe(resolver, itemCallback)) + } + + return disposable + } + + override fun observeAndGet( + resolver: ExpressionResolver, + callback: (List) -> Unit + ): Disposable { + val disposable = observe(resolver, callback) + val value = try { + evaluate(resolver) + } catch (e: ParsingException) { + logger.logError(e) + null + } + + value?.let { callback(it) } + return disposable + } + + override fun equals(other: Any?): Boolean { + return other is MutableExpressionsList<*> && expressionsList == other.expressionsList + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/schema/Field.kt b/client/android/div-json/src/main/java/com/yandex/div/json/schema/Field.kt new file mode 100644 index 000000000..5d2a89fd8 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/schema/Field.kt @@ -0,0 +1,254 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.yandex.div.json.schema + +import com.yandex.div.json.JSONSerializable +import com.yandex.div.json.JsonTemplate +import com.yandex.div.json.ListValidator +import com.yandex.div.json.ParsingEnvironment +import com.yandex.div.json.ParsingException +import com.yandex.div.json.dependencyFailed +import com.yandex.div.json.expressions.Expression +import com.yandex.div.json.expressions.ExpressionsList +import com.yandex.div.json.invalidValue +import com.yandex.div.json.missingValue +import org.json.JSONObject + +typealias Reader = (String, JSONObject, env: ParsingEnvironment) -> T + +sealed class Field(val overridable: Boolean) { + + object Null : Field(overridable = false) + object Placeholder : Field(overridable = true) + class Value(overridable: Boolean, val value: T) : Field(overridable) + class Reference(overridable: Boolean, val reference: String) : Field(overridable) + + companion object { + + @Suppress("UNCHECKED_CAST") + fun nullField(overridable: Boolean): Field { + val nullField = if (overridable) Placeholder else Null + return nullField as Field + } + } +} + +fun Field.resolve( + env: ParsingEnvironment, + key: String, + data: JSONObject, + reader: Reader +): T { + return when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value -> value + this is Field.Reference -> reader.invoke(reference, data, env) + else -> throw missingValue(data, key) + } +} + +fun Field.resolveOptional( + env: ParsingEnvironment, + key: String, + data: JSONObject, + reader: Reader +): T? { + return when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value -> value + this is Field.Reference -> reader.invoke(reference, data, env) + else -> null + } +} + +fun Field>.resolveList( + env: ParsingEnvironment, + key: String, + data: JSONObject, + validator: ListValidator = ListValidator { true }, + reader: Reader> +): List { + val result = when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value -> value + this is Field.Reference -> reader.invoke(reference, data, env) + else -> throw missingValue(data, key) + } + + return if (validator.isValid(result)) { + result + } else { + throw invalidValue(data, key, result) + } +} + +fun Field>.resolveOptionalList( + env: ParsingEnvironment, + key: String, + data: JSONObject, + validator: ListValidator = ListValidator { true }, + reader: Reader?> +): List? { + val result = when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value -> value + this is Field.Reference -> reader.invoke(reference, data, env) + else -> null + } ?: return null + + return if (validator.isValid(result)) { + result + } else { + env.logger.logError(invalidValue(data, key, result)) + null + } +} + +fun Field>.resolveTemplate( + env: ParsingEnvironment, + key: String, + data: JSONObject, + reader: Reader +): T { + return when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value -> value.resolveDependency(env, key, data) + this is Field.Reference -> reader.invoke(reference, data, env) + else -> throw missingValue(data, key) + } +} + +fun Field>.resolveOptionalTemplate( + env: ParsingEnvironment, + key: String, + data: JSONObject, + reader: Reader +): T? { + return when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value -> value.resolveOptionalDependency(env, data) + this is Field.Reference -> reader.invoke(reference, data, env) + else -> null + } +} + +fun Field>>.resolveTemplateList( + env: ParsingEnvironment, + key: String, + data: JSONObject, + validator: ListValidator = ListValidator { true }, + reader: Reader> +): List { + val result = when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value -> value.mapNotNull { it.resolveOptionalDependency(env, data) } + this is Field.Reference -> reader.invoke(reference, data, env) + else -> throw missingValue(data, key) + } + + return if (validator.isValid(result)) { + result + } else { + throw invalidValue(data, key, result) + } +} + +fun Field>>.resolveOptionalTemplateList( + env: ParsingEnvironment, + key: String, + data: JSONObject, + validator: ListValidator = ListValidator { true }, + reader: Reader?> +): List? { + val result = when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value -> value.mapNotNull { it.resolveOptionalDependency(env, data) } + this is Field.Reference -> reader.invoke(reference, data, env) + else -> null + } ?: return null + + return if (validator.isValid(result)) { + result + } else { + env.logger.logError(invalidValue(data, key, result)) + null + } +} + +@PublishedApi +internal fun JsonTemplate.resolveDependency( + env: ParsingEnvironment, + key: String, + data: JSONObject +): T { + return try { + resolve(env, data) + } catch (e: ParsingException) { + throw dependencyFailed(data, key, e) + } +} + +@PublishedApi +internal fun JsonTemplate.resolveOptionalDependency( + env: ParsingEnvironment, + data: JSONObject +): T? { + return try { + resolve(env, data) + } catch (e: ParsingException) { + env.logger.logError(e) + null + } +} + +fun Field?.clone(overridable: Boolean): Field { + return when { + this == null || this == Field.Null || this == Field.Placeholder -> Field.nullField(overridable) + this is Field.Value -> Field.Value(overridable, value) + this is Field.Reference -> Field.Reference(overridable, reference) + else -> throw IllegalStateException("Unknown field type") + } +} + +fun Field>.resolveExpression( + env: ParsingEnvironment, + key: String, + data: JSONObject, + reader: Reader> +): Expression { + return when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value> -> value + this is Field.Reference -> reader.invoke(reference, data, env) + else -> throw missingValue(data, key) + } +} + +fun Field>.resolveExpressionList( + env: ParsingEnvironment, + key: String, + data: JSONObject, + reader: Reader> +): ExpressionsList { + return when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value -> value + this is Field.Reference -> reader.invoke(reference, data, env) + else -> throw missingValue(data, key) + } +} + + +fun Field>.resolveOptionalExpression( + env: ParsingEnvironment, + key: String, + data: JSONObject, + reader: Reader?> +): Expression? { + return when { + overridable && data.has(key) -> reader.invoke(key, data, env) + this is Field.Value> -> value + this is Field.Reference -> reader.invoke(reference, data, env) + else -> null + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/schema/StringUtils.java b/client/android/div-json/src/main/java/com/yandex/div/json/schema/StringUtils.java new file mode 100644 index 000000000..7f6d6d44a --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/schema/StringUtils.java @@ -0,0 +1,23 @@ +package com.yandex.div.json.schema; + +import androidx.annotation.NonNull; +import androidx.collection.ArrayMap; +import java.util.Map; +import java.util.regex.Pattern; + +public class StringUtils { + + private static final Map sPatterns = new ArrayMap<>(); + + public static boolean isMatched(@NonNull String str, @NonNull String pattern) { + Pattern p; + synchronized (sPatterns) { + p = sPatterns.get(pattern); + if (p == null) { + p = Pattern.compile(pattern); + sPatterns.put(pattern, p); + } + } + return p.matcher(str).matches(); + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/templates/InMemoryTemplateProvider.kt b/client/android/div-json/src/main/java/com/yandex/div/json/templates/InMemoryTemplateProvider.kt new file mode 100644 index 000000000..c61ca4d67 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/templates/InMemoryTemplateProvider.kt @@ -0,0 +1,18 @@ +package com.yandex.div.json.templates + +import com.yandex.div.json.JsonTemplate +import com.yandex.div.util.arrayMap + +class InMemoryTemplateProvider> : TemplateProvider { + private val templatesMap: MutableMap = arrayMap() + + override fun get(templateId: String) = templatesMap[templateId] + + internal fun put(templateId: String, jsonTemplate: T) { + templatesMap[templateId] = jsonTemplate + } + + internal fun takeSnapshot(target: MutableMap) { + target.putAll(templatesMap) + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/templates/MainTemplateProvider.kt b/client/android/div-json/src/main/java/com/yandex/div/json/templates/MainTemplateProvider.kt new file mode 100644 index 000000000..53afc65ed --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/templates/MainTemplateProvider.kt @@ -0,0 +1,33 @@ +package com.yandex.div.json.templates + +import com.yandex.div.json.JsonTemplate + +/** + * Mixes in-memory and persistent providers to provide best performance. + */ +open class MainTemplateProvider>( + private val inMemoryProvider: InMemoryTemplateProvider, + private var dbProvider: TemplateProvider, +) : TemplateProvider { + + override fun get(templateId: String): T? { + inMemoryProvider[templateId]?.let { + return it + } + + dbProvider[templateId]?.let { + inMemoryProvider.put(templateId, it) + return it + } + + return null + } + + fun takeSnapshot(target: MutableMap) { + inMemoryProvider.takeSnapshot(target) + } + + fun putAll(parsed: Map) { + parsed.forEach { inMemoryProvider.put(it.key, it.value) } + } +} diff --git a/client/android/div-json/src/main/java/com/yandex/div/json/templates/TemplateProvider.kt b/client/android/div-json/src/main/java/com/yandex/div/json/templates/TemplateProvider.kt new file mode 100644 index 000000000..df3e74088 --- /dev/null +++ b/client/android/div-json/src/main/java/com/yandex/div/json/templates/TemplateProvider.kt @@ -0,0 +1,30 @@ +package com.yandex.div.json.templates + +import com.yandex.div.json.JsonTemplate +import com.yandex.div.json.ParsingException +import com.yandex.div.json.templateNotFound +import org.json.JSONObject + +interface TemplateProvider> { + operator fun get(templateId: String): T? + + @Throws(ParsingException::class) + fun getOrThrow(templateId: String, json: JSONObject): T { + val template = get(templateId) + ?: throw templateNotFound(json = json, templateId = templateId) + + return template as T + } + + companion object { + fun > empty() = object : TemplateProvider { + override fun get(templateId: String): T? = null + } + + fun > wrap(map: Map) = object : TemplateProvider { + override fun get(templateId: String): T? { + return map[templateId] + } + } + } +} diff --git a/client/android/div-json/src/test/java/com/yandex/TestApplication.kt b/client/android/div-json/src/test/java/com/yandex/TestApplication.kt new file mode 100644 index 000000000..f23ec7e92 --- /dev/null +++ b/client/android/div-json/src/test/java/com/yandex/TestApplication.kt @@ -0,0 +1,11 @@ +package com.yandex + +import android.app.Application +import com.yandex.div.core.util.Assert + +class TestApplication : Application() { + + init { + Assert.setEnabled(true) + } +} diff --git a/client/android/div-json/src/test/java/com/yandex/div/json/JsonPrinterTest.kt b/client/android/div-json/src/test/java/com/yandex/div/json/JsonPrinterTest.kt new file mode 100644 index 000000000..b2e5e3d90 --- /dev/null +++ b/client/android/div-json/src/test/java/com/yandex/div/json/JsonPrinterTest.kt @@ -0,0 +1,87 @@ +package com.yandex.div.json + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class JsonPrinterTest { + + @Test + fun `print minified json`() { + val json = JSONObject("""{"key": "value"}""") + + val printer = JsonPrinter(indentSpaces = 0, nestingLimit = Int.MAX_VALUE) + + assertEquals("""{"key":"value"}""", printer.print(json)) + } + + @Test + fun `print json with 2 space indentation`() { + val json = JSONObject("""{"key": "value"}""") + + val printer = JsonPrinter(indentSpaces = 2, nestingLimit = Int.MAX_VALUE) + + assertEquals( + """ + |{ + | "key": "value" + |}""".trimMargin(), + printer.print(json) + ) + } + + @Test + fun `print json with 4 space indentation`() { + val json = JSONArray("""["value"]""") + + val printer = JsonPrinter(indentSpaces = 4, nestingLimit = Int.MAX_VALUE) + + assertEquals( + """ + |[ + | "value" + |]""".trimMargin(), + printer.print(json) + ) + } + + @Test + fun `nested object replaced with ellipsis`() { + val json = JSONObject("""{"object": {"key": "value"}}""") + + val printer = JsonPrinter(indentSpaces = 0, nestingLimit = 0) + + assertEquals("""{"object":"..."}""", printer.print(json)) + } + + @Test + fun `nested array replaced with ellipsis`() { + val json = JSONArray("""[["item 1", "item 2", "item 3", "item 4"]]""") + + val printer = JsonPrinter(indentSpaces = 0, nestingLimit = 0) + + assertEquals("""["..."]""", printer.print(json)) + } + + @Test + fun `objects replaced with ellipsis on second level of nesting`() { + val json = JSONObject("""{"array": [{"key": "value"}, {"key": "value"}]}""") + + val printer = JsonPrinter(indentSpaces = 0, nestingLimit = 1) + + assertEquals("""{"array":["...","..."]}""", printer.print(json)) + } + + @Test + fun `array replaced with ellipsis on second level of nesting`() { + val json = JSONObject("""{"object": {"array": ["item 1", "item 2"]}}""") + + val printer = JsonPrinter(indentSpaces = 0, nestingLimit = 1) + + assertEquals("""{"object":{"array":"..."}}""", printer.print(json)) + } +} diff --git a/client/android/div-json/src/test/java/com/yandex/div/json/JsonTopologicalSortingTest.kt b/client/android/div-json/src/test/java/com/yandex/div/json/JsonTopologicalSortingTest.kt new file mode 100644 index 000000000..0c26e3f87 --- /dev/null +++ b/client/android/div-json/src/test/java/com/yandex/div/json/JsonTopologicalSortingTest.kt @@ -0,0 +1,335 @@ +package com.yandex.div.json + +import org.json.JSONObject +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class JsonTopologicalSortingTest { + private val env = mock() + + @Test + fun `sorting of empty json`() { + val json = JSONObject() + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertTrue(sorted.isEmpty()) + } + + @Test + fun `sorting of forward ordered inheritance`() { + val json = read("forward-ordered-inheritance.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-2" to setOf("entity-1"), + "entity-3" to setOf("entity-2"), + "entity-4" to setOf("entity-3"), + ), sorted + ) + } + + @Test + fun `sorting of forward ordered composition`() { + val json = read("forward-ordered-composition.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-2" to setOf("entity-1"), + "entity-3" to setOf("entity-2"), + "entity-4" to setOf("entity-3"), + ), sorted + ) + } + + @Test + fun `sorting of forward ordered mixed usage`() { + val json = read("forward-ordered-mixed-usage.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-2" to setOf("entity-1"), + "entity-3" to setOf("entity-2"), + "entity-4" to setOf("entity-3"), + ), sorted + ) + } + + @Test + fun `sorting of reverse ordered inheritance`() { + val json = read("reverse-ordered-inheritance.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-2" to setOf("entity-1"), + "entity-3" to setOf("entity-2"), + "entity-4" to setOf("entity-3"), + ), sorted + ) + } + + @Test + fun `sorting of reverse ordered composition`() { + val json = read("reverse-ordered-composition.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-2" to setOf("entity-1"), + "entity-3" to setOf("entity-2"), + "entity-4" to setOf("entity-3"), + ), sorted + ) + } + + @Test + fun `sorting of reverse ordered mixed usage`() { + val json = read("reverse-ordered-mixed-usage.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-2" to setOf("entity-1"), + "entity-3" to setOf("entity-2"), + "entity-4" to setOf("entity-3"), + ), sorted + ) + } + + @Test + fun `sorting of forward ordered deep composition`() { + val json = read("forward-ordered-deep-composition.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-2" to setOf(), + "entity-3" to setOf("entity-2", "entity-1"), + ), sorted + ) + } + + @Test + fun `sorting of reverse ordered deep composition`() { + val json = read("reverse-ordered-deep-composition.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-2" to setOf(), + "entity-1" to setOf(), + "entity-3" to setOf("entity-2", "entity-1"), + ), sorted + ) + } + + @Test + fun `sorting of parallel dependency inheritance`() { + val json = read("parallel-dependency-inheritance.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-2" to setOf(), + "entity-4" to setOf("entity-2"), + "entity-1" to setOf(), + "entity-3" to setOf("entity-1"), + ), sorted + ) + } + + @Test + fun `sorting of parallel dependency composition`() { + val json = read("parallel-dependency-composition.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-2" to setOf(), + "entity-4" to setOf("entity-2"), + "entity-1" to setOf(), + "entity-3" to setOf("entity-1"), + ), sorted + ) + } + + @Test + fun `sorting of parallel dependency mixed usage`() { + val json = read("parallel-dependency-mixed-usage.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-2" to setOf(), + "entity-4" to setOf("entity-2"), + "entity-1" to setOf(), + "entity-3" to setOf("entity-1"), + ), sorted + ) + } + + @Test + fun `sorting of tree dependency inheritance`() { + val json = read("tree-dependency-inheritance.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-3" to setOf("entity-1"), + "entity-4" to setOf("entity-3"), + "entity-2" to setOf("entity-1"), + ), sorted + ) + } + + @Test + fun `sorting of tree dependency composition`() { + val json = read("tree-dependency-composition.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-3" to setOf("entity-1"), + "entity-4" to setOf("entity-3"), + "entity-2" to setOf("entity-1"), + ), sorted + ) + } + + @Test + fun `sorting of tree dependency mixed usage`() { + val json = read("tree-dependency-mixed-usage.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-3" to setOf("entity-1"), + "entity-4" to setOf("entity-3"), + "entity-2" to setOf("entity-1"), + ), sorted + ) + } + + @Test + fun `sorting of graph dependency composition`() { + val json = read("graph-dependency-composition.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-3" to setOf("entity-1"), + "entity-2" to setOf("entity-1"), + "entity-4" to setOf("entity-3", "entity-2"), + ), sorted + ) + } + + @Test + fun `sorting of graph dependency mixed usage`() { + val json = read("graph-dependency-mixed-usage.json") + val sorted = JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + + assertMapsAreEqualRespectingOrder( + linkedMapOf( + "entity-1" to setOf(), + "entity-3" to setOf("entity-1"), + "entity-2" to setOf("entity-1"), + "entity-4" to setOf("entity-3", "entity-2"), + ), sorted + ) + } + + @Test(expected = CyclicDependencyException::class) + fun `sorting of cyclic dependency inheritance fails`() { + val json = read("cyclic-dependency-inheritance.json") + JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + } + + @Test(expected = CyclicDependencyException::class) + fun `sorting of cyclic dependency composition fails`() { + val json = read("cyclic-dependency-composition.json") + JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + } + + @Test(expected = CyclicDependencyException::class) + fun `sorting of cyclic dependency mixed usage fails`() { + val json = read("cyclic-dependency-mixed-usage.json") + JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + } + + @Test(expected = CyclicDependencyException::class) + fun `sorting of self-dependent inheritance fails`() { + val json = read("self-dependent-inheritance.json") + JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + } + + @Test(expected = CyclicDependencyException::class) + fun `sorting of self-dependent composition fails`() { + val json = read("self-dependent-composition.json") + JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + } + + @Test(expected = ParsingException::class) + fun `sorting of missing parent inheritance fails`() { + val json = read("missing-parent-inheritance.json") + JsonTopologicalSorting.sort(json, ParsingErrorLogger.LOG, env) + } + + @Test(expected = ParsingException::class) + fun `sorting of empty parent inheritance fails`() { + val json = read("missing-parent-inheritance.json") + JsonTopologicalSorting.sort(json, ParsingErrorLogger.LOG, env) + } + + @Test(expected = AssertionError::class) + fun `sorting of empty parent composition logs error`() { + val json = read("empty-parent-composition.json") + JsonTopologicalSorting.sort(json, ParsingErrorLogger.ASSERT, env) + } + + private fun read(filename: String): JSONObject { + val jsonStream = javaClass.classLoader?.getResourceAsStream("com/yandex/div/core/json/$filename") + if (jsonStream != null) return JSONObject(jsonStream.reader().readText()) + return JSONObject() + } + + private fun assertMapsAreEqualRespectingOrder( + expected: Map, + actual: Map + ) { + assertTrue("Expected: $expected, actual: $actual", expected.equalsRespectingOrder(actual)) + } + + private fun Map.equalsRespectingOrder(other: Map): Boolean { + if (size != other.size) return false + + val it1 = iterator() + val it2 = other.iterator() + while (it1.hasNext()) { + val entry1 = it1.next() + val entry2 = it2.next() + if (entry1.key != entry2.key || entry1.value != entry2.value) { + return false + } + } + return true + } +} diff --git a/client/android/div-json/src/test/java/com/yandex/div/json/JsonUtilsTest.kt b/client/android/div-json/src/test/java/com/yandex/div/json/JsonUtilsTest.kt new file mode 100644 index 000000000..e7491ce0c --- /dev/null +++ b/client/android/div-json/src/test/java/com/yandex/div/json/JsonUtilsTest.kt @@ -0,0 +1,41 @@ +package com.yandex.div.json + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class JsonUtilsTest { + + @Test + fun `getStringOrEmpty() gets string value`() { + val json = JSONObject("""{ "key": "value" }""") + assertEquals("value", json.getStringOrEmpty("key")) + } + + @Test + fun `getStringOrEmpty() is empty for int value`() { + val json = JSONObject("""{ "key": 123 }""") + assertEquals("", json.getStringOrEmpty("key")) + } + + @Test + fun `getStringOrEmpty() is empty for null value`() { + val json = JSONObject("""{ "key": null }""") + assertEquals("", json.getStringOrEmpty("key")) + } + + @Test + fun `getStringOrEmpty() is empty for object value`() { + val json = JSONObject("""{ "key": { "inner_key": "value" } }""") + assertEquals("", json.getStringOrEmpty("key")) + } + + @Test + fun `getStringOrEmpty() is empty for missing key`() { + val json = JSONObject("""{ }""") + assertEquals("", json.getStringOrEmpty("key")) + } +} diff --git a/client/android/div-json/src/test/java/com/yandex/div/json/StringUtilsTest.java b/client/android/div-json/src/test/java/com/yandex/div/json/StringUtilsTest.java new file mode 100644 index 000000000..ab4680ef1 --- /dev/null +++ b/client/android/div-json/src/test/java/com/yandex/div/json/StringUtilsTest.java @@ -0,0 +1,14 @@ +package com.yandex.div.json; + +import com.yandex.div.json.schema.StringUtils; +import org.junit.Assert; +import org.junit.Test; + +public class StringUtilsTest { + + @Test + public void testIsMatched() { + Assert.assertTrue(StringUtils.isMatched("13:21", "^\\d{1,2}\\:\\d{2}$")); + Assert.assertFalse(StringUtils.isMatched(":21", "^\\d{1,2}\\:\\d{2}$")); + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/cyclic-dependency-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/cyclic-dependency-composition.json new file mode 100644 index 000000000..3e22f14b7 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/cyclic-dependency-composition.json @@ -0,0 +1,26 @@ +{ + "entity-1": { + "type": "base", + "child": { + "type": "entity-2" + } + }, + "entity-2": { + "type": "base", + "child": { + "type": "entity-3" + } + }, + "entity-3": { + "type": "base", + "child": { + "type": "entity-4" + } + }, + "entity-4": { + "type": "base", + "child": { + "type": "entity-1" + } + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/cyclic-dependency-inheritance.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/cyclic-dependency-inheritance.json new file mode 100644 index 000000000..3e2d3181f --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/cyclic-dependency-inheritance.json @@ -0,0 +1,14 @@ +{ + "entity-1": { + "type": "entity-2" + }, + "entity-2": { + "type": "entity-3" + }, + "entity-3": { + "type": "entity-4" + }, + "entity-4": { + "type": "entity-1" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/cyclic-dependency-mixed-usage.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/cyclic-dependency-mixed-usage.json new file mode 100644 index 000000000..fc63c70ee --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/cyclic-dependency-mixed-usage.json @@ -0,0 +1,20 @@ +{ + "entity-1": { + "type": "entity-2" + }, + "entity-2": { + "type": "base", + "child": { + "type": "entity-3" + } + }, + "entity-3": { + "type": "entity-4" + }, + "entity-4": { + "type": "base", + "child": { + "type": "entity-1" + } + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/empty-parent-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/empty-parent-composition.json new file mode 100644 index 000000000..0eb0ab476 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/empty-parent-composition.json @@ -0,0 +1,8 @@ +{ + "entity": { + "type": "base", + "child": { + "type": "" + } + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/empty-parent-inheritance.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/empty-parent-inheritance.json new file mode 100644 index 000000000..dab6ac759 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/empty-parent-inheritance.json @@ -0,0 +1,5 @@ +{ + "entity": { + "type": "" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-composition.json new file mode 100644 index 000000000..4dc6ff15b --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-composition.json @@ -0,0 +1,23 @@ +{ + "entity-1": { + "type": "base" + }, + "entity-2": { + "type": "base", + "child": { + "type": "entity-1" + } + }, + "entity-3": { + "type": "base", + "child": { + "type": "entity-2" + } + }, + "entity-4": { + "type": "base", + "child": { + "type": "entity-3" + } + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-deep-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-deep-composition.json new file mode 100644 index 000000000..38821451a --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-deep-composition.json @@ -0,0 +1,17 @@ +{ + "entity-1": { + "type": "base" + }, + "entity-2": { + "type": "base" + }, + "entity-3": { + "type": "base", + "child": { + "type": "entity-2", + "grandchild": { + "type" : "entity-1" + } + } + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-inheritance.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-inheritance.json new file mode 100644 index 000000000..a25ba69bc --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-inheritance.json @@ -0,0 +1,14 @@ +{ + "entity-1": { + "type": "base" + }, + "entity-2": { + "type": "entity-1" + }, + "entity-3": { + "type": "entity-2" + }, + "entity-4": { + "type": "entity-3" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-mixed-usage.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-mixed-usage.json new file mode 100644 index 000000000..24186c844 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/forward-ordered-mixed-usage.json @@ -0,0 +1,20 @@ +{ + "entity-1": { + "type": "base", + "child": { + "type": "entity-0" + } + }, + "entity-2": { + "type": "entity-1" + }, + "entity-3": { + "type": "base", + "child": { + "type": "entity-2" + } + }, + "entity-4": { + "type": "entity-3" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/graph-dependency-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/graph-dependency-composition.json new file mode 100644 index 000000000..52ba69f59 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/graph-dependency-composition.json @@ -0,0 +1,26 @@ +{ + "entity-4": { + "type": "base", + "child-1": { + "type": "entity-3" + }, + "child-2": { + "type": "entity-2" + } + }, + "entity-2": { + "type": "base", + "child": { + "type": "entity-1" + } + }, + "entity-3": { + "type": "base", + "child": { + "type": "entity-1" + } + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/graph-dependency-mixed-usage.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/graph-dependency-mixed-usage.json new file mode 100644 index 000000000..036ce61eb --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/graph-dependency-mixed-usage.json @@ -0,0 +1,20 @@ +{ + "entity-4": { + "type": "entity-3", + "child": { + "type": "entity-2" + } + }, + "entity-2": { + "type": "entity-1" + }, + "entity-3": { + "type": "base", + "child": { + "type": "entity-1" + } + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/missing-parent-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/missing-parent-composition.json new file mode 100644 index 000000000..7883bb6a4 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/missing-parent-composition.json @@ -0,0 +1,7 @@ +{ + "entity": { + "type": "base", + "child": { + } + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/missing-parent-inheritance.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/missing-parent-inheritance.json new file mode 100644 index 000000000..7efb6b2b2 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/missing-parent-inheritance.json @@ -0,0 +1,4 @@ +{ + "entity": { + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/parallel-dependency-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/parallel-dependency-composition.json new file mode 100644 index 000000000..8df4c9d3a --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/parallel-dependency-composition.json @@ -0,0 +1,20 @@ +{ + "entity-4": { + "type": "base", + "child": { + "type": "entity-2" + } + }, + "entity-3": { + "type": "base", + "child": { + "type": "entity-1" + } + }, + "entity-2": { + "type": "base" + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/parallel-dependency-inheritance.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/parallel-dependency-inheritance.json new file mode 100644 index 000000000..ef572a357 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/parallel-dependency-inheritance.json @@ -0,0 +1,14 @@ +{ + "entity-4": { + "type": "entity-2" + }, + "entity-3": { + "type": "entity-1" + }, + "entity-2": { + "type": "base" + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/parallel-dependency-mixed-usage.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/parallel-dependency-mixed-usage.json new file mode 100644 index 000000000..d8dae7e28 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/parallel-dependency-mixed-usage.json @@ -0,0 +1,17 @@ +{ + "entity-4": { + "type": "base", + "child": { + "type": "entity-2" + } + }, + "entity-3": { + "type": "entity-1" + }, + "entity-2": { + "type": "base" + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-composition.json new file mode 100644 index 000000000..29789b23f --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-composition.json @@ -0,0 +1,23 @@ +{ + "entity-4": { + "type": "base", + "child": { + "type": "entity-3" + } + }, + "entity-3": { + "type": "base", + "child": { + "type": "entity-2" + } + }, + "entity-2": { + "type": "base", + "child": { + "type": "entity-1" + } + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-deep-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-deep-composition.json new file mode 100644 index 000000000..834bff579 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-deep-composition.json @@ -0,0 +1,17 @@ +{ + "entity-3": { + "type": "base", + "child": { + "type": "entity-2", + "grandchild": { + "type" : "entity-1" + } + } + }, + "entity-2": { + "type": "base" + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-inheritance.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-inheritance.json new file mode 100644 index 000000000..daeca5d36 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-inheritance.json @@ -0,0 +1,14 @@ +{ + "entity-4": { + "type": "entity-3" + }, + "entity-3": { + "type": "entity-2" + }, + "entity-2": { + "type": "entity-1" + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-mixed-usage.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-mixed-usage.json new file mode 100644 index 000000000..246256590 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/reverse-ordered-mixed-usage.json @@ -0,0 +1,20 @@ +{ + "entity-4": { + "type": "base", + "child": { + "type": "entity-3" + } + }, + "entity-3": { + "type": "entity-2" + }, + "entity-2": { + "type": "base", + "child": { + "type": "entity-1" + } + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/self-dependent-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/self-dependent-composition.json new file mode 100644 index 000000000..8a1fff339 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/self-dependent-composition.json @@ -0,0 +1,8 @@ +{ + "entity": { + "type": "base", + "child": { + "type": "entity" + } + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/self-dependent-inheritance.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/self-dependent-inheritance.json new file mode 100644 index 000000000..2b7dbd114 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/self-dependent-inheritance.json @@ -0,0 +1,5 @@ +{ + "entity": { + "type": "entity" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/tree-dependency-composition.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/tree-dependency-composition.json new file mode 100644 index 000000000..d9c248d51 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/tree-dependency-composition.json @@ -0,0 +1,23 @@ +{ + "entity-4": { + "type": "base", + "child": { + "type": "entity-3" + } + }, + "entity-2": { + "type": "base", + "child": { + "type": "entity-1" + } + }, + "entity-3": { + "type": "base", + "child": { + "type": "entity-1" + } + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/tree-dependency-inheritance.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/tree-dependency-inheritance.json new file mode 100644 index 000000000..6089f04c7 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/tree-dependency-inheritance.json @@ -0,0 +1,14 @@ +{ + "entity-4": { + "type": "entity-3" + }, + "entity-2": { + "type": "entity-1" + }, + "entity-3": { + "type": "entity-1" + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/com/yandex/div/core/json/tree-dependency-mixed-usage.json b/client/android/div-json/src/test/resources/com/yandex/div/core/json/tree-dependency-mixed-usage.json new file mode 100644 index 000000000..c059c9ff9 --- /dev/null +++ b/client/android/div-json/src/test/resources/com/yandex/div/core/json/tree-dependency-mixed-usage.json @@ -0,0 +1,17 @@ +{ + "entity-4": { + "type": "entity-3" + }, + "entity-2": { + "type": "entity-1" + }, + "entity-3": { + "type": "base", + "child": { + "type": "entity-1" + } + }, + "entity-1": { + "type": "base" + } +} diff --git a/client/android/div-json/src/test/resources/robolectric.properties b/client/android/div-json/src/test/resources/robolectric.properties new file mode 100644 index 000000000..d2ec25387 --- /dev/null +++ b/client/android/div-json/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +sdk=28 +application=com.yandex.TestApplication diff --git a/client/android/div-legacy/YAOWNERS b/client/android/div-legacy/YAOWNERS new file mode 100644 index 000000000..25c4bd74b --- /dev/null +++ b/client/android/div-legacy/YAOWNERS @@ -0,0 +1,11 @@ +gulevsky@yandex-team.ru +tayrinn@yandex-team.ru +jelyscs@yandex-team.ru +syntezzz@yandex-team.ru +anarkh@yandex-team.ru +ttok@yandex-team.ru +i-ts@yandex-team.ru +geaden@yandex-team.ru + +set noparent +set lock diff --git a/client/android/div-legacy/build.gradle b/client/android/div-legacy/build.gradle new file mode 100644 index 000000000..6ba51b136 --- /dev/null +++ b/client/android/div-legacy/build.gradle @@ -0,0 +1,24 @@ +apply from: "${project.projectDir}/../../common-lib.gradle" +apply from: "${project.projectDir}/../../tests.gradle" +apply from: "${project.projectDir}/../../aspects.gradle" +apply from: "${project.projectDir}/../../publish_alicekit.gradle" + +dependencies { + implementation project(path: ':core') + implementation project(path: ':json') + implementation project(path: ':div-json') + implementation project(path: ':logging') + implementation project(path: ':images') + implementation project(path: ':div-default-image-loader') + implementation project(path: ':div-core') + + api project(path: ':div-legacy-data') + api project(path: ':div-data') + api project(path: ':div-core-views') + + implementation "com.google.dagger:dagger:$versions.dagger" + kapt "com.google.dagger:dagger-compiler:$versions.dagger" + + implementation "androidx.appcompat:appcompat:$versions.androidx.appcompat" + implementation "androidx.recyclerview:recyclerview:$versions.androidx.recycler" +} diff --git a/client/android/div-legacy/jacoco.excludes b/client/android/div-legacy/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-legacy/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-legacy/proguard-rules.pro b/client/android/div-legacy/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/client/android/div-legacy/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/client/android/div-legacy/src/main/AndroidManifest.xml b/client/android/div-legacy/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0a61b88bc --- /dev/null +++ b/client/android/div-legacy/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/Alignment.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/Alignment.java new file mode 100644 index 000000000..2f8bf96ed --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/Alignment.java @@ -0,0 +1,7 @@ +package com.yandex.div.legacy; + +public enum Alignment { + LEFT, + CENTER, + RIGHT +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/BackgroundImageDrawable.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/BackgroundImageDrawable.java new file mode 100644 index 000000000..b5c07980d --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/BackgroundImageDrawable.java @@ -0,0 +1,86 @@ +package com.yandex.div.legacy; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.images.utils.ThumbnailUtils; + +public class BackgroundImageDrawable extends Drawable { + private final Rect mDrawableBounds = new Rect(); + @NonNull + private final Context mContext; + @NonNull + private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG); + + @Nullable + private Bitmap mOriginalBitmap; + + @Nullable + private Matrix mThumbTransformMatrix; + + public BackgroundImageDrawable(@NonNull Context context) { + mContext = context; + } + + @Override + protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + mDrawableBounds.set(bounds); + + onChanged(); + } + + public void setOriginalBitmap(@NonNull Bitmap bitmap) { + mOriginalBitmap = bitmap; + + onChanged(); + } + + private void onChanged() { + if (mOriginalBitmap == null) { + return; + } + + if (mDrawableBounds.width() == 0 || mDrawableBounds.height() == 0) { + return; + } + + mThumbTransformMatrix = ThumbnailUtils.makeThumbnailTransformMatrix(mOriginalBitmap, mDrawableBounds.width(), mDrawableBounds.height(), 0); + invalidateSelf(); + } + + @Override + public void draw(@NonNull Canvas canvas) { + if (mOriginalBitmap == null || mThumbTransformMatrix == null) { + return; + } + canvas.save(); + canvas.clipRect(mDrawableBounds); + canvas.drawBitmap(mOriginalBitmap, mThumbTransformMatrix, mPaint); + canvas.restore(); + } + + @Override + public void setAlpha(@IntRange(from = 0, to = 255) int alpha) { + mPaint.setAlpha(alpha); + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter) { + mPaint.setColorFilter(colorFilter); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivAutoLogger.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivAutoLogger.java new file mode 100644 index 000000000..cced57167 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivAutoLogger.java @@ -0,0 +1,33 @@ +package com.yandex.div.legacy; + +import android.net.Uri; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.annotations.PublicApi; + +/** + * Contains methods for SearchApp automatic logging. + */ +@PublicApi +public interface DivAutoLogger { + + DivAutoLogger DEFAULT = new DivAutoLogger() { + + @Override + public void setId(@NonNull View view, @NonNull String id) { + } + + @Override + public void logPopupMenuItemClick(@NonNull View view, int position, @Nullable String text) { + } + }; + + void setId(@NonNull View view, @NonNull String id); + + void logPopupMenuItemClick(@NonNull View view, int position, @Nullable String text); + + default void logPopupMenuItemClick(@NonNull View view, int position, @Nullable String text, @Nullable Uri url) { + logPopupMenuItemClick(view, position, text); + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivContext.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivContext.java new file mode 100644 index 000000000..6eb4b17fb --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivContext.java @@ -0,0 +1,104 @@ +package com.yandex.div.legacy; + +import android.app.Activity; +import android.content.Context; +import android.util.AttributeSet; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.annotations.PublicApi; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.util.CustomInflaterContext; +import com.yandex.div.legacy.dagger.DaggerDivComponent; +import com.yandex.div.legacy.dagger.DivComponent; +import com.yandex.div.legacy.view.DivView; + +/** + * Context to be used to create instance of {@link DivView} + * Note: if you want to inflate a DivView from XML layout file you should get an inflater as following + *

    + *     {@code
    + *     DivContext divContext = new DivContext(activity, divConfiguration);
    + *     LayoutInflater inflater = (LayoutInflater) divContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    + *     }
    + * 
    + * or + *
    + *     {@code
    + *     DivContext divContext = new DivContext(activity, divConfiguration);
    + *     LayoutInflater inflater = LayoutInflater.from(divContext);
    + *     }
    + * 
    + */ +@PublicApi +public class DivContext extends CustomInflaterContext { + + @NonNull + private final DivComponent mComponent; + + @MainThread + public DivContext(ContextThemeWrapper baseContext, @NonNull DivLegacyConfiguration configuration) { + super(baseContext); + + Assert.assertMainThread(); + mComponent = DaggerDivComponent.builder() + .baseContext(baseContext) + .configuration(configuration) + .build(); + } + + @MainThread + public DivContext(Activity activity, @NonNull DivLegacyConfiguration configuration) { + this((ContextThemeWrapper) activity, configuration); + } + + @NonNull + @Override + public LayoutInflater.Factory2 createInflaterFactory() { + return new DivInflaterFactory(this); + } + + @NonNull + public DivComponent getComponent() { + return mComponent; + } + + public void warmUp() { + mComponent.getViewBuilder(); + } + + private static class DivInflaterFactory implements LayoutInflater.Factory2 { + + private static final String DIV_VIEW_CLASS_NAME = "com.yandex.div.core.DivView"; + private static final String DIV_VIEW_SIMPLE_CLASS_NAME = "DivView"; + + @NonNull + private final DivContext mDivContext; + + DivInflaterFactory(@NonNull DivContext context) { + mDivContext = context; + } + + @Nullable + @Override + public View onCreateView(View parent, String name, Context context, AttributeSet attrs) { + return onCreateView(name, context, attrs); + } + + @Nullable + @Override + public View onCreateView(String name, Context context, AttributeSet attrs) { + if (isDivView(name)) { + return new DivView(mDivContext, attrs); + } + return null; + } + + private boolean isDivView(@NonNull String viewClassName) { + return DIV_VIEW_CLASS_NAME.equals(viewClassName) || DIV_VIEW_SIMPLE_CLASS_NAME.equals(viewClassName); + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivDataValidator.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivDataValidator.java new file mode 100644 index 000000000..d7b93b66e --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivDataValidator.java @@ -0,0 +1,176 @@ +package com.yandex.div.legacy; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import com.yandex.div.DivButtonsBlock; +import com.yandex.div.DivContainerBlock; +import com.yandex.div.DivData; +import com.yandex.div.DivFooterBlock; +import com.yandex.div.DivGalleryBlock; +import com.yandex.div.DivImageBlock; +import com.yandex.div.DivSeparatorBlock; +import com.yandex.div.DivTableBlock; +import com.yandex.div.DivTabsBlock; +import com.yandex.div.DivTitleBlock; +import com.yandex.div.DivTrafficBlock; +import com.yandex.div.DivUniversalBlock; +import com.yandex.div.legacy.view.ButtonsDivBlockViewBuilder; +import com.yandex.div.legacy.view.DivTableBlockViewBuilder; +import com.yandex.div.legacy.view.FooterDivViewBuilder; +import com.yandex.div.legacy.view.UniversalDivViewBuilder; +import java.util.List; + +/** + * Validates div data blocks against their specifications. + *

    + * NOTE: This class is not thread safe! + */ +@WorkerThread +public class DivDataValidator extends DivVisitor { + private final Processor mProcessor = new Processor(); + + /** + * Process validation on each div block within each div state. Amount of valid blocks will be written under + * corresponding to a div state index in given {@code answer} array. + * + * @param divData div data for validation + * @param answer the array into which the data of validation are to be + * stored, if it is big enough; otherwise, a new array of the same + * runtime type is allocated for this purpose. + * @return an array containing validation data + */ + @NonNull + public int[] validate(@NonNull DivData divData, @Nullable int[] answer) { + final int statesSize = divData.states.size(); + if (answer == null || answer.length != statesSize) { + answer = new int[statesSize]; + } + + for (int i = 0; i < statesSize; i++) { + mProcessor.reset(); + + final DivData.State state = divData.states.get(i); + for (DivData.State.Block block : state.blocks) { + visit(block); + } + + answer[i] = mProcessor.get(); + } + + return answer; + } + + @Nullable + @Override + protected Void visit(@NonNull DivButtonsBlock data) { + if (ButtonsDivBlockViewBuilder.isValidBlock(data)) { + mProcessor.increment(); + } + return null; + } + + @Nullable + @Override + protected Void visit(@NonNull DivFooterBlock data) { + if (FooterDivViewBuilder.isValidBlock(data)) { + mProcessor.increment(); + } + return null; + } + + @Nullable + @Override + protected Void visit(@NonNull DivImageBlock data) { + if (LegacyDivDataUtils.isDivImageValid(data.image)) { + mProcessor.increment(); + } + return null; + } + + @Nullable + @Override + protected Void visit(@NonNull DivSeparatorBlock data) { + return null; // Separator block does not count towards data validation + } + + @Nullable + @Override + protected Void visit(@NonNull DivTableBlock data) { + if (DivTableBlockViewBuilder.validateAndGetColumnCount(data) > 0) { + mProcessor.increment(); + } + return null; + } + + @Nullable + @Override + protected Void visit(@NonNull DivTitleBlock data) { + if (LegacyDivDataUtils.isValidBlock(data)) { + mProcessor.increment(); + } + return null; + } + + @Nullable + @Override + protected Void visit(@NonNull DivTrafficBlock data) { + if (LegacyDivDataUtils.isValidBlock(data)) { + mProcessor.increment(); + } + return null; + } + + @Nullable + @Override + protected Void visit(@NonNull DivUniversalBlock data) { + if (UniversalDivViewBuilder.isValidBlock(data)) { + mProcessor.increment(); + } + return null; + } + + @Nullable + @Override + protected Void visit(@NonNull DivGalleryBlock data) { + final List items = data.items; + for (final DivContainerBlock item : items) { + visit(item); + } + return null; + } + + @Nullable + @Override + protected Void visit(@NonNull DivContainerBlock data) { + for (final DivContainerBlock.Children child : data.children) { + visit(child); + } + return null; + } + + @Nullable + @Override + protected Void visit(@NonNull DivTabsBlock data) { + for (final DivTabsBlock.Item item : data.items) { + visit(item.content); + } + return null; + } + + private static class Processor { + private int mCounter = 0; + + void reset() { + mCounter = 0; + } + + void increment() { + mCounter++; + } + + int get() { + return mCounter; + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivInternalLogger.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivInternalLogger.kt new file mode 100644 index 000000000..742d328c4 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivInternalLogger.kt @@ -0,0 +1,29 @@ +package com.yandex.div.legacy + +import com.yandex.div.DivBaseBlock +import com.yandex.div.legacy.dagger.DivLegacyScope +import com.yandex.div.legacy.view.DivView +import com.yandex.metrica.IReporterInternal +import dagger.Lazy +import javax.inject.Inject + +@DivLegacyScope +internal class DivInternalLogger @Inject constructor( + private val reporterLazy: Lazy +) { + + private val reporter: IReporterInternal + get() = reporterLazy.get() + + fun logViewBuildingFailure(divView: DivView, data: DivBaseBlock) { + val params = mapOf( + "div view id" to divView.divTag.id, + "div block id" to data.blockId + ) + reporter.reportEvent(EVENT_BUILDING_FAILURE, params) + } + + companion object { + private const val EVENT_BUILDING_FAILURE = "DIV_VIEW_BUILDING_FAILURE" + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivLegacyConfiguration.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivLegacyConfiguration.java new file mode 100644 index 000000000..cc6499a35 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivLegacyConfiguration.java @@ -0,0 +1,147 @@ +package com.yandex.div.legacy; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.annotations.PublicApi; +import com.yandex.alicekit.core.experiments.ExperimentConfig; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.legacy.state.LegacyDivStateCache; +import com.yandex.div.legacy.state.LegacyInMemoryDivStateCache; +import dagger.Module; +import dagger.Provides; + +/** + * Holds DivView configuration. + * Create instance using {@link Builder} class. + */ +@PublicApi +@Module +public class DivLegacyConfiguration { + + @NonNull + private final DivAutoLogger mAutoLogger; + @NonNull + private final DivImageLoader mImageLoader; + @NonNull + private final LegacyDivActionHandler mActionHandler; + @NonNull + private final ExperimentConfig mExperimentConfig; + @NonNull + private final DivLogger mDivLogger; + @NonNull + private final LegacyDivStateCache mDivStateCache; + + private DivLegacyConfiguration( + @NonNull DivAutoLogger autoLogger, + @NonNull DivImageLoader imageLoader, + @NonNull LegacyDivActionHandler actionHandler, + @NonNull ExperimentConfig experimentConfig, + @NonNull DivLogger divLogger, + @NonNull LegacyDivStateCache divStateCache + ) { + mAutoLogger = autoLogger; + mImageLoader = imageLoader; + mActionHandler = actionHandler; + mExperimentConfig = experimentConfig; + mDivLogger = divLogger; + mDivStateCache = divStateCache; + } + + @Provides + @NonNull + public LegacyDivActionHandler getActionHandler() { + return mActionHandler; + } + + @Provides + @NonNull + public DivAutoLogger getAutoLogger() { + return mAutoLogger; + } + + @Provides + @NonNull + public DivImageLoader getImageLoader() { + return mImageLoader; + } + + @Provides + @NonNull + public ExperimentConfig getExperimentConfig() { + return mExperimentConfig; + } + + @Provides + @NonNull + public DivLogger getDivLogger() { + return mDivLogger; + } + + @Provides + @NonNull + public LegacyDivStateCache getDivStateCache() { + return mDivStateCache; + } + + public static class Builder { + + @NonNull + private final DivImageLoader mImageLoader; + @Nullable + private DivAutoLogger mAutoLogger; + @Nullable + private LegacyDivActionHandler mActionHandler; + @Nullable + private ExperimentConfig mExperimentConfig; + @Nullable + private DivLogger mDivLogger; + @Nullable + private LegacyDivStateCache mDivStateCache; + + public Builder(@NonNull DivImageLoader imageLoader) { + mImageLoader = imageLoader; + } + + @NonNull + public Builder autoLogger(@NonNull DivAutoLogger autoLogger) { + mAutoLogger = autoLogger; + return this; + } + + @NonNull + public Builder actionHandler(@NonNull LegacyDivActionHandler actionHandler) { + mActionHandler = actionHandler; + return this; + } + + @NonNull + public Builder experimentConfig(@NonNull ExperimentConfig experimentConfig) { + mExperimentConfig = experimentConfig; + return this; + } + + @NonNull + public Builder divLogger(@NonNull DivLogger logger) { + mDivLogger = logger; + return this; + } + + @NonNull + public Builder divStateCache(@NonNull LegacyDivStateCache divStateCache) { + mDivStateCache = divStateCache; + return this; + } + + @NonNull + public DivLegacyConfiguration build() { + return new DivLegacyConfiguration( + mAutoLogger == null ? DivAutoLogger.DEFAULT : mAutoLogger, + mImageLoader, + mActionHandler == null ? new LegacyDivActionHandler() : mActionHandler, + mExperimentConfig == null ? new ExperimentConfig() : mExperimentConfig, + mDivLogger == null ? DivLogger.STUB : mDivLogger, + mDivStateCache == null ? new LegacyInMemoryDivStateCache() : mDivStateCache + ); + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivLogger.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivLogger.java new file mode 100644 index 000000000..b49f69336 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivLogger.java @@ -0,0 +1,62 @@ +package com.yandex.div.legacy; + +import android.text.TextUtils; +import android.view.View; +import androidx.annotation.NonNull; +import com.yandex.alicekit.core.annotations.PublicApi; +import com.yandex.div.DivAction; +import com.yandex.div.legacy.view.DivView; + +/** + * Logs Div UI-events. + */ +@PublicApi +public interface DivLogger { + + DivLogger STUB = new DivLogger() { }; + + /** + * Is called when element is clicked. + */ + default void logClick(DivView divView, View view, String logId) { + //do nothing + } + + /** + * Is called when element is clicked. + */ + default void logClick(DivView divView, View view, DivAction action) { + String id = action.logId; + if (!TextUtils.isEmpty(id)) { + logClick(divView, view, id); + } + } + + /** + * Is called when selected page in tabs div is changed. + */ + default void logTabPageChanged(DivView divView, int selectedTab) { + // do nothing + } + + /** + * Is called when active tab title clicked + */ + default void logActiveTabTitleClicked(@NonNull DivView divView, int selectedTab, @NonNull DivAction divAction) { + // do nothing + } + + /** + * Is called when title bar in tabs div is scrolled. + */ + default void logTabTitlesScroll(DivView divView) { + // do nothing + } + + /** + * Is called when gallery is scrolled. + */ + default void logGalleryScroll(DivView divView) { + // do nothing + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivTextStyleProvider.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivTextStyleProvider.java new file mode 100644 index 000000000..f646103bc --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivTextStyleProvider.java @@ -0,0 +1,275 @@ +package com.yandex.div.legacy; + +import android.graphics.Typeface; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.alicekit.core.widget.TypefaceProvider; +import com.yandex.div.DivCustomTextStyle; +import com.yandex.div.DivTextStyle; +import com.yandex.div.legacy.view.TextStyle; +import javax.inject.Inject; +import javax.inject.Provider; + +public class DivTextStyleProvider { + + @NonNull + private final TextStyle mNumbersS; + @NonNull + private final TextStyle mNumbersM; + @NonNull + private final TextStyle mNumbersL; + @NonNull + private final TextStyle mTitleS; + @NonNull + private final TextStyle mTitleM; + @NonNull + private final TextStyle mTitleL; + @NonNull + private final TextStyle mTextS; + @NonNull + private final TextStyle mTextM; + @NonNull + private final TextStyle mTextL; + @NonNull + private final TextStyle mButton; + @NonNull + private final TextStyle mCardHeader; + @NonNull + private final TextStyle mTextMMedium; + @NonNull + private final TypefaceProvider mTypefaceProvider; + @NonNull + private final Provider mLight; + @NonNull + private final Provider mRegular; + @NonNull + private final Provider mMedium; + + @Inject + public DivTextStyleProvider(@NonNull TypefaceProvider typefaceProvider) { + mTypefaceProvider = typefaceProvider; + + mLight = mTypefaceProvider::getLight; + mRegular = mTypefaceProvider::getRegularLegacy; + mMedium = mTypefaceProvider::getMedium; + + mNumbersS = new TextStyle.Builder().setTypefaceProvider(mRegular) + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_numbers_s) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_numbers_s) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_numbers_s) + .createTextStyle(); + + mNumbersM = new TextStyle.Builder().setTypefaceProvider(mRegular) + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_numbers_m) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_numbers_m) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_numbers_m) + .createTextStyle(); + + mNumbersL = new TextStyle.Builder().setTypefaceProvider(mLight) + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_numbers_l) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_numbers_l) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_numbers_l) + .createTextStyle(); + + mTitleS = new TextStyle.Builder().setTypefaceProvider(mMedium) + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_title_s) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_no) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_title_s) + .createTextStyle(); + + mTitleM = new TextStyle.Builder().setTypefaceProvider(mMedium) + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_title_m) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_no) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_title_m) + .createTextStyle(); + + mTitleL = new TextStyle.Builder().setTypefaceProvider(mMedium) + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_title_l) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_no) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_title_l) + .createTextStyle(); + + mTextS = new TextStyle.Builder().setTypefaceProvider(mRegular) + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_text_s) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_no) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_text_s) + .createTextStyle(); + + mTextM = new TextStyle.Builder().setTypefaceProvider(mRegular) + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_text_m) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_no) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_text_m) + .createTextStyle(); + + mTextL = new TextStyle.Builder().setTypefaceProvider(mRegular) + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_text_l) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_no) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_text_l) + .createTextStyle(); + + mButton = new TextStyle.Builder().setTypefaceProvider(mMedium) + .setColor(R.color.div_text_dark_disabled_50) + .setTextSizeDimenRes(R.dimen.div_style_text_size_button) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_button) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_no) + .createTextStyle(); + + mCardHeader = new TextStyle.Builder().setTypefaceProvider(mMedium) + .setColor(R.color.div_text_dark_disabled_40) + .setTextSizeDimenRes(R.dimen.div_style_text_size_card_header) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_card_header) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_card_header) + .createTextStyle(); + + mTextMMedium = new TextStyle.Builder().setTypefaceProvider(mMedium) + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_text_m) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_no) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_text_m) + .createTextStyle(); + } + + @NonNull + private static Typeface selectWithDefault(Typeface first, @NonNull Typeface second) { + Assert.assertNotNull(first); + return first == null ? second : first; + } + + @NonNull + public TypefaceProvider getTypefaceProvider() { + return mTypefaceProvider; + } + + @NonNull + public TextStyle getNumbersS() { + return mNumbersS; + } + + @NonNull + public TextStyle getNumbersM() { + return mNumbersM; + } + + @NonNull + public TextStyle getNumbersL() { + return mNumbersL; + } + + @NonNull + public TextStyle getTitleS() { + return mTitleS; + } + + @NonNull + public TextStyle getTitleM() { + return mTitleM; + } + + @NonNull + public TextStyle getTitleL() { + return mTitleL; + } + + @NonNull + public TextStyle getTextS() { + return mTextS; + } + + @NonNull + public TextStyle getTextM() { + return mTextM; + } + + @NonNull + public TextStyle getTextL() { + return mTextL; + } + + @NonNull + public TextStyle getTextMMedium() { + return mTextMMedium; + } + + @NonNull + public TextStyle getCardHeader() { + return mCardHeader; + } + + @NonNull + public TextStyle getButton() { + return mButton; + } + + @NonNull + public TextStyle getTextStyle(@NonNull @DivTextStyle String divTextStyle) { + switch (divTextStyle) { + case DivTextStyle.TEXT_S: + return mTextS; + case DivTextStyle.TEXT_M: + return mTextM; + case DivTextStyle.TEXT_L: + return mTextL; + case DivTextStyle.TEXT_M_MEDIUM: + return mTextMMedium; + case DivTextStyle.TITLE_S: + return mTitleS; + case DivTextStyle.TITLE_M: + return mTitleM; + case DivTextStyle.TITLE_L: + return mTitleL; + case DivTextStyle.NUMBERS_S: + return mNumbersS; + case DivTextStyle.NUMBERS_M: + return mNumbersM; + case DivTextStyle.NUMBERS_L: + return mNumbersL; + case DivTextStyle.CARD_HEADER: + return mCardHeader; + case DivTextStyle.BUTTON: + return mButton; + default: + Assert.fail("Unknown text style: " + divTextStyle); + return mTextM; + } + } + + @NonNull + public TextStyle getCustomTextStyle(@NonNull DivCustomTextStyle customTextStyle) { + TextStyle.Builder builder = new TextStyle.Builder(); + builder.setTypefaceProvider(getTypefaceProvider(customTextStyle.fontStyle)) + .setLetterSpacing(customTextStyle.letterSpacing); + + return builder.createTextStyle(); + } + + @NonNull + private Provider getTypefaceProvider(@DivCustomTextStyle.FontStyle String fontStyle) { + switch (fontStyle) { + case DivCustomTextStyle.FontStyle.LIGHT: + return mLight; + case DivCustomTextStyle.FontStyle.MEDIUM: + return mMedium; + case DivCustomTextStyle.FontStyle.REGULAR: + return mRegular; + default: + Assert.fail("Unsupported font style " + fontStyle); + return mRegular; + } + } + + @NonNull + public TextStyle getTextStyle(@NonNull @DivTextStyle String divTextStyle, @Nullable Integer maxLines) { + TextStyle baseStyle = getTextStyle(divTextStyle); + return maxLines == null ? baseStyle : new TextStyle(baseStyle, maxLines); + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivViewBuilder.java new file mode 100644 index 000000000..6f4da5e0b --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivViewBuilder.java @@ -0,0 +1,252 @@ +package com.yandex.div.legacy; + +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.DivBaseBlock; +import com.yandex.div.DivButtonsBlock; +import com.yandex.div.DivContainerBlock; +import com.yandex.div.DivData; +import com.yandex.div.DivFooterBlock; +import com.yandex.div.DivGalleryBlock; +import com.yandex.div.DivImageBlock; +import com.yandex.div.DivSeparatorBlock; +import com.yandex.div.DivTableBlock; +import com.yandex.div.DivTabsBlock; +import com.yandex.div.DivTitleBlock; +import com.yandex.div.DivTrafficBlock; +import com.yandex.div.DivUniversalBlock; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.view.ButtonsDivBlockViewBuilder; +import com.yandex.div.legacy.view.ContainerDivBlockViewBuilder; +import com.yandex.div.legacy.view.DivBaseViewBuilder; +import com.yandex.div.legacy.view.DivImageBlockViewBuilder; +import com.yandex.div.legacy.view.DivTableBlockViewBuilder; +import com.yandex.div.legacy.view.DivView; +import com.yandex.div.legacy.view.DivViewUtils; +import com.yandex.div.legacy.view.FooterDivViewBuilder; +import com.yandex.div.legacy.view.GalleryDivViewBuilder; +import com.yandex.div.legacy.view.SeparatorDivViewBuilder; +import com.yandex.div.legacy.view.TitleDivBlockViewBuilder; +import com.yandex.div.legacy.view.TrafficDivViewBuilder; +import com.yandex.div.legacy.view.UniversalDivViewBuilder; +import com.yandex.div.legacy.view.tab.TabsDivBlockViewBuilder; +import java.util.List; +import javax.inject.Inject; + +@DivLegacyScope +public class DivViewBuilder { + + @NonNull + private final ButtonsDivBlockViewBuilder mButtonViewBuilder; + @NonNull + private final ContainerDivBlockViewBuilder mContainerViewBuilder; + @NonNull + private final FooterDivViewBuilder mFooterViewBuilder; + @NonNull + private final GalleryDivViewBuilder mGalleryViewBuilder; + @NonNull + private final DivImageBlockViewBuilder mImageViewBuilder; + @NonNull + private final SeparatorDivViewBuilder mSeparatorViewBuilder; + @NonNull + private final DivTableBlockViewBuilder mTableViewBuilder; + @NonNull + private final TabsDivBlockViewBuilder mTabsViewBuilder; + @NonNull + private final TitleDivBlockViewBuilder mTitleViewBuilder; + @NonNull + private final TrafficDivViewBuilder mTrafficViewBuilder; + @NonNull + private final UniversalDivViewBuilder mUniversalViewBuilder; + @NonNull + private final DivInternalLogger mLogger; + + @Inject + DivViewBuilder(@NonNull ButtonsDivBlockViewBuilder buttonViewBuilder, + @NonNull ContainerDivBlockViewBuilder containerViewBuilder, + @NonNull FooterDivViewBuilder footerViewBuilder, + @NonNull GalleryDivViewBuilder galleryViewBuilder, + @NonNull DivImageBlockViewBuilder imageViewBuilder, + @NonNull SeparatorDivViewBuilder separatorViewBuilder, + @NonNull DivTableBlockViewBuilder tableViewBuilder, + @NonNull TabsDivBlockViewBuilder tabsViewBuilder, + @NonNull TitleDivBlockViewBuilder titleViewBuilder, + @NonNull TrafficDivViewBuilder trafficViewBuilder, + @NonNull UniversalDivViewBuilder universalViewBuilder, + @NonNull DivInternalLogger logger) { + mButtonViewBuilder = buttonViewBuilder; + mContainerViewBuilder = containerViewBuilder; + mFooterViewBuilder = footerViewBuilder; + mGalleryViewBuilder = galleryViewBuilder; + mImageViewBuilder = imageViewBuilder; + mSeparatorViewBuilder = separatorViewBuilder; + mTableViewBuilder = tableViewBuilder; + mTabsViewBuilder = tabsViewBuilder; + mTitleViewBuilder = titleViewBuilder; + mTrafficViewBuilder = trafficViewBuilder; + mUniversalViewBuilder = universalViewBuilder; + mLogger = logger; + } + + public void build(@NonNull DivView divView, @NonNull ViewGroup container, @NonNull DivData.State state, @NonNull String path) { + final List blocks = state.blocks; + final Visitor visitor = new Visitor(divView, path); + + //noinspection ForLoopReplaceableByForEach + for (int i = 0, n = blocks.size(); i < n; i++) { + View child = visitor.visit(blocks.get(i)); + if (child != null) { + container.addView(child); + } + } + } + + public void build(@NonNull DivView divView, @NonNull ViewGroup container, @NonNull DivContainerBlock block, @NonNull String path) { + final List children = block.children; + final Visitor visitor = new Visitor(divView, path); + + //noinspection ForLoopReplaceableByForEach + for (int i = 0, n = children.size(); i < n; i++) { + View child = visitor.visit(children.get(i)); + if (child != null) { + container.addView(child); + } + } + } + + private class Visitor extends DivVisitor { + + @NonNull + private final DivView mDivView; + @NonNull + private final String mPath; + + private int mNumVisited = 0; + + private Visitor(@NonNull DivView divView, @NonNull String path) { + mDivView = divView; + mPath = path; + } + + @Nullable + @Override + public View visit(@NonNull DivData.State.Block block) { + View view = super.visit(block); + mNumVisited++; + return view; + } + + @Nullable + @Override + public View visit(@NonNull DivContainerBlock.Children block) { + View view = super.visit(block); + mNumVisited++; + return view; + } + + @Nullable + @Override + public View visit(@NonNull DivButtonsBlock divData) { + if (divData.items.isEmpty()) { + return null; + } + return buildDivView(divData, mButtonViewBuilder); + } + + @Nullable + @Override + public View visit(@NonNull DivContainerBlock divData) { + return buildDivView(divData, mContainerViewBuilder); + } + + @Nullable + @Override + public View visit(@NonNull DivFooterBlock divData) { + return buildDivView(divData, mFooterViewBuilder); + } + + @Nullable + @Override + public View visit(@NonNull DivGalleryBlock divData) { + return buildDivView(divData, mGalleryViewBuilder); + } + + @Nullable + @Override + public View visit(@NonNull DivImageBlock divData) { + if (!LegacyDivDataUtils.isDivImageValid(divData.image)) { + return null; + } + return buildDivView(divData, mImageViewBuilder); + } + + @Nullable + @Override + public View visit(@NonNull DivSeparatorBlock divData) { + return buildDivView(divData, mSeparatorViewBuilder); + } + + @Nullable + @Override + public View visit(@NonNull DivTableBlock divData) { + return buildDivView(divData, mTableViewBuilder); + } + + @Nullable + @Override + protected View visit(@NonNull DivTabsBlock divData) { + return buildDivView(divData, mTabsViewBuilder); + } + + @Nullable + @Override + public View visit(@NonNull DivTitleBlock divData) { + if (!LegacyDivDataUtils.isValidBlock(divData)) { + return null; + } + return buildDivView(divData, mTitleViewBuilder); + } + + @Nullable + @Override + public View visit(@NonNull DivTrafficBlock divData) { + if (!LegacyDivDataUtils.isValidBlock(divData)) { + return null; + } + return buildDivView(divData, mTrafficViewBuilder); + } + + @Nullable + @Override + public View visit(@NonNull DivUniversalBlock divData) { + return buildDivView(divData, mUniversalViewBuilder); + } + + @Nullable + private View buildDivView(@NonNull B data, @NonNull DivBaseViewBuilder builder) { + View view; + try { + view = builder.build(mDivView, data, DivBlockWithId.appendId(mPath, String.valueOf(mNumVisited))); + } catch (RuntimeException cause) { + mLogger.logViewBuildingFailure(mDivView, data); + Assert.fail("Div view building failed", cause); + return null; + } + + if (view == null) { + return null; + } + + if (!(data instanceof DivGalleryBlock)) { + DivViewUtils.applyPadding(data.paddingModifier, view); + } + if (data.action != null) { + mDivView.setActionHandlerForView(view, data.action); + } + return view; + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivVisitor.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivVisitor.java new file mode 100644 index 000000000..ee3b932ec --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/DivVisitor.java @@ -0,0 +1,181 @@ +package com.yandex.div.legacy; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.DivButtonsBlock; +import com.yandex.div.DivContainerBlock; +import com.yandex.div.DivData; +import com.yandex.div.DivFooterBlock; +import com.yandex.div.DivGalleryBlock; +import com.yandex.div.DivImageBlock; +import com.yandex.div.DivSeparatorBlock; +import com.yandex.div.DivTableBlock; +import com.yandex.div.DivTabsBlock; +import com.yandex.div.DivTitleBlock; +import com.yandex.div.DivTrafficBlock; +import com.yandex.div.DivUniversalBlock; + +/** + * Base visitor class to process div data tree. + * + * @param returning type of operation over div block + */ +public abstract class DivVisitor { + /** + * Visit block + * + * @param block div block + */ + @Nullable + public T visit(@NonNull DivData.State.Block block) { + DivButtonsBlock buttonsBlock = block.asDivButtonsBlock(); + if (buttonsBlock != null) { + return visit(buttonsBlock); + } + + DivFooterBlock footerBlock = block.asDivFooterBlock(); + if (footerBlock != null) { + return visit(footerBlock); + } + + DivImageBlock imageBlock = block.asDivImageBlock(); + if (imageBlock != null) { + return visit(imageBlock); + } + + DivSeparatorBlock separatorBlock = block.asDivSeparatorBlock(); + if (separatorBlock != null) { + return visit(separatorBlock); + } + + DivTableBlock tableBlock = block.asDivTableBlock(); + if (tableBlock != null) { + return visit(tableBlock); + } + + DivTitleBlock titleBlock = block.asDivTitleBlock(); + if (titleBlock != null) { + return visit(titleBlock); + } + + DivTrafficBlock trafficBlock = block.asDivTrafficBlock(); + if (trafficBlock != null) { + return visit(trafficBlock); + } + + DivUniversalBlock universalBlock = block.asDivUniversalBlock(); + if (universalBlock != null) { + return visit(universalBlock); + } + + DivGalleryBlock galleryBlock = block.asDivGalleryBlock(); + if (galleryBlock != null) { + return visit(galleryBlock); + } + + DivContainerBlock containerBlock = block.asDivContainerBlock(); + if (containerBlock != null) { + return visit(containerBlock); + } + + DivTabsBlock tabsBlock = block.asDivTabsBlock(); + if (tabsBlock != null) { + return visit(tabsBlock); + } + + // todo(ntcheban) should we fail or just ignore ? + Assert.fail("Unknown div block got " + block.toString()); + return null; + } + + @Nullable + public T visit(@NonNull DivContainerBlock.Children block) { + DivButtonsBlock buttonsBlock = block.asDivButtonsBlock(); + if (buttonsBlock != null) { + return visit(buttonsBlock); + } + + DivFooterBlock footerBlock = block.asDivFooterBlock(); + if (footerBlock != null) { + return visit(footerBlock); + } + + DivImageBlock imageBlock = block.asDivImageBlock(); + if (imageBlock != null) { + return visit(imageBlock); + } + + DivSeparatorBlock separatorBlock = block.asDivSeparatorBlock(); + if (separatorBlock != null) { + return visit(separatorBlock); + } + + DivTableBlock tableBlock = block.asDivTableBlock(); + if (tableBlock != null) { + return visit(tableBlock); + } + + DivTitleBlock titleBlock = block.asDivTitleBlock(); + if (titleBlock != null) { + return visit(titleBlock); + } + + DivTrafficBlock trafficBlock = block.asDivTrafficBlock(); + if (trafficBlock != null) { + return visit(trafficBlock); + } + + DivUniversalBlock universalBlock = block.asDivUniversalBlock(); + if (universalBlock != null) { + return visit(universalBlock); + } + + DivContainerBlock containerBlock = block.asDivContainerBlock(); + if (containerBlock != null) { + return visit(containerBlock); + } + + DivGalleryBlock galleryBlock = block.asDivGalleryBlock(); + if (galleryBlock != null) { + return visit(galleryBlock); + } + + // todo(ntcheban) should we fail or just ignore ? + Assert.fail("Unknown div block got " + block.toString()); + return null; + } + + @Nullable + protected abstract T visit(@NonNull DivButtonsBlock data); + + @Nullable + protected abstract T visit(@NonNull DivFooterBlock data); + + @Nullable + protected abstract T visit(@NonNull DivImageBlock data); + + @Nullable + protected abstract T visit(@NonNull DivSeparatorBlock data); + + @Nullable + protected abstract T visit(@NonNull DivTableBlock data); + + @Nullable + protected abstract T visit(@NonNull DivTitleBlock data); + + @Nullable + protected abstract T visit(@NonNull DivTrafficBlock data); + + @Nullable + protected abstract T visit(@NonNull DivUniversalBlock data); + + @Nullable + protected abstract T visit(@NonNull DivGalleryBlock data); + + @Nullable + protected abstract T visit(@NonNull DivContainerBlock data); + + @Nullable + protected abstract T visit(@NonNull DivTabsBlock data); +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivActionHandler.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivActionHandler.java new file mode 100644 index 000000000..d066d07a1 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivActionHandler.java @@ -0,0 +1,115 @@ +package com.yandex.div.legacy; + +import android.net.Uri; +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.annotations.PublicApi; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.json.expressions.ExpressionResolver; +import com.yandex.div.legacy.view.DivView; +import com.yandex.div2.DivAction; +import com.yandex.div2.DivVisibilityAction; +import org.json.JSONObject; + +/** + * Handles URIs and payloads, that are triggered by click events in DivView. + */ +@PublicApi +public class LegacyDivActionHandler { + private static final String SCHEME_DIV_ACTION = "div-action"; + + private static final String AUTHORITY_SWITCH_STATE = "set_state"; + + private static final String PARAM_STATE_ID = "state_id"; + + /** + * Handles the given URI. + * Call super implementation to automatically handle internal div schemes when overriding. + * + * @param uri URI to handle + * @param view calling DivView + * @return TRUE if uri was handled + */ + @CallSuper + @Deprecated + public boolean handleUri(@NonNull Uri uri, @NonNull DivView view) { + return handleActionUrl(uri, view); + } + + /** + * Handles the given div action. + * Call super implementation to automatically handle internal div schemes when overriding. + * + * @param action full div action to handle + * @param view calling DivView + * @return TRUE if uri was handled + */ + @CallSuper + public boolean handleAction(@NonNull DivAction action, @NonNull DivView view) { + Uri url = action.url != null ? action.url.evaluate(ExpressionResolver.EMPTY) : null; + return handleActionUrl(url, view); + } + + /** + * Handles the given div visibility action. + * Call super implementation to automatically handle internal div schemes when overriding. + * + * @param action full div visibility action to handle + * @param view calling DivView + * @return TRUE if uri was handled + */ + @CallSuper + public boolean handleAction(@NonNull DivVisibilityAction action, @NonNull DivView view) { + Uri url = action.url != null ? action.url.evaluate(ExpressionResolver.EMPTY) : null; + return handleActionUrl(url, view); + } + + /** + * Handles the given json. + * + * @param payload json to handle + */ + public void handlePayload(@NonNull JSONObject payload) { /* not implemented */ } + + /** + * Handles the URI with {@code div-action} scheme. + * + * @param uri URI to handle + * @param view calling DivView + * @return TRUE if uri was handled + */ + public final boolean handleActionUrl(@Nullable Uri uri, @NonNull DivView view) { + if (uri == null) { + return false; + } + + //noinspection SimplifiableIfStatement + if (SCHEME_DIV_ACTION.equals(uri.getScheme())) { + return handleAction(uri, view); + } + + return false; + } + + private boolean handleAction(@NonNull Uri uri, @NonNull DivView view) { + String action = uri.getAuthority(); + if (!AUTHORITY_SWITCH_STATE.equals(action)) { + return false; + } + // legacy divs supports only uri like: div-action://set_state?state_id=1 + String stateId = uri.getQueryParameter(PARAM_STATE_ID); + if (stateId == null) { + Assert.fail(PARAM_STATE_ID + " param is required"); + return false; + } + try { + int state = Integer.parseInt(stateId); + view.switchToState(state); + } catch (NumberFormatException e) { + Assert.fail("Switch state action should contain integer stateId, but was:" + uri.toString(), e); + return false; + } + return true; + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivDataUtils.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivDataUtils.java new file mode 100644 index 000000000..9a8a696b8 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivDataUtils.java @@ -0,0 +1,171 @@ +package com.yandex.div.legacy; + +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.text.TextUtils; +import androidx.annotation.ColorInt; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.DivBackground; +import com.yandex.div.DivData; +import com.yandex.div.DivGradientBackground; +import com.yandex.div.DivImageBackground; +import com.yandex.div.DivImageElement; +import com.yandex.div.DivSolidBackground; +import com.yandex.div.DivTitleBlock; +import com.yandex.div.DivTrafficBlock; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.core.images.LoadReference; +import com.yandex.div.legacy.view.DivView; +import com.yandex.div.util.DivKitImageUtils; +import com.yandex.images.CachedBitmap; + +/** + * This class contains utils useful for working with Div data. + */ +public class LegacyDivDataUtils { + public static final int INVALID_STATE_ID = -1; + + private LegacyDivDataUtils() {} + + public static int getInitialStateId(@NonNull DivData data) { + return data.states.isEmpty() ? INVALID_STATE_ID : data.states.get(0).stateId; + } + + /** + * Method to get state by its id without NPE. + * + * @param divData div data + * @param id state id as it's set in stateId field + * @return state or null if not found + */ + @Nullable + public static DivData.State getStateByIdSafely(@NonNull DivData divData, @IntRange(from = INVALID_STATE_ID) int id) { + if (id == INVALID_STATE_ID) { + return null; + } + + for (DivData.State state : divData.states) { + if (state.stateId == id) { + return state; + } + } + + Assert.fail("Non existent state id got " + id); + return null; + } + + /** + * Method to check if image element is valid + * + * @param element div image element + * @return true if element is valid + */ + public static boolean isDivImageValid(@Nullable DivImageElement element) { + if (element == null) { + return false; + } + + String imageUrl = element.imageUrl.toString(); + if (TextUtils.isEmpty(imageUrl)) { + return false; + } + + return true; + } + + /** + * Method to check if text element is valid. + * + * @param text text from div text field + * @return true if not empty + */ + public static boolean isDivTextValid(@Nullable CharSequence text) { + return !TextUtils.isEmpty(text); + } + + public static boolean isTextOnlyDiv(@Nullable CharSequence text, @Nullable DivImageElement image) { + return isDivTextValid(text) && !isDivImageValid(image); + } + + public static boolean isImageOnlyDiv(@Nullable CharSequence text, @Nullable DivImageElement image) { + return !isDivTextValid(text) && isDivImageValid(image); + } + + public static boolean isTextAndImageDiv(@Nullable CharSequence text, @Nullable DivImageElement image) { + return isDivTextValid(text) && isDivImageValid(image); + } + + public static boolean isValidBlock(@NonNull DivTitleBlock divTitleBlock) { + return !(TextUtils.isEmpty(divTitleBlock.text) && (divTitleBlock.menuItems == null || divTitleBlock.menuItems.isEmpty())); + } + + public static boolean isValidBlock(@NonNull DivTrafficBlock divTrafficBlock) { + for (DivTrafficBlock.Item item : divTrafficBlock.items) { + if (!TextUtils.isEmpty(item.score)) { + return true; + } + } + return false; + } + + @Nullable + public static Drawable divBackgroundToDrawable(@Nullable DivBackground background, + @NonNull DivImageLoader imageLoader, + @NonNull DivView divView) { + if (background == null) { + return null; + } + DivSolidBackground solidBackground = background.asDivSolidBackground(); + if (solidBackground != null) { + return getSolidDrawable(solidBackground.color); + } + + DivGradientBackground gradientBackground = background.asDivGradientBackground(); + if (gradientBackground != null) { + return getGradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, gradientBackground.startColor, + gradientBackground.endColor); + } + + DivImageBackground imageBackground = background.asDivImageBackground(); + if (imageBackground != null) { + return getImageDrawable(imageBackground.imageUrl.toString(), imageLoader, divView); + } + + return null; + } + + @NonNull + private static Drawable getSolidDrawable(@ColorInt int color) { + return new ColorDrawable(color); + } + + @NonNull + private static Drawable getGradientDrawable(GradientDrawable.Orientation orientation, + @ColorInt int startColor, @ColorInt int endColor) { + return new GradientDrawable(orientation, new int[] {startColor, endColor}); + } + + @NonNull + private static Drawable getImageDrawable(@NonNull String imageUrl, + @NonNull DivImageLoader imageLoader, + @NonNull DivView targetView) { + final BackgroundImageDrawable backgroundImageDrawable = new BackgroundImageDrawable(targetView.getContext()); + + LoadReference loadReference = imageLoader + .loadImage(imageUrl, DivKitImageUtils.toDivKitCallback(new LegacyDivImageDownloadCallback(targetView) { + @UiThread + @Override + public void onSuccess(@NonNull CachedBitmap cachedBitmap) { + backgroundImageDrawable.setOriginalBitmap(cachedBitmap.getBitmap()); + } + })); + targetView.addLoadReference(loadReference, targetView); + + return backgroundImageDrawable; + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivImageDownloadCallback.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivImageDownloadCallback.kt new file mode 100644 index 000000000..115eb41b8 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivImageDownloadCallback.kt @@ -0,0 +1,13 @@ +package com.yandex.div.legacy + +import com.yandex.div.legacy.view.DivView +import com.yandex.images.ImageDownloadCallback + +internal open class LegacyDivImageDownloadCallback( + private val divId: String + ) : ImageDownloadCallback() { + + constructor(divView: DivView) : this(divView.divTag.id) + + override fun getAdditionalLogInfo() = divId +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivViewConfig.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivViewConfig.java new file mode 100644 index 000000000..ebda63c48 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/LegacyDivViewConfig.java @@ -0,0 +1,19 @@ +package com.yandex.div.legacy; + +import androidx.annotation.Px; +import com.yandex.alicekit.core.annotations.PublicApi; + +/** + * Configuration for DivView + */ +@PublicApi +public interface LegacyDivViewConfig { + LegacyDivViewConfig DEFAULT = () -> true; + + boolean isContextMenuEnabled(); + + @Px + default int getLogCardScrollSignificantThreshold() { + return 0; + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/DivComponent.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/DivComponent.java new file mode 100644 index 000000000..c1dd97312 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/DivComponent.java @@ -0,0 +1,54 @@ +package com.yandex.div.legacy.dagger; + +import android.view.ContextThemeWrapper; +import androidx.annotation.NonNull; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.legacy.DivAutoLogger; +import com.yandex.div.legacy.DivLegacyConfiguration; +import com.yandex.div.legacy.DivLogger; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.DivViewBuilder; +import com.yandex.div.legacy.LegacyDivActionHandler; +import com.yandex.div.legacy.state.LegacyDivStateManager; +import dagger.BindsInstance; +import dagger.Component; + +@DivLegacyScope +@Component(modules = {DivModule.class, DivLegacyConfiguration.class}) +public interface DivComponent { + + @NonNull + DivImageLoader getImageLoader(); + + @NonNull + DivTextStyleProvider getTextStyleProvider(); + + @NonNull + LegacyDivActionHandler getActionHandler(); + + @NonNull + DivLogger getLogger(); + + @NonNull + DivAutoLogger getAutoLogger(); + + @NonNull + LegacyDivStateManager getStateManager(); + + @NonNull + DivViewBuilder getViewBuilder(); + + @Component.Builder + interface Builder { + + @BindsInstance + @NonNull + Builder baseContext(@NonNull ContextThemeWrapper baseContext); + + @NonNull + Builder configuration(@NonNull DivLegacyConfiguration configuration); + + @NonNull + DivComponent build(); + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/DivLegacyScope.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/DivLegacyScope.java new file mode 100644 index 000000000..d16f6bb19 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/DivLegacyScope.java @@ -0,0 +1,12 @@ +package com.yandex.div.legacy.dagger; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import javax.inject.Scope; + +/** + * A Dagger scope used for objects created for each instance of //todo. + */ +@Scope +@Retention(RetentionPolicy.RUNTIME) +public @interface DivLegacyScope {} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/DivModule.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/DivModule.java new file mode 100644 index 000000000..6401bd053 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/DivModule.java @@ -0,0 +1,90 @@ +package com.yandex.div.legacy.dagger; + +import android.content.Context; +import android.view.ContextThemeWrapper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.experiments.ExperimentConfig; +import com.yandex.alicekit.core.metrica.MetricaUtils; +import com.yandex.alicekit.core.widget.TypefaceProvider; +import com.yandex.alicekit.core.widget.YandexSansTypefaceProvider; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.experiments.LegacyDivFlags; +import com.yandex.div.legacy.view.DivLineHeightTextViewFactory; +import com.yandex.div.legacy.view.TextViewFactory; +import com.yandex.div.view.pooling.AdvanceViewPool; +import com.yandex.div.view.pooling.PseudoViewPool; +import com.yandex.div.view.pooling.ViewPool; +import com.yandex.div.view.pooling.ViewPoolProfiler; +import com.yandex.metrica.IReporterInternal; +import dagger.Binds; +import dagger.Module; +import dagger.Provides; +import java.util.Map; +import javax.inject.Named; + +@Module +abstract class DivModule { + + @Binds + @Named(LegacyNames.CONTEXT) + @NonNull + abstract Context bindContext(@NonNull ContextThemeWrapper baseContext); + + @Provides + @DivLegacyScope + @NonNull + static TypefaceProvider provideTypefaceProvider(@Named(LegacyNames.CONTEXT) @NonNull Context context) { + return new YandexSansTypefaceProvider(context); + } + + @Provides + @Named(LegacyNames.THEMED_CONTEXT) + @DivLegacyScope + @NonNull + static Context provideThemedContext(@NonNull ContextThemeWrapper baseContext) { + return new ContextThemeWrapper(baseContext, R.style.Legacy_Theme); + } + + @Provides + @DivLegacyScope + @NonNull + static IReporterInternal provideMetricaReporter(@NonNull @Named(LegacyNames.CONTEXT) Context context) { + return MetricaUtils.getReporter(context); + } + + @Provides + @DivLegacyScope + @NonNull + static ViewPool provideViewPool(@NonNull ExperimentConfig experimentConfig, + @Nullable ViewPoolProfiler profiler) { + boolean viewPoolEnabled = experimentConfig.getBooleanValue(LegacyDivFlags.VIEW_POOL_ENABLED); + return viewPoolEnabled ? new AdvanceViewPool(profiler, ViewCreatorHolder.VIEW_CREATOR) : new PseudoViewPool(); + } + + @Provides + @DivLegacyScope + @Nullable + static ViewPoolProfiler provideViewPoolProfiler(@NonNull ExperimentConfig experimentConfig, + @NonNull IReporterInternal reporter) { + boolean profilingEnabled = experimentConfig.getBooleanValue(LegacyDivFlags.VIEW_POOL_PROFILING_ENABLED); + return profilingEnabled ? new ViewPoolProfiler( + (message, result) -> reporter.reportEvent(message, (Map) result) + ) : null; + } + + @Provides + @DivLegacyScope + @NonNull + static DivTextStyleProvider provideTextStyleProvider(@NonNull TypefaceProvider typefaceProvider) { + return new DivTextStyleProvider(typefaceProvider); + } + + @Provides + @DivLegacyScope + @NonNull + static TextViewFactory provideDivTextFactory() { + return new DivLineHeightTextViewFactory(); + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/LegacyNames.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/LegacyNames.kt new file mode 100644 index 000000000..5fdc897c9 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/LegacyNames.kt @@ -0,0 +1,7 @@ +package com.yandex.div.legacy.dagger + +internal object LegacyNames { + + const val CONTEXT = "context" + const val THEMED_CONTEXT = "themed_context" +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/ViewCreatorHolder.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/ViewCreatorHolder.kt new file mode 100644 index 000000000..7c87cb538 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/dagger/ViewCreatorHolder.kt @@ -0,0 +1,9 @@ +package com.yandex.div.legacy.dagger + +import com.yandex.div.core.histogram.CpuUsageHistogramReporter +import com.yandex.div.view.pooling.ViewCreator + +internal object ViewCreatorHolder { + @JvmField + val VIEW_CREATOR = ViewCreator(CpuUsageHistogramReporter.NoOp()) +} \ No newline at end of file diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/experiments/LegacyDivFlags.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/experiments/LegacyDivFlags.java new file mode 100644 index 000000000..f63c9a0ab --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/experiments/LegacyDivFlags.java @@ -0,0 +1,39 @@ +package com.yandex.div.legacy.experiments; + +import androidx.annotation.NonNull; +import com.yandex.alicekit.core.annotations.PublicApi; +import com.yandex.alicekit.core.experiments.BooleanFlag; +import com.yandex.alicekit.core.experiments.ExperimentFlag; +import java.util.Arrays; +import java.util.Collection; + +/** + * A set of experiment flags that used by divs. + * To create a new flag you should instantiate a constant in this class and add to the {@link #EXPERIMENT_FLAGS} list. + */ +@PublicApi +public class LegacyDivFlags { + + private LegacyDivFlags() { /* prevent initialization */ } + + /** + * Enables div view pool. + * Default value is {@code false}. + */ + public static final BooleanFlag VIEW_POOL_ENABLED = new BooleanFlag("Div.ViewPool.enabled", true); + public static final BooleanFlag VIEW_POOL_PROFILING_ENABLED = + new BooleanFlag("Div.ViewPool.profilingEnabled", false); + + private static final ExperimentFlag[] EXPERIMENT_FLAGS = { + VIEW_POOL_ENABLED, + VIEW_POOL_PROFILING_ENABLED + }; + + /** + * @return All experiment flags related to divs + */ + @NonNull + public static Collection> getAllFlags() { + return Arrays.asList(EXPERIMENT_FLAGS); + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyDivStateCache.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyDivStateCache.java new file mode 100644 index 000000000..d625a7dfe --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyDivStateCache.java @@ -0,0 +1,26 @@ +package com.yandex.div.legacy.state; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import com.yandex.alicekit.core.annotations.PublicApi; +import javax.annotation.Nullable; + +/** + * Storage of activated states that are keyed by pair of cardId and activation path. + */ +@PublicApi +public interface LegacyDivStateCache { + @MainThread + void putState(@NonNull String cardId, @NonNull String path, @NonNull String state); + + @MainThread + void putRootState(@NonNull String cardId, @NonNull String state); + + @MainThread + @Nullable + String getState(@NonNull String cardId, @NonNull String path); + + @MainThread + @Nullable + String getRootState(@NonNull String cardId); +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyDivStateManager.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyDivStateManager.kt new file mode 100644 index 000000000..301b20454 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyDivStateManager.kt @@ -0,0 +1,40 @@ +package com.yandex.div.legacy.state + +import androidx.annotation.MainThread +import androidx.collection.ArrayMap +import com.yandex.div.DivDataTag +import com.yandex.div.legacy.dagger.DivLegacyScope +import javax.inject.Inject + +/** + * Manipulates application's div + * data state change and retrieval. + */ +@DivLegacyScope +@MainThread +class LegacyDivStateManager @Inject internal constructor( + private val cache: LegacyDivStateCache, +) { + private val states = ArrayMap() + + fun getState(tag: DivDataTag): LegacyDivViewState? { + var state = states[tag] + if (state == null) { + state = cache.getRootState(tag.id)?.toInt()?.let { LegacyDivViewState(it) } + states[tag] = state + } + return state + } + + fun updateState(tag: DivDataTag, stateId: Int) { + if (DivDataTag.INVALID != tag) { + val state = getState(tag) + states[tag] = if (state == null) { + LegacyDivViewState(stateId) + } else { + LegacyDivViewState(stateId, state.blockStates) + } + cache.putRootState(tag.id, stateId.toString()) + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyDivViewState.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyDivViewState.java new file mode 100644 index 000000000..8d9205292 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyDivViewState.java @@ -0,0 +1,50 @@ +package com.yandex.div.legacy.state; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.ArrayMap; +import java.util.Map; + +/** + * Describes view state (current selected div state, scrolls etc), so div views that accept this data could save previous layout + */ +public class LegacyDivViewState { + + private final int mCurrentDivStateId; + @NonNull + private final Map mBlockStates; + + LegacyDivViewState(int currentDivStateId) { + this(currentDivStateId, new ArrayMap<>()); + } + + LegacyDivViewState(int currentDivStateId, @NonNull Map blockStates) { + mCurrentDivStateId = currentDivStateId; + mBlockStates = blockStates; + } + + public int getCurrentDivStateId() { + return mCurrentDivStateId; + } + + @Nullable + public T getBlockState(@NonNull String blockId) { + //noinspection unchecked + return (T) mBlockStates.get(blockId); + } + + public void putBlockState(@NonNull String blockId, @NonNull T blockState) { + mBlockStates.put(blockId, blockState); + } + + public void reset() { + mBlockStates.clear(); + } + + @NonNull + Map getBlockStates() { + return mBlockStates; + } + + interface BlockState {} +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyGalleryState.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyGalleryState.java new file mode 100644 index 000000000..6525594be --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyGalleryState.java @@ -0,0 +1,19 @@ +package com.yandex.div.legacy.state; + +public class LegacyGalleryState implements LegacyDivViewState.BlockState { + private final int mVisibleItemIndex; + private final int mScrollOffset; + + public LegacyGalleryState(int visibleItemIndex, int scrollOffset) { + mVisibleItemIndex = visibleItemIndex; + mScrollOffset = scrollOffset; + } + + public int getVisibleItemIndex() { + return mVisibleItemIndex; + } + + public int getScrollOffset() { + return mScrollOffset; + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyInMemoryDivStateCache.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyInMemoryDivStateCache.kt new file mode 100644 index 000000000..166b74de3 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyInMemoryDivStateCache.kt @@ -0,0 +1,26 @@ +package com.yandex.div.legacy.state + +import androidx.annotation.MainThread + +@MainThread +class LegacyInMemoryDivStateCache : LegacyDivStateCache { + private val states = mutableMapOf, String>() + private val rootStates = mutableMapOf() + + override fun putState(cardId: String, path: String, state: String) { + states[cardId to path] = state + } + + override fun putRootState(cardId: String, state: String) { + rootStates[cardId] = state + } + + override fun getState(cardId: String, path: String) = states[cardId to path] + + override fun getRootState(cardId: String) = rootStates[cardId] + + internal fun clear() { + states.clear() + rootStates.clear() + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyTabsState.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyTabsState.java new file mode 100644 index 000000000..57c27ab10 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyTabsState.java @@ -0,0 +1,13 @@ +package com.yandex.div.legacy.state; + +public class LegacyTabsState implements LegacyDivViewState.BlockState { + private final int mCurrentPage; + + public LegacyTabsState(int currentPage) { + this.mCurrentPage = currentPage; + } + + public int getCurrentPage() { + return mCurrentPage; + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyUpdateStateScrollListener.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyUpdateStateScrollListener.java new file mode 100644 index 000000000..85e9d5dfd --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/state/LegacyUpdateStateScrollListener.java @@ -0,0 +1,33 @@ +package com.yandex.div.legacy.state; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +public class LegacyUpdateStateScrollListener extends RecyclerView.OnScrollListener { + @NonNull + private final LegacyDivViewState mDivViewState; + @NonNull + private final String mBlockId; + @NonNull + private final LinearLayoutManager mLayoutManager; + + public LegacyUpdateStateScrollListener( + @NonNull String blockId, + @NonNull LegacyDivViewState divViewState, + @NonNull LinearLayoutManager layoutManager + ) { + mDivViewState = divViewState; + mBlockId = blockId; + mLayoutManager = layoutManager; + } + + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + int visibleItemIndex = mLayoutManager.findFirstVisibleItemPosition(); + RecyclerView.ViewHolder visibleItemHolder = recyclerView.findViewHolderForLayoutPosition(visibleItemIndex); + int scrollOffset = visibleItemHolder != null ? visibleItemHolder.itemView.getLeft() : 0; + mDivViewState.putBlockState(mBlockId, new LegacyGalleryState(visibleItemIndex, scrollOffset)); + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/BaseDivViewExtensions.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/BaseDivViewExtensions.kt new file mode 100644 index 000000000..cfd22afeb --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/BaseDivViewExtensions.kt @@ -0,0 +1,103 @@ +package com.yandex.div.legacy.view + +import android.util.DisplayMetrics +import android.util.TypedValue +import android.view.View +import android.widget.TextView +import com.yandex.alicekit.core.utils.fontHeight +import com.yandex.alicekit.core.widget.TabView +import com.yandex.alicekit.core.widget.TypefaceType +import com.yandex.div.core.images.LoadReference +import com.yandex.div.json.expressions.ExpressionResolver +import com.yandex.div.legacy.R +import com.yandex.div2.DivFontWeight +import com.yandex.div2.DivSizeUnit +import com.yandex.div2.DivTabs +import kotlin.math.roundToInt + +//todo move me to core-views when alicekit gonna be removed from deps +@Suppress("UNCHECKED_CAST") +internal fun View.saveLoadReference(reference: LoadReference) { + val references = getTag(R.id.load_references_tag) + if (references == null) { + setTag(R.id.load_references_tag, mutableSetOf(reference)) + } else { + val referenceSet = references as MutableSet + referenceSet.add(reference) + } +} + +fun TabView.applyStyle(style: DivTabs.TabTitleStyle) { + applyFontSize(style.fontSize.evaluate(ExpressionResolver.EMPTY), style.fontSizeUnit.evaluate(ExpressionResolver.EMPTY)) + applyLetterSpacing(style.letterSpacing.evaluate(ExpressionResolver.EMPTY)) + applyLineHeight(style.lineHeight?.evaluate(ExpressionResolver.EMPTY)) + + includeFontPadding = false + val paddings = style.paddings + val metrics = resources.displayMetrics + setTabPadding( + paddings.left.evaluate(ExpressionResolver.EMPTY).dpToPx(metrics), paddings.top.evaluate(ExpressionResolver.EMPTY).dpToPx(metrics), + paddings.right.evaluate(ExpressionResolver.EMPTY).dpToPx(metrics), paddings.bottom.evaluate(ExpressionResolver.EMPTY).dpToPx(metrics) + ) + when (style.fontWeight.evaluate(ExpressionResolver.EMPTY)) { + DivFontWeight.MEDIUM -> setDefaultTypefaceType(TypefaceType.MEDIUM) + DivFontWeight.REGULAR -> setDefaultTypefaceType(TypefaceType.REGULAR) + DivFontWeight.LIGHT -> setDefaultTypefaceType(TypefaceType.LIGHT) + DivFontWeight.BOLD -> setDefaultTypefaceType(TypefaceType.BOLD) + } +} + +fun TextView.applyFontSize(fontSize: Int, unit: DivSizeUnit) { + setTextSize(unit.toAndroidUnit(), fontSize.toFloat()) +} + +fun DivSizeUnit.toAndroidUnit(): Int { + return when (this) { + DivSizeUnit.DP -> TypedValue.COMPLEX_UNIT_DIP + DivSizeUnit.SP -> TypedValue.COMPLEX_UNIT_SP + DivSizeUnit.PX -> TypedValue.COMPLEX_UNIT_PX + } +} + +fun TextView.applyLetterSpacing(letterSpacing: Double) { + this.letterSpacing = letterSpacing.toFloat() +} + +fun TextView.applyLineHeight(lineHeight: Int?) { + val lineSpacingExtra = + lineHeight?.let { it.spToPx(resources.displayMetrics) - this.fontHeight } ?: 0 + setLineSpacing(lineSpacingExtra.toFloat(), 1f) +} + +fun Int?.dpToPx(metrics: DisplayMetrics): Int { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this?.toFloat() ?: 0f, metrics) + .roundToInt() +} + +fun Int?.dpToPxF(metrics: DisplayMetrics): Float { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this?.toFloat() ?: 0f, metrics) +} + +fun Double?.dpToPx(metrics: DisplayMetrics): Int { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this?.toFloat() ?: 0f, metrics) + .roundToInt() +} + +fun Int?.spToPx(metrics: DisplayMetrics): Int { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this?.toFloat() ?: 0f, metrics) + .roundToInt() +} + +fun Int?.spToPxF(metrics: DisplayMetrics): Float { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this?.toFloat() ?: 0f, metrics) +} + +fun Double?.spToPx(metrics: DisplayMetrics): Int { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this?.toFloat() ?: 0f, metrics) + .roundToInt() +} + +fun Int?.unitToPx(metrics: DisplayMetrics, unit: DivSizeUnit): Int { + return TypedValue.applyDimension(unit.toAndroidUnit(), this?.toFloat() ?: 0f, metrics) + .roundToInt() +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ButtonsDivBlockViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ButtonsDivBlockViewBuilder.java new file mode 100644 index 000000000..e94495e6c --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ButtonsDivBlockViewBuilder.java @@ -0,0 +1,371 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.DivButtonsBlock; +import com.yandex.div.DivTextStyle; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.core.images.LoadReference; +import com.yandex.div.legacy.Alignment; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.LegacyDivDataUtils; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.dagger.LegacyNames; +import com.yandex.div.view.pooling.ViewPool; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; + +@DivLegacyScope +public class ButtonsDivBlockViewBuilder extends DivElementDataViewBuilder { + + private static final String FACTORY_TAG_TEXT_BUTTON = "ButtonsDivBlockViewBuilder.TEXT_BUTTON"; + private static final String FACTORY_TAG_IMAGE_BUTTON = "ButtonsDivBlockViewBuilder.IMAGE_BUTTON"; + private static final String FACTORY_TAG_BUTTON_WRAPPER = "ButtonsDivBlockViewBuilder.BUTTON_WRAPPER"; + + @NonNull + private final Context mThemedContext; + @NonNull + private final ViewPool mViewPool; + @NonNull + private final DivImageLoader mImageLoader; + @NonNull + private final DivTextStyleProvider mTextStyleProvider; + + @Inject + ButtonsDivBlockViewBuilder(@NonNull @Named(LegacyNames.THEMED_CONTEXT) Context themedContext, + @NonNull ViewPool viewPool, + @NonNull DivImageLoader imageLoader, + @NonNull DivTextStyleProvider textStyleProvider) { + super(); + mThemedContext = themedContext; + mViewPool = viewPool; + mImageLoader = imageLoader; + mTextStyleProvider = textStyleProvider; + + mViewPool.register(FACTORY_TAG_TEXT_BUTTON, this::createTextButton, 8); + mViewPool.register(FACTORY_TAG_IMAGE_BUTTON, this::createImageButton, 8); + mViewPool.register(FACTORY_TAG_BUTTON_WRAPPER, this::createButtonWrapper, 8); + } + + public static boolean isValidBlock(@NonNull DivButtonsBlock divButtonsBlock) { + return !getValidButtons(divButtonsBlock).isEmpty(); + } + + @Override + @Nullable + protected View build(@NonNull DivView divView, @NonNull DivButtonsBlock divData) { + List buttonsList = getValidButtons(divData); + + if (buttonsList.isEmpty()) { + return null; + } + + final Alignment alignment = DivViewUtils.divAlignmentToAlignment(divData.alignment); + + if (buttonsList.size() == 1) { + return buildSingleButton(divView, divData, alignment); + } + return buildMultipleButtons(divView, buttonsList, alignment); + } + + @NonNull + private static List getValidButtons(@NonNull DivButtonsBlock divData) { + if (divData.items.isEmpty()) { + return Collections.emptyList(); + } + + List buttonsList = new ArrayList<>(); + for (DivButtonsBlock.Item item : divData.items) { + if (LegacyDivDataUtils.isDivImageValid(item.image) || LegacyDivDataUtils.isDivTextValid(item.text)) { + buttonsList.add(item); + } + } + + return buttonsList; + } + + private static boolean isImageButton(@NonNull DivButtonsBlock.Item divButtonItem) { + return LegacyDivDataUtils.isImageOnlyDiv(divButtonItem.text, divButtonItem.image); + } + + @NonNull + private View buildSingleButton(@NonNull DivView divView, @NonNull DivButtonsBlock divData, @NonNull Alignment alignment) { + DivButtonsBlock.Item buttonElementData = divData.items.get(0); + + View view = isImageButton(buttonElementData) ? mViewPool.obtain(FACTORY_TAG_IMAGE_BUTTON) : mViewPool.obtain(FACTORY_TAG_TEXT_BUTTON); + if (divData.isFullwidth) { + FrameLayout wrapper = mViewPool.obtain(FACTORY_TAG_BUTTON_WRAPPER); + + bindButtonContent(divView, view, buttonElementData); + bindButtonAppearance(divView, wrapper, buttonElementData); + bindFullsizeButton(view); + + wrapper.addView(view); + view = wrapper; + } else { + bindButtonContent(divView, view, buttonElementData); + bindButtonAppearance(divView, view, buttonElementData); + bindSingleButtonAlignment(view, alignment); + } + + Resources resources = view.getResources(); + int horizontalPadding = resources.getDimensionPixelOffset(R.dimen.div_horizontal_padding); + int verticalPadding = resources.getDimensionPixelOffset(R.dimen.div_button_text_vertical_padding); + + FrameLayout rootLayout = new FrameLayout(view.getContext()); + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + layoutParams.leftMargin = horizontalPadding; + layoutParams.rightMargin = horizontalPadding; + layoutParams.topMargin = verticalPadding; + layoutParams.bottomMargin = verticalPadding; + + rootLayout.setLayoutParams(layoutParams); + rootLayout.addView(view); + divView.setActionHandlerForView(rootLayout, buttonElementData.action); + return rootLayout; + } + + @NonNull + private TextView createTextButton() { + TextView textButton = new TextView(mThemedContext, null, R.attr.legacyButtonTextStyle); + int height = mThemedContext.getResources().getDimensionPixelSize(R.dimen.div_button_height); + textButton.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, height)); + return textButton; + } + + @NonNull + private ImageView createImageButton() { + ImageView imageButton = new ImageView(mThemedContext, null, R.attr.legacyButtonImageStyle); + int size = mThemedContext.getResources().getDimensionPixelSize(R.dimen.div_button_height); + imageButton.setLayoutParams(new FrameLayout.LayoutParams(size, size)); + return imageButton; + } + + @NonNull + private FrameLayout createButtonWrapper() { + FrameLayout wrapper = new FrameLayout(mThemedContext); + FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + layoutParams.gravity = Gravity.CENTER; + wrapper.setLayoutParams(layoutParams); + return wrapper; + } + + @Nullable + private Drawable createBackground(@NonNull DivButtonsBlock.Item data) { + final Drawable background = ContextCompat.getDrawable(mThemedContext, R.drawable.button_background); + if (background == null) { + return null; + } + + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) { + DrawableCompat.setTint(background, data.backgroundColor); + } else { + background.mutate().setColorFilter(data.backgroundColor, PorterDuff.Mode.SRC_IN); + } + return background; + } + + private void bindButtonContent(@NonNull DivView divView, @NonNull View view, @NonNull DivButtonsBlock.Item data) { + if (isImageButton(data)) { + bindImageContent(divView, (ImageView) view, data); + } else { + bindTextContent(divView, (TextView) view, data); + } + } + + private void bindImageContent(@NonNull DivView divView, @NonNull ImageView imageView, @NonNull DivButtonsBlock.Item data) { + //noinspection ConstantConditions + LoadReference loadReference = mImageLoader.loadImage(data.image.imageUrl.toString(), imageView); + divView.addLoadReference(loadReference, imageView); + } + + private void bindTextContent(@NonNull DivView divView, @NonNull TextView textView, @NonNull DivButtonsBlock.Item data) { + TextStyle buttonStyle = mTextStyleProvider.getTextStyle(DivTextStyle.TEXT_M); + buttonStyle.apply(textView); + textView.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY); // override alignment from text style + + if (LegacyDivDataUtils.isTextOnlyDiv(data.text, data.image)) { + textView.setText(data.text); + } else if (LegacyDivDataUtils.isTextAndImageDiv(data.text, data.image)) { + //noinspection ConstantConditions + bind( + divView, + mImageLoader, + textView, + data.text, + data.image, + R.dimen.div_button_text_horizontal_image_padding, + R.dimen.div_button_text_horizontal_padding, + R.dimen.div_button_image_size, + R.dimen.div_button_image_size + ); + } + } + + private void bindButtonAppearance(@NonNull DivView divView, @NonNull View view, @NonNull DivButtonsBlock.Item data) { + view.setBackground(createBackground(data)); + divView.setActionHandlerForView(view, data.action); + } + + private void bindSingleButtonAlignment(@NonNull View view, @NonNull Alignment alignment) { + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams(); + + switch (alignment) { + case LEFT: + layoutParams.gravity = Gravity.START; + break; + case CENTER: + layoutParams.gravity = Gravity.CENTER_HORIZONTAL; + break; + case RIGHT: + layoutParams.gravity = Gravity.END; + break; + default: + Assert.fail("Unknown value"); + break; + } + } + + private void bindFullsizeButton(@NonNull View view) { + view.setBackground(null); + FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams(); + layoutParams.gravity = Gravity.CENTER_HORIZONTAL; + } + + @NonNull + private View buildMultipleButtons(@NonNull DivView divView, @NonNull List buttonItems, @NonNull Alignment alignment) { + Context context = divView.getContext(); + + RecyclerView recyclerView = new RecyclerView(context); + recyclerView.setId(R.id.div_buttons); + LinearLayoutManager layoutManager = new LinearLayoutManager(context); + layoutManager.setOrientation(LinearLayoutManager.HORIZONTAL); + recyclerView.setLayoutManager(layoutManager); + recyclerView.addItemDecoration(new ButtonListItemDecorator(context.getResources())); + + ButtonsAdapter buttonsAdapter = new ButtonsAdapter(divView, buttonItems); + recyclerView.setAdapter(buttonsAdapter); + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + switch (alignment) { + case LEFT: + layoutParams.gravity = Gravity.START; + break; + case CENTER: + layoutParams.gravity = Gravity.CENTER_HORIZONTAL; + layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT; + break; + case RIGHT: + layoutParams.gravity = Gravity.END; + layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT; + break; + default: + Assert.fail("Unknown value"); + break; + } + layoutParams.gravity = layoutParams.gravity | Gravity.CENTER_VERTICAL; + recyclerView.setLayoutParams(layoutParams); + return recyclerView; + } + + private class ButtonsAdapter extends RecyclerView.Adapter { + + private static final int TEXT_TYPE = 0; + private static final int IMAGE_TYPE = 1; + + @NonNull + private final DivView mDivView; + @NonNull + private final List mButtons; + + ButtonsAdapter(@NonNull DivView divView, + @NonNull List buttons) { + mDivView = divView; + mButtons = buttons; + } + + @NonNull + @Override + public ButtonViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = viewType == TEXT_TYPE ? mViewPool.obtain(FACTORY_TAG_TEXT_BUTTON) : mViewPool.obtain(FACTORY_TAG_IMAGE_BUTTON); + return new ButtonViewHolder(mDivView, view); + } + + @Override + public void onBindViewHolder(@NonNull ButtonViewHolder holder, int position) { + holder.bind(mButtons.get(position)); + } + + @Override + public int getItemViewType(int position) { + return isImageButton(mButtons.get(position)) ? IMAGE_TYPE : TEXT_TYPE; + } + + @Override + public int getItemCount() { + return mButtons.size(); + } + } + + private class ButtonViewHolder extends RecyclerView.ViewHolder { + + @NonNull + private final DivView mDivView; + + ButtonViewHolder(@NonNull DivView divView, @NonNull View itemView) { + super(itemView); + mDivView = divView; + } + + public void bind(@NonNull DivButtonsBlock.Item data) { + bindButtonContent(mDivView, itemView, data); + bindButtonAppearance(mDivView, itemView, data); + } + } + + private static class ButtonListItemDecorator extends RecyclerView.ItemDecoration { + + @Px + private final int mHorizontalItemPadding; + @Px + private final int mVerticalItemPadding; + + ButtonListItemDecorator(@NonNull Resources resources) { + mHorizontalItemPadding = resources.getDimensionPixelSize(R.dimen.div_horizontal_padding); + mVerticalItemPadding = resources.getDimensionPixelSize(R.dimen.div_button_text_vertical_padding); + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + final int position = parent.getLayoutManager().getPosition(view); + outRect.set(position == 0 ? mHorizontalItemPadding : 0, mVerticalItemPadding, + mHorizontalItemPadding, mVerticalItemPadding); + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ContainerBorderLayout.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ContainerBorderLayout.kt new file mode 100644 index 000000000..195858866 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ContainerBorderLayout.kt @@ -0,0 +1,26 @@ +package com.yandex.div.legacy.view + +import android.content.Context +import android.view.Gravity +import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import com.yandex.alicekit.core.utils.dp +import com.yandex.div.legacy.R + +internal class ContainerBorderLayout constructor(context: Context) : RoundedCornersWithStrokeLayout(context) { + + init { + layoutParams = createLayoutParams() + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_VERTICAL + cornerRadius = resources.getDimension(R.dimen.div_gallery_item_corners_radius) + strokeColor = ContextCompat.getColor(context, android.R.color.white) + strokeWidth = dp(1) + } + + private fun createLayoutParams(): LayoutParams { + return LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + gravity = Gravity.CENTER_VERTICAL + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ContainerDivBlockViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ContainerDivBlockViewBuilder.java new file mode 100644 index 000000000..ebb82e2fe --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ContainerDivBlockViewBuilder.java @@ -0,0 +1,217 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.DivAlignment; +import com.yandex.div.DivAlignmentVertical; +import com.yandex.div.DivBackground; +import com.yandex.div.DivContainerBlock; +import com.yandex.div.DivNumericSize; +import com.yandex.div.DivPredefinedSize; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.legacy.DivViewBuilder; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.dagger.LegacyNames; +import com.yandex.div.view.pooling.ViewPool; +import dagger.Lazy; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; + +/** + * Build view for {@link com.yandex.div.DivContainerBlock} + */ +@DivLegacyScope +public class ContainerDivBlockViewBuilder extends DivBaseViewBuilder { + + private static final String FACTORY_TAG_CONTAINER = "ContainerDivBlockViewBuilder.CONTAINER"; + private static final String FACTORY_TAG_SHADOW_FRAME = "ContainerDivBlockViewBuilder.SHADOW_FRAME"; + private static final String FACTORY_TAG_BORDER_FRAME = "ContainerDivBlockViewBuilder.BORDER_FRAME"; + + @NonNull + private final Context mContext; + @NonNull + private final ViewPool mViewPool; + @NonNull + private final DivImageLoader mDivImageLoader; + @NonNull + private final Lazy mViewBuilder; + + @Inject + ContainerDivBlockViewBuilder(@NonNull @Named(LegacyNames.CONTEXT) Context context, + @NonNull ViewPool viewPool, + @NonNull DivImageLoader imageLoader, + @NonNull Lazy viewBuilder) { + super(); + mContext = context; + mViewPool = viewPool; + mDivImageLoader = imageLoader; + mViewBuilder = viewBuilder; + + mViewPool.register(FACTORY_TAG_CONTAINER, () -> new LinearLayout(mContext), 4); + mViewPool.register(FACTORY_TAG_SHADOW_FRAME, () -> new ContainerShadowLayout(mContext), 4); + mViewPool.register(FACTORY_TAG_BORDER_FRAME, () -> new ContainerBorderLayout(mContext), 4); + } + + @Override + @NonNull + protected View build(@NonNull DivView divView, @NonNull DivContainerBlock divData) { + final LinearLayout containerView = mViewPool.obtain(FACTORY_TAG_CONTAINER); + ViewGroup.LayoutParams containerLayoutParams = createLayoutParams(divView, divData); + containerView.setLayoutParams(containerLayoutParams); + containerView.setOrientation(DivContainerBlock.Direction.VERTICAL.equals(divData.direction) + ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL); + + containerView.setGravity(processGravity(divData)); + + DivViewBuilder viewBuilder = mViewBuilder.get(); + viewBuilder.build(divView, containerView, divData, divData.getBlockId()); + + for (int i = 0; i < containerView.getChildCount(); i++) { + View child = containerView.getChildAt(i); + LinearLayout.LayoutParams layoutParams = makeLayoutParams(divData.direction, child.getLayoutParams()); + if (layoutParams != null) { + child.setLayoutParams(layoutParams); + } + } + + processBackgroundData(divView, divData, containerView); + + ViewGroup rootView = createRootView(divData.frame); + if (rootView != null) { + rootView.setLayoutParams(containerLayoutParams); + rootView.addView(containerView); + return rootView; + } + + return containerView; + } + + @Nullable + private static LinearLayout.LayoutParams makeLayoutParams(@Nullable String direction, @Nullable ViewGroup.LayoutParams original) { + if (DivContainerBlock.Direction.HORIZONTAL.equals(direction)) { + LinearLayout.LayoutParams layoutParams = original instanceof LinearLayout.LayoutParams + ? (LinearLayout.LayoutParams) original + : new LinearLayout.LayoutParams(original); + layoutParams.weight = layoutParams.width == LinearLayout.LayoutParams.MATCH_PARENT ? 1 : layoutParams.weight; + return layoutParams; + } + return null; + } + + private int processGravity(@NonNull DivContainerBlock divBlock) { + int gravity = 0; + switch (divBlock.alignmentHorizontal) { + case DivAlignment.LEFT: + gravity = Gravity.LEFT; + break; + case DivAlignment.CENTER: + gravity = Gravity.CENTER_HORIZONTAL; + break; + case DivAlignment.RIGHT: + gravity = Gravity.RIGHT; + break; + default: + Assert.fail("Unsupported container gravity"); + break; + } + switch (divBlock.alignmentVertical) { + case DivAlignmentVertical.TOP: + gravity |= Gravity.TOP; + break; + case DivAlignmentVertical.CENTER: + gravity |= Gravity.CENTER_VERTICAL; + break; + case DivAlignmentVertical.BOTTOM: + gravity |= Gravity.BOTTOM; + break; + default: + Assert.fail("Unsupported container gravity"); + break; + } + return gravity; + } + + @Nullable + private ViewGroup createRootView(@Nullable DivContainerBlock.Frame frame) { + if (frame == null) { + return null; + } + + if (DivContainerBlock.Frame.Style.SHADOW.equals(frame.style)) { + return mViewPool.obtain(FACTORY_TAG_SHADOW_FRAME); + } + + final RoundedCornersWithStrokeLayout containerView = mViewPool.obtain(FACTORY_TAG_BORDER_FRAME); + + if (DivContainerBlock.Frame.Style.ONLY_ROUND_CORNERS.equals(frame.style)) { + containerView.setStrokeWidth(0); + } else if (frame.color != null) { // BORDER + containerView.setStrokeColor(frame.color); + } + + return containerView; + } + + @NonNull + private static LinearLayout.LayoutParams createLayoutParams(@NonNull DivView divView, @NonNull DivContainerBlock divBlock) { + int width = getSize(divView, divBlock.width.asDivNumericSize(), divBlock.width.asDivPredefinedSize()); + int height = getSize(divView, divBlock.height.asDivNumericSize(), divBlock.height.asDivPredefinedSize()); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(width, height); + if (width == LinearLayout.LayoutParams.MATCH_PARENT || height == LinearLayout.LayoutParams.MATCH_PARENT) { + lp.weight = 1; + } + return lp; + } + + private static int getSize(@NonNull DivView divView, + @Nullable DivNumericSize numericSize, + @Nullable DivPredefinedSize predefinedSize) { + if (numericSize != null) { + DisplayMetrics displayMetrics = divView.getResources().getDisplayMetrics(); + return DivViewUtils.divSizeToLayoutParamsSize(numericSize, displayMetrics); + } else if (predefinedSize != null) { + if (DivPredefinedSize.Value.WRAP_CONTENT.equals(predefinedSize.value)) { + return LinearLayout.LayoutParams.WRAP_CONTENT; + } else if (DivPredefinedSize.Value.MATCH_PARENT.equals(predefinedSize.value)) { + return LinearLayout.LayoutParams.MATCH_PARENT; + } + Assert.fail("No predefined size"); + return -1; + } + Assert.fail("No size defined"); + return -1; + } + + private void processBackgroundData(@NonNull DivView divView, @NonNull DivContainerBlock divBlockData, @NonNull View view) { + final List backgroundData = divBlockData.background; + if (backgroundData == null || backgroundData.isEmpty()) { + return; + } + final List drawableList = new ArrayList<>(backgroundData.size()); + //noinspection Convert2streamapi + for (final DivBackground background : backgroundData) { + Drawable drawable = DivViewUtils.divBackgroundToDrawable(background, mDivImageLoader, divView); + if (drawable != null) { + drawableList.add(drawable); + } + } + + if (drawableList.isEmpty()) { + return; + } + + final LayerDrawable layerDrawable = new LayerDrawable(drawableList.toArray(new Drawable[drawableList.size()])); + view.setBackground(layerDrawable); + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ContainerShadowLayout.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ContainerShadowLayout.kt new file mode 100644 index 000000000..c2a397841 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/ContainerShadowLayout.kt @@ -0,0 +1,28 @@ +package com.yandex.div.legacy.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import com.yandex.alicekit.core.utils.dpF +import com.yandex.div.legacy.R + + +internal class ContainerShadowLayout +@JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : androidx.cardview.widget.CardView(context, attrs) { + + init { + layoutParams = createLayoutParams() + radius = resources.getDimension(R.dimen.div_gallery_item_corners_radius) + cardElevation = dpF(2) + maxCardElevation = cardElevation + setCardBackgroundColor(ContextCompat.getColor(context, android.R.color.white)) + } + + private fun createLayoutParams(): LinearLayout.LayoutParams { + return LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + gravity = Gravity.CENTER_VERTICAL + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivBaseViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivBaseViewBuilder.java new file mode 100644 index 000000000..373b00a81 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivBaseViewBuilder.java @@ -0,0 +1,20 @@ +package com.yandex.div.legacy.view; + +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.div.DivBaseBlock; +import com.yandex.div.legacy.DivBlockWithId; + +public abstract class DivBaseViewBuilder { + + @Nullable + public View build(@NonNull DivView divView, @NonNull B block, @NonNull String path) { + block.setBlockId(DivBlockWithId.appendId(path, block.getClass().getSimpleName())); + return build(divView, block); + } + + @Nullable + protected abstract View build(@NonNull DivView divView, @NonNull B block); + +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivElementDataViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivElementDataViewBuilder.java new file mode 100644 index 000000000..db18f5777 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivElementDataViewBuilder.java @@ -0,0 +1,120 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import androidx.annotation.AttrRes; +import androidx.annotation.DimenRes; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.annotation.UiThread; +import androidx.appcompat.widget.AppCompatTextView; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.alicekit.core.views.EmptyDrawable; +import com.yandex.div.DivBaseBlock; +import com.yandex.div.DivImageElement; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.core.images.LoadReference; +import com.yandex.div.legacy.LegacyDivImageDownloadCallback; +import com.yandex.div.legacy.R; +import com.yandex.div.util.DivKitImageUtils; +import com.yandex.div.util.Position; +import com.yandex.images.CachedBitmap; +import com.yandex.images.utils.ScaleMode; +import com.yandex.images.utils.ThumbnailUtils; + +abstract class DivElementDataViewBuilder extends DivBaseViewBuilder { + + private static final String TAG = "DivElementDataViewBuilder"; + + static void setTextAndStyle(@NonNull AppCompatTextView textView, @Nullable CharSequence text, @NonNull TextStyle textStyle) { + if (!TextUtils.isEmpty(text)) { + textStyle.applyWithEllipsizig(textView); + textView.setText(text); + textView.setVisibility(View.VISIBLE); + } else { + textView.setVisibility(View.GONE); + } + } + + @NonNull + static AppCompatTextView createTextView(@NonNull TextViewFactory textViewFactory, + @NonNull Context context, + @AttrRes int styleAttr, @IdRes int id) { + AppCompatTextView textView = textViewFactory.create(context, null, styleAttr); + textView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + textView.setId(id); + return textView; + } + + @Px + static int getDimensionOffset(@NonNull Context context, @DimenRes int dimen) { + return context.getResources().getDimensionPixelOffset(dimen); + } + + @Px + static int getDimensionSize(@NonNull Context context, @DimenRes int dimen) { + return context.getResources().getDimensionPixelSize(dimen); + } + + static void bind(@NonNull DivView divView, + @NonNull DivImageLoader imageLoader, + @NonNull TextView textView, + @Nullable CharSequence divText, + @NonNull DivImageElement imageElement, + @DimenRes int paddingLeftDimenRes, + @DimenRes int paddingRightDimenRes, + @DimenRes int imageWidthDimenRes, + @DimenRes int imageHeightDimenRes) { + + final Resources resources = textView.getResources(); + final int imageWidth = resources.getDimensionPixelSize(imageWidthDimenRes); + final int imageHeight = resources.getDimensionPixelSize(imageHeightDimenRes); + final EmptyDrawable emptyDrawable = new EmptyDrawable(imageWidth, imageHeight); + + // no support for position in button divs yet + setCompoundDrawable(textView, emptyDrawable, Position.LEFT); + textView.setText(divText); + + textView.setCompoundDrawablePadding(resources.getDimensionPixelSize(R.dimen.div_compound_drawable_padding)); + final int paddingLeft = resources.getDimensionPixelOffset(paddingLeftDimenRes); + final int paddingRight = resources.getDimensionPixelOffset(paddingRightDimenRes); + final int vertical = resources.getDimensionPixelOffset(R.dimen.div_compound_drawable_vertical_padding); + textView.setPadding(paddingLeft, vertical, paddingRight, vertical); + + LoadReference loadReference = imageLoader.loadImage( + imageElement.imageUrl.toString(), + DivKitImageUtils.toDivKitCallback(new LegacyDivImageDownloadCallback(divView) { + @UiThread + @Override + public void onSuccess(@NonNull CachedBitmap cachedBitmap) { + final Bitmap resized = ThumbnailUtils.extractThumbnail(cachedBitmap.getBitmap(), imageWidth, imageHeight, 0, + ScaleMode.CENTER_CROP); + final BitmapDrawable drawable = new BitmapDrawable(resources, resized); + setCompoundDrawable(textView, drawable, Position.LEFT); + } + })); + divView.addLoadReference(loadReference, textView); + } + + private static void setCompoundDrawable(@NonNull TextView textView, @NonNull Drawable drawable, + @NonNull Position imagePosition) { + final int h = drawable.getIntrinsicHeight(); + final int w = drawable.getIntrinsicWidth(); + drawable.setBounds(0, 0, w, h); + if (imagePosition == Position.LEFT) { + textView.setCompoundDrawables(drawable, null, null, null); + } else { + Assert.assertEquals(Position.RIGHT, imagePosition); + textView.setCompoundDrawables(null, null, drawable, null); + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivImageBlockViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivImageBlockViewBuilder.java new file mode 100644 index 000000000..d7fdee42e --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivImageBlockViewBuilder.java @@ -0,0 +1,55 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.div.DivImageBlock; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.core.images.LoadReference; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.dagger.LegacyNames; +import com.yandex.div.view.pooling.ViewPool; +import javax.inject.Inject; +import javax.inject.Named; + +@DivLegacyScope +public class DivImageBlockViewBuilder extends DivElementDataViewBuilder { + + private static final String FACTORY_TAG_IMAGE = "DivImageBlockViewBuilder.IMAGE"; + + @NonNull + private final Context mThemedContext; + @NonNull + private final ViewPool mViewPool; + @NonNull + private final DivImageLoader mImageLoader; + + @Inject + DivImageBlockViewBuilder(@NonNull @Named(LegacyNames.THEMED_CONTEXT) Context themedContext, + @NonNull ViewPool viewPool, + @NonNull DivImageLoader imageLoader) { + super(); + mThemedContext = themedContext; + mViewPool = viewPool; + mImageLoader = imageLoader; + + mViewPool.register(FACTORY_TAG_IMAGE, () -> new RatioImageView(mThemedContext), 8); + } + + @Nullable + @Override + protected View build(@NonNull DivView divView, @NonNull DivImageBlock block) { + RatioImageView imageView = mViewPool.obtain(FACTORY_TAG_IMAGE); + imageView.setId(R.id.div_image); + imageView.setRatio(DivViewUtils.getImageRatio(block.image)); + imageView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + + LoadReference loadReference = mImageLoader.loadImage(block.image.imageUrl.toString(), imageView); + divView.addLoadReference(loadReference, imageView); + + return imageView; + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivLineHeightTextViewFactory.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivLineHeightTextViewFactory.kt new file mode 100644 index 000000000..33d512710 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivLineHeightTextViewFactory.kt @@ -0,0 +1,12 @@ +package com.yandex.div.legacy.view + +import android.content.Context +import android.util.AttributeSet +import com.yandex.div.view.SuperLineHeightTextView + +internal class DivLineHeightTextViewFactory : TextViewFactory { + + override fun create(context: Context, attrs: AttributeSet?, defStyle: Int) = + SuperLineHeightTextView(context, attrs, defStyle) + +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivTableBlockViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivTableBlockViewBuilder.java new file mode 100644 index 000000000..60ff552be --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivTableBlockViewBuilder.java @@ -0,0 +1,403 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.content.res.Resources; +import android.view.Gravity; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Space; +import android.widget.TextView; +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatTextView; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.alicekit.core.utils.Views; +import com.yandex.div.DivAction; +import com.yandex.div.DivImageElement; +import com.yandex.div.DivSize; +import com.yandex.div.DivTableBlock; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.core.images.LoadReference; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.LegacyDivDataUtils; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.dagger.LegacyNames; +import com.yandex.div.util.Position; +import com.yandex.div.view.SeparatorView; +import com.yandex.div.view.pooling.ViewPool; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; + +@DivLegacyScope +public class DivTableBlockViewBuilder extends DivBaseViewBuilder { + + private static final String FACTORY_TAG_TABLE = "DivTableBlockViewBuilder.TABLE"; + private static final String FACTORY_TAG_TEXT = "DivTableBlockViewBuilder.TEXT"; + private static final String FACTORY_TAG_IMAGE = "DivTableBlockViewBuilder.IMAGE"; + private static final String FACTORY_TAG_TEXT_AND_IMAGE = "DivTableBlockViewBuilder.TEXT_AND_IMAGE"; + private static final String FACTORY_TAG_SEPARATOR = "DivTableBlockViewBuilder.SEPARATOR"; + + private static final int INVALID_COLUMNS_COUNT = -1; + private static final int INVALID_PADDING = -1; + + @NonNull + private final Context mContext; + @NonNull + private final ViewPool mViewPool; + @NonNull + private final DivImageLoader mImageLoader; + @NonNull + private final DivTextStyleProvider mTextStyleProvider; + @DimenRes + private final int mDefaultColumnPaddingHorizontalRes; + + @Inject + DivTableBlockViewBuilder(@NonNull @Named(LegacyNames.THEMED_CONTEXT) Context context, + @NonNull ViewPool viewPool, + @NonNull DivImageLoader imageLoader, + @NonNull DivTextStyleProvider textStyleProvider) { + super(); + mContext = context; + mViewPool = viewPool; + mImageLoader = imageLoader; + mTextStyleProvider = textStyleProvider; + mDefaultColumnPaddingHorizontalRes = getPaddingResource(DivSize.XS); + + mViewPool.register(FACTORY_TAG_TABLE, () -> new FitTableLayout(mContext), 4); + mViewPool.register(FACTORY_TAG_TEXT, () -> new AppCompatTextView(mContext), 8); + mViewPool.register(FACTORY_TAG_IMAGE, () -> new RatioImageView(mContext), 8); + mViewPool.register(FACTORY_TAG_TEXT_AND_IMAGE, () -> createTextAndImageCell(mContext), 8); + mViewPool.register(FACTORY_TAG_SEPARATOR, () -> new SeparatorView(mContext), 8); + } + + @Nullable + @Override + protected View build(@NonNull DivView divView, @NonNull DivTableBlock tableBlock) { + int columnCount = validateAndGetColumnCount(tableBlock); + if (columnCount > 0) { + FitTableLayout tableLayout = mViewPool.obtain(FACTORY_TAG_TABLE); + tableLayout.setColumnCount(columnCount); + Views.setPadding(tableLayout, R.dimen.div_horizontal_padding, Views.VIEW_SIDE_LEFT); + Views.setPadding(tableLayout, R.dimen.div_horizontal_padding, Views.VIEW_SIDE_RIGHT); + buildViews(divView, tableLayout, tableBlock); + return tableLayout; + } + + return null; + } + + public static int validateAndGetColumnCount(@NonNull DivTableBlock divData) { + int columnCount = INVALID_COLUMNS_COUNT; + int rowCount = 0; + + for (DivTableBlock.Row row : divData.rows) { + final DivTableBlock.RowElement rowElement = row.asRowElement(); + final DivTableBlock.SeparatorElement separatorElement = row.asSeparatorElement(); + + if (rowElement != null) { + final List cells = rowElement.cells; + if (columnCount != INVALID_COLUMNS_COUNT) { + if (columnCount != cells.size()) { + Assert.fail("Found cells size inconsistency!"); + return INVALID_COLUMNS_COUNT; + } + } else { + columnCount = cells.size(); + } + + for (final DivTableBlock.RowElement.Cell cell : cells) { + if (LegacyDivDataUtils.isDivTextValid(cell.text) + || LegacyDivDataUtils.isDivImageValid(cell.image)) { + // row has valid element + rowCount++; + break; + } + } + + } else if (separatorElement == null) { + Assert.fail("Unknown row type: " + row.type); + return INVALID_COLUMNS_COUNT; + } + } + + if (columnCount != INVALID_COLUMNS_COUNT && divData.columns != null && divData.columns.size() != columnCount) { + Assert.fail("Columns data doesn't matches cells size!"); + return INVALID_COLUMNS_COUNT; + } + + if (rowCount == 0) { + Assert.fail("No single data row was added!"); + return INVALID_COLUMNS_COUNT; + } + + return columnCount; + } + + private void buildViews(@NonNull DivView divView, + @NonNull FitTableLayout tableLayout, + @NonNull DivTableBlock tableBlock) { + tableLayout.removeAllViews(); + + final List tableViews = createViews(divView, tableLayout.getColumnCount(), tableBlock.rows, tableBlock.columns); + + for (View cellView: tableViews) { + tableLayout.addView(cellView); + } + } + + @NonNull + private List createViews(@NonNull DivView divView, + int columnCount, + @NonNull List rowData, + @Nullable List columnData) { + final int rowsCount = rowData.size(); + final List tableViews = new ArrayList<>(); + + //noinspection ForLoopReplaceableByForEach + for (int rowIndex = 0; rowIndex < rowsCount; rowIndex++) { + final DivTableBlock.Row row = rowData.get(rowIndex); + + final DivTableBlock.RowElement rowElement = row.asRowElement(); + if (rowElement != null) { + final List cells = rowElement.cells; + for (int columnIndex = 0; columnIndex < cells.size(); columnIndex++) { + DivTableBlock.Column column = columnData == null ? null : columnData.get(columnIndex); + DivTableBlock.RowElement.Cell cell = cells.get(columnIndex); + View cellView = buildCellView(divView, cell, column); + applyColumnPaddings(cellView, column); + applyRowPaddings(cellView, rowElement); + tableViews.add(cellView); + } + } + + final DivTableBlock.SeparatorElement separatorElement = row.asSeparatorElement(); + if (separatorElement != null) { + View separatorView = buildSeparatorView(separatorElement, columnCount); + tableViews.add(separatorView); + } + } + return tableViews; + } + + @NonNull + private View buildCellView(@NonNull DivView divView, + @NonNull DivTableBlock.RowElement.Cell cell, + @Nullable DivTableBlock.Column column) { + View view; + + if (LegacyDivDataUtils.isTextOnlyDiv(cell.text, cell.image)) { + view = buildTextDivView(cell); + } else if (LegacyDivDataUtils.isTextAndImageDiv(cell.text, cell.image)) { + view = buildTextAndImageDivView(divView, cell); + } else if (LegacyDivDataUtils.isImageOnlyDiv(cell.text, cell.image)) { + //noinspection ConstantConditions + view = buildImageDivView(divView, cell.image, getImageSizeDimenRes(cell.imageSize)); + } else { + view = buildUnknownCell(); + } + + final int gravity = DivViewUtils.verticalAlignmentToGravity(cell.verticalAlignment) | + DivViewUtils.horizontalAlignmentToGravity(cell.horizontalAlignment); + + final int weight = column == null ? 0 : column.weight; + setLayoutParams(view, gravity, weight); + + final DivAction action = cell.action; + if (action != null) { + divView.setActionHandlerForView(view, action); + } + return view; + } + + private void applyColumnPaddings(@NonNull View view, @Nullable DivTableBlock.Column colData) { + @DimenRes int leftPaddingRes = colData != null ? getPaddingResource(colData.leftPadding) : mDefaultColumnPaddingHorizontalRes; + @DimenRes int rightPaddinRes = colData != null ? getPaddingResource(colData.rightPadding) : mDefaultColumnPaddingHorizontalRes; + + FitTableLayout.LayoutParams params = (FitTableLayout.LayoutParams) view.getLayoutParams(); + Resources resources = view.getResources(); + params.leftMargin = resources.getDimensionPixelSize(leftPaddingRes); + params.rightMargin = resources.getDimensionPixelSize(rightPaddinRes); + } + + private void applyRowPaddings(@NonNull View view, @NonNull DivTableBlock.RowElement row) { + @DimenRes int topPaddingRes = getPaddingResource(row.topPadding); + @DimenRes int bottomPaddingRes = getPaddingResource(row.bottomPadding); + + FitTableLayout.LayoutParams params = (FitTableLayout.LayoutParams) view.getLayoutParams(); + Resources resources = view.getResources(); + params.topMargin = resources.getDimensionPixelSize(topPaddingRes); + params.bottomMargin = resources.getDimensionPixelSize(bottomPaddingRes); + } + + private void setLayoutParams(@NonNull View view, int gravity, int weight) { + FitTableLayout.LayoutParams params = (FitTableLayout.LayoutParams) view.getLayoutParams(); + if (params == null) { + params = new FitTableLayout.LayoutParams(); + } + + params.gravity = gravity; + params.weight = weight; + view.setLayoutParams(params); + } + + @NonNull + private View buildTextDivView(@NonNull DivTableBlock.RowElement.Cell cell) { + TextStyle textStyle = mTextStyleProvider.getTextStyle(cell.textStyle); + final TextView textView = mViewPool.obtain(FACTORY_TAG_TEXT); + textView.setMaxLines(1); + textView.setIncludeFontPadding(false); + + textView.setText(cell.text); + textStyle.apply(textView); + + return textView; + } + + @NonNull + private View buildImageDivView(@NonNull DivView divView, + @NonNull DivImageElement image, + @DimenRes int imageSizeDimenRes) { + final RatioImageView imageView = mViewPool.obtain(FACTORY_TAG_IMAGE); + imageView.setApplyOn(RatioImageView.APPLY_ON_WIDTH); + imageView.setRatio(DivViewUtils.getImageRatio(image)); + + final int imageSize = mContext.getResources().getDimensionPixelSize(imageSizeDimenRes); + FitTableLayout.LayoutParams params = new FitTableLayout.LayoutParams(); + params.height = imageSize; + imageView.setLayoutParams(params); + + LoadReference loadReference = mImageLoader.loadImage(image.imageUrl.toString(), imageView); + divView.addLoadReference(loadReference, imageView); + + return imageView; + } + + @NonNull + private View buildTextAndImageDivView(@NonNull DivView divView, + @NonNull DivTableBlock.RowElement.Cell cell) { + final LinearLayout textAndImage = mViewPool.obtain(FACTORY_TAG_TEXT_AND_IMAGE); + + ImageView imageView = (ImageView) textAndImage.getChildAt(0); + TextView textView = (TextView) textAndImage.getChildAt(1); + + LinearLayout.LayoutParams firstChildParams; + if (DivViewUtils.divPositionToPosition(cell.imagePosition) == Position.LEFT) { + firstChildParams = (LinearLayout.LayoutParams) imageView.getLayoutParams(); + } else { + textAndImage.removeView(textView); + textAndImage.addView(textView, 0); + firstChildParams = (LinearLayout.LayoutParams) textView.getLayoutParams(); + } + firstChildParams.rightMargin = mContext.getResources().getDimensionPixelSize(R.dimen.div_compound_drawable_padding); + + // binding + textView.setText(cell.text); + final TextStyle textStyle = mTextStyleProvider.getTextM(); + textStyle.apply(textView); + + //noinspection ConstantConditions + LoadReference loadReference = mImageLoader.loadImage(cell.image.imageUrl.toString(), imageView); + divView.addLoadReference(loadReference, imageView); + + return textAndImage; + } + + private static LinearLayout createTextAndImageCell(Context context) { + LinearLayout textAndImage = new LinearLayout(context); + textAndImage.setOrientation(LinearLayout.HORIZONTAL); + textAndImage.setGravity(Gravity.CENTER); + + ImageView imageView = new ImageView(context); + imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); + int imageSize = context.getResources().getDimensionPixelSize(R.dimen.div_table_image_size_m); + LinearLayout.LayoutParams imageLayoutParams = new LinearLayout.LayoutParams(imageSize, imageSize); + imageView.setLayoutParams(imageLayoutParams); + + TextView textView = new AppCompatTextView(context); + textView.setIncludeFontPadding(false); + textView.setMaxLines(1); + + textAndImage.addView(imageView); + textAndImage.addView(textView); + return textAndImage; + } + + @NonNull + private View buildSeparatorView(DivTableBlock.SeparatorElement separatorElement, int columnCount) { + SeparatorView separatorView = mViewPool.obtain(FACTORY_TAG_SEPARATOR); + + separatorView.setDividerHeightResource(R.dimen.div_separator_delimiter_height); + separatorView.setDividerColor(separatorElement.color); + Views.setPadding(separatorView, R.dimen.div_table_padding_xxs, Views.VIEW_SIDE_TOP); + + FitTableLayout.LayoutParams params = new FitTableLayout.LayoutParams( + FitTableLayout.LayoutParams.MATCH_PARENT, FitTableLayout.LayoutParams.WRAP_CONTENT); + params.span = columnCount; + separatorView.setLayoutParams(params); + + return separatorView; + } + + @NonNull + private View buildUnknownCell() { + final Space space = new Space(mContext); + space.setLayoutParams(new FitTableLayout.LayoutParams()); + return space; + } + + @DimenRes + private static int getPaddingResource(@DivSize @Nullable String size) { + if (size == null) { + return INVALID_PADDING; + } + + switch (size) { + case DivSize.XS: + return R.dimen.div_table_padding_xs; + case DivSize.S: + return R.dimen.div_table_padding_s; + case DivSize.M: + return R.dimen.div_table_padding_m; + case DivSize.L: + return R.dimen.div_table_padding_l; + case DivSize.XL: + return R.dimen.div_table_padding_xl; + case DivSize.XXL: + return R.dimen.div_table_padding_xxl; + case DivSize.XXS: + return R.dimen.div_table_padding_xxs; + case DivSize.ZERO: + return R.dimen.div_table_padding_zero; + default: + return INVALID_PADDING; + } + } + + @DimenRes + private static int getImageSizeDimenRes(@Nullable @DivSize String imageSize) { + if (imageSize == null) { + return R.dimen.div_table_image_size_m; + } + + switch (imageSize) { + case DivSize.XS: + return R.dimen.div_table_image_size_xs; + case DivSize.S: + return R.dimen.div_table_image_size_s; + case DivSize.L: + return R.dimen.div_table_image_size_l; + case DivSize.XL: + return R.dimen.div_table_image_size_xl; + case DivSize.XXL: + return R.dimen.div_table_image_size_xxl; + case DivSize.M: + default: + return R.dimen.div_table_image_size_m; + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivView.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivView.java new file mode 100644 index 000000000..0b0ef1280 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivView.java @@ -0,0 +1,305 @@ +package com.yandex.div.legacy.view; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.net.Uri; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.annotations.PublicApi; +import com.yandex.alicekit.core.utils.AnimationUtils; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.DivAction; +import com.yandex.div.DivBackground; +import com.yandex.div.DivData; +import com.yandex.div.DivDataTag; +import com.yandex.div.DivPredefinedSize; +import com.yandex.div.core.images.LoadReference; +import com.yandex.div.legacy.DivBlockWithId; +import com.yandex.div.legacy.DivContext; +import com.yandex.div.legacy.DivViewBuilder; +import com.yandex.div.legacy.LegacyDivDataUtils; +import com.yandex.div.legacy.LegacyDivViewConfig; +import com.yandex.div.legacy.dagger.DivComponent; +import com.yandex.div.legacy.state.LegacyDivViewState; +import com.yandex.div.util.DivViewScrollHelper; +import com.yandex.div.view.menu.OverflowMenuSubscriber; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * View that builds its own layout using the given {@link DivData}. + */ +@SuppressLint("ViewConstructor") +@PublicApi +public class DivView extends LinearLayout implements OverflowMenuSubscriber { + + private final List mOverflowMenuListeners = new ArrayList<>(1); + private final List> mImageLoadReferences = new ArrayList<>(); + + @NonNull + private final DivComponent mComponent; + + @Nullable + private DivData mData; + + @IntRange(from = LegacyDivDataUtils.INVALID_STATE_ID) + private int mCurrentStateId = LegacyDivDataUtils.INVALID_STATE_ID; + @NonNull + private LegacyDivViewConfig mConfig = LegacyDivViewConfig.DEFAULT; + @NonNull + private DivDataTag mTag = DivDataTag.INVALID; + + public DivView(@NonNull Context context) { + this(context, null); + } + + public DivView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public DivView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + if (context instanceof DivContext) { + DivContext divContext = (DivContext) context; + setOrientation(VERTICAL); + mComponent = divContext.getComponent(); + } else { + throw new IllegalStateException("Use DivContext for creating this v"); + } + } + + public void addLoadReference(@NonNull LoadReference loadReference, @NonNull View targetView) { + BaseDivViewExtensionsKt.saveLoadReference(targetView, loadReference); + mImageLoadReferences.add(new WeakReference<>(loadReference)); + } + + public void setConfig(@NonNull LegacyDivViewConfig config) { + mConfig = config; + } + + @Nullable + public DivData getDivData() { + return mData; + } + + public boolean setDivData(@NonNull DivData data, @NonNull DivDataTag tag) { + if (mData == data) { + return false; + } + + cleanup(); + + mData = data; + mTag = tag; + + setBackgroundData(data); + setWidth(); + + LegacyDivViewState state = getCurrentState(); + int stateId = state == null ? LegacyDivDataUtils.getInitialStateId(data) : state.getCurrentDivStateId(); + switchToState(stateId); + + return true; + } + + public void setActionHandlerForView(@NonNull View view, @Nullable DivAction action) { + if (action == null) { + AnimationUtils.detachTouchAnimation(this); + setOnClickListener(null); + return; + } + + AnimationUtils.attachTouchAnimation(view); + view.setOnClickListener(v -> { + mComponent.getLogger().logClick(this, view, action); + handleUri(action.url); + }); + + String id = action.logId; + if (!TextUtils.isEmpty(id)) { + mComponent.getAutoLogger().setId(view, id); + } + } + + @NonNull + public LegacyDivViewConfig getConfig() { + return mConfig; + } + + @NonNull + public DivDataTag getDivTag() { + return mTag; + } + + public void handleUri(@NonNull Uri uri) { + mComponent.getActionHandler().handleUri(uri, this); + } + + public void cleanup() { + dismissPendingOverflowMenus(); + mOverflowMenuListeners.clear(); + + mCurrentStateId = LegacyDivDataUtils.INVALID_STATE_ID; + mData = null; + setBackground(null); + + cancelImageLoads(); + clearSubscriptions(); + } + + private void cancelImageLoads() { + for (WeakReference weakReference : mImageLoadReferences) { + LoadReference reference = weakReference.get(); + if (reference != null) { + reference.cancel(); + } + } + mImageLoadReferences.clear(); + } + + private void setBackgroundData(@NonNull DivData data) { + final List backgroundList = data.background; + if (backgroundList == null || backgroundList.isEmpty()) { + return; + } + + final List drawableList = new ArrayList<>(backgroundList.size()); + + for (final DivBackground background : backgroundList) { + Drawable drawable = LegacyDivDataUtils.divBackgroundToDrawable( + background, + mComponent.getImageLoader(), + this + ); + if (drawable != null) { + drawableList.add(drawable); + } + } + + if (drawableList.isEmpty()) { + return; + } + + final LayerDrawable layerDrawable = new LayerDrawable(drawableList.toArray(new Drawable[drawableList.size()])); + setBackground(layerDrawable); + } + + private void setWidth() { + DivPredefinedSize width = mData != null ? mData.width.asDivPredefinedSize() : null; + if (width != null && DivPredefinedSize.Value.WRAP_CONTENT.equals(width.value)) { + ViewGroup.LayoutParams layoutParams = getLayoutParams(); + layoutParams.width = LayoutParams.WRAP_CONTENT; + setLayoutParams(layoutParams); + } + } + + public void switchToState(@IntRange(from = 0) int id) { + if (mCurrentStateId == id) { + return; + } + setState(id); + } + + public void switchToInitialState() { + if (mData == null) { + return; + } + setState(LegacyDivDataUtils.getInitialStateId(mData)); + } + + @Override + public void subscribe(@NonNull Listener listener) { + mOverflowMenuListeners.add(listener); + } + + public void clearSubscriptions() { + mOverflowMenuListeners.clear(); + } + + public void onConfigurationChangedOutside(@NonNull Configuration newConfig) { + dismissPendingOverflowMenus(); + } + + public void dismissPendingOverflowMenus() { + //noinspection Convert2streamapi + for (final Listener overflowMenuListener : mOverflowMenuListeners) { + overflowMenuListener.dismiss(); + } + } + + public boolean hasScrollableViewUnder(@NonNull MotionEvent event) { + return DivViewScrollHelper.hasScrollableChildUnder(this, event); + } + + public void resetToInitialState() { + LegacyDivViewState viewState = getCurrentState(); + if (viewState != null) { + viewState.reset(); + } + + final int initialState; + if (mData == null) { + Assert.fail("This shouldn't happen. Fix in MOBSEARCHANDROID-16428"); + initialState = LegacyDivDataUtils.INVALID_STATE_ID; + } else { + initialState = LegacyDivDataUtils.getInitialStateId(mData); + } + setState(initialState); + } + + public int getCurrentStateId() { + return mCurrentStateId; + } + + @Nullable + public LegacyDivViewState getCurrentState() { + LegacyDivViewState currentState = mComponent.getStateManager().getState(mTag); + if (mData == null || currentState == null) { + return null; + } + + for (DivData.State state : mData.states) { + if (state.stateId == currentState.getCurrentDivStateId()) { + return currentState; + } + } + return null; + } + + @NonNull + public View getView() { + return this; + } + + private void setState(@IntRange(from = 0) int id) { + mCurrentStateId = id; + removeAllViews(); + + DivData.State currentState = LegacyDivDataUtils.getStateByIdSafely(mData, mCurrentStateId); + if (currentState == null) { + // todo(ntcheban) invalid state processing + mCurrentStateId = LegacyDivDataUtils.INVALID_STATE_ID; + return; + } + + mComponent.getStateManager().updateState(mTag, mCurrentStateId); + + setActionHandlerForView(this, currentState.action); + + final DivViewBuilder viewBuilder = mComponent.getViewBuilder(); + String randomId = UUID.randomUUID().toString(); + viewBuilder.build(this, this, currentState, DivBlockWithId.appendId(randomId + "/state", String.valueOf(mCurrentStateId))); + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivViewLegacyUtils.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivViewLegacyUtils.java new file mode 100644 index 000000000..2906e5ada --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivViewLegacyUtils.java @@ -0,0 +1,43 @@ +package com.yandex.div.legacy.view; + +import android.util.DisplayMetrics; +import android.util.TypedValue; +import androidx.annotation.NonNull; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.DivNumericSize; +import com.yandex.div.DivSizeUnit; + +public class DivViewLegacyUtils { + + public static int divSizeToLayoutParamsSize(@NonNull DivNumericSize numericSize, @NonNull DisplayMetrics metrics) { + if (DivSizeUnit.DP.equals(numericSize.unit)) { + return DivViewLegacyUtils.dpToPx(numericSize.value, metrics); + } else if (DivSizeUnit.SP.equals(numericSize.unit)) { + return DivViewLegacyUtils.spToPx(numericSize.value, metrics); + } + Assert.fail("No unit size defined"); + return -1; + } + + /** + * converts dp to pixels + * + * @param dp + * @param metrics + * @return + */ + public static int dpToPx(int dp, @NonNull DisplayMetrics metrics) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, metrics); + } + + /** + * converts dp to pixels + * + * @param dp + * @param metrics + * @return + */ + public static int spToPx(int dp, @NonNull DisplayMetrics metrics) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, metrics); + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivViewUtils.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivViewUtils.java new file mode 100644 index 000000000..6791f7c27 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/DivViewUtils.java @@ -0,0 +1,250 @@ +package com.yandex.div.legacy.view; + +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import androidx.annotation.ColorInt; +import androidx.annotation.DimenRes; +import androidx.annotation.FloatRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.alicekit.core.utils.Views; +import com.yandex.div.DivAlignment; +import com.yandex.div.DivAlignmentVertical; +import com.yandex.div.DivBackground; +import com.yandex.div.DivGradientBackground; +import com.yandex.div.DivImageBackground; +import com.yandex.div.DivImageElement; +import com.yandex.div.DivNumericSize; +import com.yandex.div.DivPaddingModifier; +import com.yandex.div.DivPosition; +import com.yandex.div.DivSize; +import com.yandex.div.DivSizeUnit; +import com.yandex.div.DivSolidBackground; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.core.images.LoadReference; +import com.yandex.div.legacy.Alignment; +import com.yandex.div.legacy.BackgroundImageDrawable; +import com.yandex.div.legacy.LegacyDivImageDownloadCallback; +import com.yandex.div.legacy.R; +import com.yandex.div.util.DivKitImageUtils; +import com.yandex.div.util.Position; +import com.yandex.images.CachedBitmap; + +public class DivViewUtils { + + private static final float DEFAULT_RATIO = 1.0f; + + private DivViewUtils() { + } + + @SuppressWarnings("squid:CallToDeprecatedMethod") + @Nullable + public static Drawable divBackgroundToDrawable(@Nullable DivBackground background, + @NonNull DivImageLoader imageLoader, + @NonNull DivView divView) { + if (background == null) { + return null; + } + DivSolidBackground solidBackground = background.asDivSolidBackground(); + if (solidBackground != null) { + return getSolidDrawable(solidBackground.color); + } + + DivGradientBackground gradientBackground = background.asDivGradientBackground(); + if (gradientBackground != null) { + return getGradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, gradientBackground.startColor, + gradientBackground.endColor); + } + + DivImageBackground imageBackground = background.asDivImageBackground(); + if (imageBackground != null) { + return getImageDrawable(imageBackground.imageUrl.toString(), imageLoader, divView); + } + + return null; + } + + @NonNull + private static Drawable getSolidDrawable(@ColorInt int color) { + return new ColorDrawable(color); + } + + @NonNull + private static Drawable getGradientDrawable(GradientDrawable.Orientation orientation, + @ColorInt int startColor, @ColorInt int endColor) { + return new GradientDrawable(orientation, new int[]{startColor, endColor}); + } + + @NonNull + private static Drawable getImageDrawable(@NonNull String imageUrl, + @NonNull DivImageLoader imageLoader, + @NonNull DivView targetView) { + final BackgroundImageDrawable backgroundImageDrawable = new BackgroundImageDrawable(targetView.getContext()); + + LoadReference loadReference = imageLoader.loadImage( + imageUrl, + DivKitImageUtils.toDivKitCallback(new LegacyDivImageDownloadCallback(targetView) { + @UiThread + @Override + public void onSuccess(@NonNull CachedBitmap cachedBitmap) { + backgroundImageDrawable.setOriginalBitmap(cachedBitmap.getBitmap()); + } + })); + targetView.addLoadReference(loadReference, targetView); + + return backgroundImageDrawable; + } + + @FloatRange(from = 0.f, fromInclusive = false) + public static float getImageRatio(@Nullable DivImageElement imageElement) { + if (imageElement == null) { + return DEFAULT_RATIO; + } + return (float) (imageElement.ratio > 0 ? imageElement.ratio : DEFAULT_RATIO); + } + + public static void applyPadding(@Nullable DivPaddingModifier paddingModifier, @NonNull View view) { + if (paddingModifier == null) { + return; + } + + @DimenRes + int paddingDimenRes = getPaddingDimenResBySize(paddingModifier.size); + Position position = divPositionToPosition(paddingModifier.position); + + Views.setPadding(view, paddingDimenRes, + position == Position.RIGHT ? Views.VIEW_SIDE_RIGHT : Views.VIEW_SIDE_LEFT); + } + + @DimenRes + static int getPaddingDimenResBySize(@NonNull @DivSize String paddingSize) { + switch (paddingSize) { + case DivSize.S: + return R.dimen.div_horizontal_padding_s; + case DivSize.M: + return R.dimen.div_horizontal_padding_m; + case DivSize.L: + return R.dimen.div_horizontal_padding_l; + default: + return R.dimen.div_horizontal_padding; + } + } + + @Nullable + static Uri getUri(@Nullable String uri) { + return TextUtils.isEmpty(uri) ? null : Uri.parse(uri); + } + + @NonNull + public static Position divPositionToPosition(@Nullable @DivPosition String position) { + if (position == null) { + return Position.LEFT; + } + + switch (position) { + case DivPosition.LEFT: + return Position.LEFT; + case DivPosition.RIGHT: + return Position.RIGHT; + default: + Assert.fail("Unknown position: " + position); + return Position.LEFT; + } + } + + @NonNull + public static Alignment divAlignmentToAlignment(@Nullable @DivAlignment String alignment) { + if (alignment == null) { + return Alignment.LEFT; + } + + switch (alignment) { + case DivAlignment.LEFT: + return Alignment.LEFT; + case DivAlignment.RIGHT: + return Alignment.RIGHT; + case DivAlignment.CENTER: + return Alignment.CENTER; + default: + Assert.fail("Unknown alignment: " + alignment); + return Alignment.LEFT; + } + } + + public static int horizontalAlignmentToGravity(@Nullable @DivAlignment String alignment) { + if (alignment == null) { + return Gravity.LEFT; + } + + switch (alignment) { + case DivAlignment.LEFT: + return Gravity.LEFT; + case DivAlignment.RIGHT: + return Gravity.RIGHT; + case DivAlignment.CENTER: + return Gravity.CENTER_HORIZONTAL; + default: + Assert.fail("Unknown alignment: " + alignment); + return Gravity.LEFT; + } + } + + public static int verticalAlignmentToGravity(@Nullable @DivAlignmentVertical String alignment) { + if (alignment == null) { + return Gravity.CENTER_VERTICAL; + } + + switch (alignment) { + case DivAlignmentVertical.TOP: + return Gravity.TOP; + case DivAlignmentVertical.BOTTOM: + return Gravity.BOTTOM; + case DivAlignmentVertical.CENTER: + return Gravity.CENTER_VERTICAL; + default: + Assert.fail("Unknown vertical alignment: " + alignment); + return Gravity.CENTER_VERTICAL; + } + } + + /** + * converts dp to pixels + * + * @param dp + * @param metrics + * @return + */ + public static int dpToPx(int dp, @NonNull DisplayMetrics metrics) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, metrics); + } + + /** + * converts dp to pixels + * + * @param dp + * @param metrics + * @return + */ + public static int spToPx(int dp, @NonNull DisplayMetrics metrics) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, dp, metrics); + } + + public static int divSizeToLayoutParamsSize(@NonNull DivNumericSize numericSize, @NonNull DisplayMetrics metrics) { + if (DivSizeUnit.DP.equals(numericSize.unit)) { + return DivViewUtils.dpToPx(numericSize.value, metrics); + } else if (DivSizeUnit.SP.equals(numericSize.unit)) { + return DivViewUtils.spToPx(numericSize.value, metrics); + } + Assert.fail("No unit size defined"); + return -1; + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/FitTableLayout.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/FitTableLayout.java new file mode 100644 index 000000000..e20cb06de --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/FitTableLayout.java @@ -0,0 +1,902 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.FloatRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.legacy.R; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +// TODO(gulevsky): add RTL support +public final class FitTableLayout extends ViewGroup { + + private static final int MAX_SIZE = 32768; + private static final int DEFAULT_COLUMN_COUNT = 1; + private static final int UNINITIALIZED_HASH = 0; + + private final Grid mGrid = new Grid(); + + private int mLastLayoutParamsHashCode = UNINITIALIZED_HASH; + private boolean mInitialized = false; + + public FitTableLayout(@NonNull Context context) { + this(context, null); + } + + public FitTableLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public FitTableLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.FitTableLayout, defStyleAttr, 0); + try { + setColumnCount(array.getInt(R.styleable.FitTableLayout_android_columnCount, DEFAULT_COLUMN_COUNT)); + } finally { + array.recycle(); + } + mInitialized = true; + } + + public int getColumnCount() { + return mGrid.getColumnCount(); + } + + public void setColumnCount(int count) { + mGrid.setColumnCount(count); + invalidateStructure(); + requestLayout(); + } + + public int getRowCount() { + return mGrid.getRowCount(); + } + + @Override + protected LayoutParams generateDefaultLayoutParams() { + return new LayoutParams(); + } + + @Override + public LayoutParams generateLayoutParams(AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + @Override + protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) { + if (lp instanceof LayoutParams) { + return new LayoutParams((LayoutParams) lp); + } else if (lp instanceof MarginLayoutParams) { + return new LayoutParams((MarginLayoutParams) lp); + } + return new LayoutParams(lp); + } + + @Override + public void onViewAdded(View child) { + super.onViewAdded(child); + + child.setPivotX(0.0f); + child.setPivotY(0.0f); + invalidateStructure(); + } + + @Override + public void onViewRemoved(View child) { + super.onViewRemoved(child); + invalidateStructure(); + } + + @Override + public void requestLayout() { + super.requestLayout(); + if (mInitialized) { + invalidateMeasurement(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + checkConsistency(); + invalidateMeasurement(); + + resetChildrenScale(); + + int hPadding = getPaddingLeft() + getPaddingRight(); + int vPadding = getPaddingTop() + getPaddingBottom(); + + int maxWidthSpecWithoutPadding = MeasureSpec.makeMeasureSpec( + MeasureSpec.getSize(MAX_SIZE - hPadding), + MeasureSpec.getMode(widthMeasureSpec)); + int maxHeightSpecWithoutPadding = MeasureSpec.makeMeasureSpec( + MeasureSpec.getSize(MAX_SIZE - vPadding), + MeasureSpec.getMode(heightMeasureSpec)); + + measureChildrenInitial(maxWidthSpecWithoutPadding, maxHeightSpecWithoutPadding); + + int widthSpecWithoutPadding = MeasureSpec.makeMeasureSpec( + MeasureSpec.getSize(widthMeasureSpec - hPadding), + MeasureSpec.getMode(widthMeasureSpec)); + int heightSpecWithoutPadding = MeasureSpec.makeMeasureSpec( + MeasureSpec.getSize(heightMeasureSpec - vPadding), + MeasureSpec.getMode(heightMeasureSpec)); + + int contentWidth = mGrid.calcWidth(widthSpecWithoutPadding); + int contentHeight = mGrid.calcHeight(heightSpecWithoutPadding); + + remeasureMatchParentChildren(widthSpecWithoutPadding, heightSpecWithoutPadding); + + float downscale = mGrid.getDownscaleFactor(); + if (downscale < 1.0f) { + contentHeight = downscale(contentHeight, downscale); + } + + int measuredWidth = Math.max(contentWidth + hPadding, getSuggestedMinimumWidth()); + int measuredHeight = Math.max(contentHeight + vPadding, getSuggestedMinimumHeight()); + + setMeasuredDimension( + resolveSizeAndState(measuredWidth, widthMeasureSpec, 0), + resolveSizeAndState(measuredHeight, heightMeasureSpec, 0)); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + checkConsistency(); + + int childCount = getChildCount(); + List columns = mGrid.getColumns(); + List rows = mGrid.getRows(); + List cells = mGrid.getCells(); + + float downscale = mGrid.getDownscaleFactor(); + int paddingLeft = getPaddingLeft(); + int paddingTop = getPaddingTop(); + + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + + if (child.getVisibility() == View.GONE) { + continue; + } + + LayoutParams params = getLayoutParams(child); + Cell cell = cells.get(i); + int cellLeft = getCellLeft(columns, cell); + int cellTop = getCellTop(rows, cell); + int cellWidth = getCellRight(columns, cell) - cellLeft; + int cellHeight = getCellBottom(rows, cell) - cellTop; + int childLeft = calcChildHorizontalPosition(cellLeft, cellWidth, child.getMeasuredWidth(), params.gravity); + int childTop = calcChildVerticalPosition(cellTop, cellHeight, child.getMeasuredHeight(), params.gravity); + + if (downscale < 1.0f) { + child.setScaleX(downscale); + child.setScaleY(downscale); + childLeft = downscale(childLeft, downscale); + childTop = downscale(childTop, downscale); + } + + childLeft += paddingLeft; + childTop += paddingTop; + + child.layout(childLeft, childTop, childLeft + child.getMeasuredWidth(), + childTop + child.getMeasuredHeight()); + } + } + + private static int calcChildHorizontalPosition(int cellLeft, int cellWidth, int childWidth, int gravity) { + int horizontalGravity = gravity & Gravity.HORIZONTAL_GRAVITY_MASK; + switch (horizontalGravity) { + case Gravity.RIGHT: + return cellLeft + cellWidth - childWidth; + + case Gravity.CENTER_HORIZONTAL: + return cellLeft + (cellWidth - childWidth) / 2; + + case Gravity.LEFT: + default: + return cellLeft; + } + } + + private static int calcChildVerticalPosition(int cellTop, int cellHeight, int childHeight, int gravity) { + int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK; + switch (verticalGravity) { + case Gravity.BOTTOM: + return cellTop + cellHeight - childHeight; + + case Gravity.CENTER_VERTICAL: + return cellTop + (cellHeight - childHeight) / 2; + + case Gravity.TOP: + default: + return cellTop; + } + } + + private int downscale(int size, float factor) { + return (int) Math.ceil(size * factor); + } + + private void resetChildrenScale() { + int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + child.setScaleX(1.0f); + child.setScaleY(1.0f); + } + } + + private void measureChildrenInitial(int widthSpec, int heightSpec) { + int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + if (child.getVisibility() == View.GONE) { + continue; + } + + LayoutParams params = getLayoutParams(child); + int childWidth = params.width == LayoutParams.MATCH_PARENT ? 0 : params.width; + int childHeight = params.height == LayoutParams.MATCH_PARENT ? 0 : params.height; + measureChildInitial(child, widthSpec, heightSpec, childWidth, childHeight); + } + } + + private void remeasureMatchParentChildren(int widthSpec, int heightSpec) { + List cells = mGrid.getCells(); + List columns = mGrid.getColumns(); + List rows = mGrid.getRows(); + int childCount = getChildCount(); + + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + if (child.getVisibility() == View.GONE) { + continue; + } + + LayoutParams params = getLayoutParams(child); + if (params.width != LayoutParams.MATCH_PARENT && params.height != LayoutParams.MATCH_PARENT) { + continue; + } + + Cell cell = cells.get(i); + int celldWidth = getCellWidth(columns, cell); + int cellHeight = getCellHeight(rows, cell); + measureMatchParentChild(child, widthSpec, heightSpec, params.width, params.height, celldWidth, cellHeight); + } + } + + private static void measureChildInitial(View child, int parentWidthSpec, int parentHeightSpec, int childWidth, int childHeight) { + int childWidthSpec = getChildMeasureSpec(parentWidthSpec, 0, childWidth); + int childHeightSpec = getChildMeasureSpec(parentHeightSpec, 0, childHeight); + child.measure(childWidthSpec, childHeightSpec); + } + + private static void measureMatchParentChild(View child, int parentWidthSpec, int parentHeightSpec, + int childWidth, int childHeight, + int celldWidth, int cellHeight) { + int childWidthSpec = childWidth == LayoutParams.MATCH_PARENT ? + MeasureSpec.makeMeasureSpec(celldWidth, MeasureSpec.EXACTLY) : + getChildMeasureSpec(parentWidthSpec, 0, childWidth); + int childHeightSpec = childHeight == LayoutParams.MATCH_PARENT ? + MeasureSpec.makeMeasureSpec(cellHeight, MeasureSpec.EXACTLY) : + getChildMeasureSpec(parentHeightSpec, 0, childHeight); + child.measure(childWidthSpec, childHeightSpec); + } + + private static int getCellLeft(@NonNull List columns, @NonNull Cell cell) { + Bounds firstColumn = columns.get(cell.column); + return firstColumn.offset + firstColumn.leadingMargin; + } + + private static int getCellRight(@NonNull List columns, @NonNull Cell cell) { + Bounds lastColumn = columns.get(cell.lastColumn()); + return lastColumn.offset + lastColumn.size - lastColumn.trailingMargin; + } + + private static int getCellTop(@NonNull List rows, @NonNull Cell cell) { + Bounds row = rows.get(cell.row); + return row.offset + row.leadingMargin; + } + + private static int getCellBottom(@NonNull List rows, @NonNull Cell cell) { + Bounds row = rows.get(cell.row); + return row.offset + row.size - row.trailingMargin; + } + + private static int getCellWidth(@NonNull List columns, @NonNull Cell cell) { + return getCellRight(columns, cell) - getCellLeft(columns, cell); + } + + private static int getCellHeight(@NonNull List rows, @NonNull Cell cell) { + return getCellBottom(rows, cell) - getCellTop(rows, cell); + } + + private void invalidateStructure() { + mLastLayoutParamsHashCode = UNINITIALIZED_HASH; + mGrid.invalidateStructure(); + } + + private void invalidateMeasurement() { + mGrid.invalidateMeasurement(); + } + + private int computeLayoutParamsHashCode() { + int result = 223; + int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + if (child.getVisibility() == View.GONE) { + continue; + } + LayoutParams lp = getLayoutParams(child); + result = 31 * result + lp.hashCode(); + } + return result; + } + + private void checkConsistency() { + if (mLastLayoutParamsHashCode == UNINITIALIZED_HASH) { + validateLayoutParams(); + mLastLayoutParamsHashCode = computeLayoutParamsHashCode(); + } else if (mLastLayoutParamsHashCode != computeLayoutParamsHashCode()) { + invalidateStructure(); + checkConsistency(); + } + } + + private void validateLayoutParams() { + int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + LayoutParams params = getLayoutParams(child); + + if (params.weight < 0) { + throw new IllegalStateException("Negative weights are not supported."); + } + + if (params.span < 0) { + throw new IllegalStateException("Negative spans are not supported."); + } + } + } + + private static LayoutParams getLayoutParams(View child) { + return (LayoutParams) child.getLayoutParams(); + } + + public static class LayoutParams extends MarginLayoutParams { + + private static final int DEFAULT_GRAVITY = Gravity.LEFT | Gravity.TOP; + private static final int DEFAULT_WIDTH = WRAP_CONTENT; + private static final int DEFAULT_HEIGHT = WRAP_CONTENT; + private static final int DEFAULT_SPAN = 1; + static final float UNDEFINED_WEIGHT = 0.0f; + + public int gravity; + public int span; + public float weight; + + public LayoutParams() { + this(DEFAULT_WIDTH, DEFAULT_HEIGHT); + } + + public LayoutParams(int width, int height) { + super(width, height); + gravity = DEFAULT_GRAVITY; + span = DEFAULT_SPAN; + weight = UNDEFINED_WEIGHT; + } + + public LayoutParams(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.FitTableLayout_Layout); + try { + gravity = array.getInt(R.styleable.FitTableLayout_Layout_android_layout_gravity, DEFAULT_GRAVITY); + span = array.getInt(R.styleable.FitTableLayout_Layout_android_layout_span, DEFAULT_SPAN); + weight = array.getFloat(R.styleable.FitTableLayout_Layout_android_layout_weight, UNDEFINED_WEIGHT); + } finally { + array.recycle(); + } + } + + public LayoutParams(ViewGroup.LayoutParams source) { + super(source); + } + + public LayoutParams(MarginLayoutParams source) { + super(source); + } + + public LayoutParams(LayoutParams source) { + super(source); + this.gravity = source.gravity; + this.span = source.span; + this.weight = source.weight; + } + + @Override + protected void setBaseAttributes(TypedArray attributes, int widthAttr, int heightAttr) { + this.width = attributes.getLayoutDimension(widthAttr, DEFAULT_WIDTH); + this.height = attributes.getLayoutDimension(heightAttr, DEFAULT_HEIGHT); + } + + @Override + public boolean equals(Object that) { + if (this == that) { + return true; + } + if (that == null || getClass() != that.getClass()) { + return false; + } + + LayoutParams params = (LayoutParams) that; + return width == params.width && + height == params.height && + leftMargin == params.leftMargin && + rightMargin == params.rightMargin && + topMargin == params.topMargin && + bottomMargin == params.bottomMargin && + gravity == params.gravity && + span == params.span && + weight == params.weight; + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + gravity; + result = 31 * result + span; + result = 31 * result + Float.floatToIntBits(weight); + return result; + } + } + + private static class Bounds { + + int size; + int offset; + int leadingMargin; + int trailingMargin; + + Bounds(int size, int offset) { + this(size, offset, 0, 0); + } + + Bounds(int size, int offset, int leadingMargin, int trailingMargin) { + this.size = size; + this.offset = offset; + this.leadingMargin = leadingMargin; + this.trailingMargin = trailingMargin; + } + + int getMargins() { + return leadingMargin + trailingMargin; + } + + int getCellSize() { + return size - getMargins(); + } + + void include(int viewSize, int leadingMargin, int trailingMargin) { + int cellSize = getCellSize(); + + this.leadingMargin = Math.max(this.leadingMargin, leadingMargin); + this.trailingMargin = Math.max(this.trailingMargin, trailingMargin); + cellSize = Math.max(cellSize, viewSize); + + this.size = cellSize + this.leadingMargin + this.trailingMargin; + } + } + + private static class WeightedBounds extends Bounds { + + float weight = LayoutParams.UNDEFINED_WEIGHT; + + WeightedBounds(int size, int offset) { + super(size, offset); + } + + WeightedBounds(int size, int offset, int leadingMargin, int trailingMargin, float weight) { + super(size, offset, leadingMargin, trailingMargin); + this.weight = weight; + } + + void include(int viewsize, int leadingMargin, int trailingMargin, float weight) { + super.include(viewsize, leadingMargin, trailingMargin); + this.weight = Math.max(this.weight, weight); + } + + boolean isFlexible() { + return weight != LayoutParams.UNDEFINED_WEIGHT; + } + } + + private static class Cell { + + final int viewIndex; + final int column; + final int row; + final int span; + + Cell(int viewIndex, int column, int row, int span) { + this.viewIndex = viewIndex; + this.column = column; + this.row = row; + this.span = span; + } + + int lastColumn() { + return column + span - 1; + } + } + + private static class SizeConstraint { + + public int min; + public int max; + + SizeConstraint(int min, int max) { + this.min = min; + this.max = max; + } + + void set(int min, int max) { + this.min = min; + this.max = max; + } + } + + private class Grid { + + @NonNull + private final List mColumns = new ArrayList<>(); + @NonNull + private final List mRows = new ArrayList<>(); + @NonNull + private final List mCells = new ArrayList<>(); + + @NonNull + private final SizeConstraint mWidthConstraint = new SizeConstraint(0, MAX_SIZE); + @NonNull + private final SizeConstraint mHeigthConstraint = new SizeConstraint(0, MAX_SIZE); + private final Comparator mLastColumnCellComparator = new LastColumnCellComparator(); + + private int mColumnCount; + private int mRowCount; + private boolean mStructureValid; + private boolean mColumnsMeasurmentValid; + private boolean mRowsMeasurmentValid; + + int getColumnCount() { + return mColumnCount; + } + + void setColumnCount(int count) { + mColumnCount = count; + } + + int getRowCount() { + if (!mStructureValid) { + createStructure(); + } + return mRowCount; + } + + void invalidateStructure() { + mCells.clear(); + mStructureValid = false; + + invalidateMeasurement(); + } + + void invalidateMeasurement() { + mColumns.clear(); + mRows.clear(); + mColumnsMeasurmentValid = false; + mRowsMeasurmentValid = false; + } + + @NonNull + List getCells() { + if (!mStructureValid) { + createStructure(); + } + return mCells; + } + + @NonNull + List getColumns() { + if (!mStructureValid) { + createStructure(); + } + if (!mColumnsMeasurmentValid) { + measureColumns(); + } + return mColumns; + } + + @NonNull + List getRows() { + if (!mStructureValid) { + createStructure(); + } + if (!mRowsMeasurmentValid) { + measureRows(); + } + return mRows; + } + + int calcWidth(int widthSpec) { + setParentConstraints(mWidthConstraint, widthSpec); + int gridWidth = getGridWidth(); + return Math.max(mWidthConstraint.min, Math.min(gridWidth, mWidthConstraint.max)); + } + + int calcHeight(int heightSpec) { + setParentConstraints(mHeigthConstraint, heightSpec); + int gridHeight = getGridHeight(); + return Math.max(mHeigthConstraint.min, Math.min(gridHeight, mHeigthConstraint.max)); + } + + @FloatRange(from = 0.0f, to = 1.0f) + float getDownscaleFactor() { + int gridWidth = getGridWidth(); + if (gridWidth <= mWidthConstraint.max) { + return 1.0f; + } + return (float) mWidthConstraint.max / gridWidth; + } + + private int getGridWidth() { + List columns = getColumns(); + if (columns.isEmpty()) { + return 0; + } + Bounds lastColumn = columns.get(columns.size() - 1); + return lastColumn.offset + lastColumn.size; + } + + private int getGridHeight() { + List rows = getRows(); + if (rows.isEmpty()) { + return 0; + } + Bounds lastRow = rows.get(rows.size() - 1); + return lastRow.offset + lastRow.size; + } + + private void setParentConstraints(@NonNull SizeConstraint constraints, int measureSpec) { + int mode = MeasureSpec.getMode(measureSpec); + int size = MeasureSpec.getSize(measureSpec); + switch (mode) { + case MeasureSpec.UNSPECIFIED: + constraints.set(0, MAX_SIZE); + break; + + case MeasureSpec.EXACTLY: + constraints.set(size, size); + break; + + case MeasureSpec.AT_MOST: + constraints.set(0, size); + break; + } + } + + private void createStructure() { + if (mStructureValid) { + Assert.fail("Rectreation of valid structure is not allowed"); + return; + } + + int columnCount = mColumnCount; + int childCount = getChildCount(); + int filledCells = 0; + + for (int i = 0; i < childCount; i++) { + LayoutParams params = getLayoutParams(getChildAt(i)); + int column = filledCells % columnCount; + int row = filledCells / columnCount; + int span = Math.min(params.span, columnCount - column); + + mCells.add(new Cell(i, column, row, span)); + filledCells += span; + } + + mRowCount = (filledCells - 1) / columnCount + 1; + mStructureValid = true; + } + + private void measureRows() { + for (int i = 0; i < mRowCount; i++) { + mRows.add(new Bounds(0, 0)); + } + + for (int i = 0; i < mCells.size(); i++) { + Cell cell = mCells.get(i); + Bounds row = mRows.get(cell.row); + View child = getChildAt(cell.viewIndex); + + LayoutParams params = getLayoutParams(child); + row.include(child.getMeasuredHeight(), params.topMargin, params.bottomMargin); + } + + int totalHeight = 0; + for (int i = 0; i < mRowCount; i++) { + Bounds row = mRows.get(i); + row.offset = totalHeight; + totalHeight += row.size; + } + + mRowsMeasurmentValid = true; + } + + private void measureColumns() { + for (int i = 0; i < mColumnCount; i++) { + mColumns.add(new WeightedBounds(0, 0)); + } + + List reorderedCells = createLastColumnOrderedCells(); + List fixedSpannedCells = new ArrayList<>(); + List flexibleSpannedCells = new ArrayList<>(); + + // Pass one: placing children without span + for (int i = 0; i < reorderedCells.size(); i++) { + Cell cell = reorderedCells.get(i); + View child = getChildAt(cell.viewIndex); + if (child.getVisibility() == GONE) { + continue; + } + + LayoutParams params = getLayoutParams(child); + WeightedBounds column = mColumns.get(cell.column); + if (cell.span == 1) { + column.include(child.getMeasuredWidth(), params.leftMargin, params.rightMargin, params.weight); + } else { + if (isFlexibleSpan(cell)) { + flexibleSpannedCells.add(cell); + } else { + fixedSpannedCells.add(cell); + } + Bounds endColumn = mColumns.get(cell.column + cell.span - 1); + column.include(0, params.leftMargin, Integer.MIN_VALUE, params.weight); + endColumn.include(0, Integer.MIN_VALUE, params.rightMargin); + } + } + + // Pass two: placing children with fixed span + for (int i = 0; i < fixedSpannedCells.size(); i++) { + Cell cell = fixedSpannedCells.get(i); + View child = getChildAt(cell.viewIndex); + Bounds firstColumn = mColumns.get(cell.column); + Bounds lastColumn = mColumns.get(cell.column + cell.span - 1); + + int remainedWidth = child.getMeasuredWidth() + firstColumn.leadingMargin; + for (int j = 0; j < cell.span - 1; j++) { + WeightedBounds column = mColumns.get(cell.column + j); + remainedWidth -= column.size; + } + lastColumn.include(remainedWidth - lastColumn.leadingMargin, lastColumn.leadingMargin, + lastColumn.trailingMargin); + } + + // Pass three: placing children with flexible span + for (int i = 0; i < flexibleSpannedCells.size(); i++) { + Cell cell = flexibleSpannedCells.get(i); + View child = getChildAt(cell.viewIndex); + Bounds firstColumn = mColumns.get(cell.column); + Bounds lastColumn = mColumns.get(cell.column + cell.span - 1); + + int flexibleWidth = child.getMeasuredWidth() + firstColumn.leadingMargin + lastColumn.trailingMargin; + float spanTotalWeight = 0.0f; + for (int j = 0; j < cell.span; j++) { + WeightedBounds column = mColumns.get(cell.column + j); + if (column.isFlexible()) { + spanTotalWeight += column.weight; + } else { + flexibleWidth -= column.size; + } + } + + for (int j = 0; j < cell.span; j++) { + WeightedBounds column = mColumns.get(cell.column + j); + if (column.isFlexible()) { + int viewWidth = (int) Math.ceil(column.weight / spanTotalWeight * flexibleWidth); + viewWidth -= column.leadingMargin + column.trailingMargin; + column.include(viewWidth, column.leadingMargin, column.trailingMargin); + } + } + } + + // Pass four: adjusting weighted columns + + float totalWeight = 0.0f; + float maxWeightedWidth = 0.0f; + for (int i = 0; i < mColumnCount; i++) { + WeightedBounds column = mColumns.get(i); + if (column.isFlexible()) { + totalWeight += column.weight; + maxWeightedWidth = Math.max(maxWeightedWidth, column.getCellSize() / column.weight); + } + } + int minTotalWidth = 0; + for (int i = 0; i < mColumnCount; i++) { + WeightedBounds column = mColumns.get(i); + if (column.isFlexible()) { + int viewWidth = (int) Math.ceil(column.weight * maxWeightedWidth); + column.include(viewWidth, column.leadingMargin, column.trailingMargin); + } + minTotalWidth += column.size; + } + int freeSpace = calcFreeSpace(minTotalWidth); + for (int i = 0; i < mColumnCount; i++) { + WeightedBounds column = mColumns.get(i); + if (column.isFlexible()) { + int viewWidth = (int) Math.ceil(column.getCellSize() + (freeSpace * column.weight / totalWeight)); + column.include(viewWidth, column.leadingMargin, column.trailingMargin); + } + } + + // Pass five: columns aligning + int totalWidth = 0; + for (int i = 0; i < mColumnCount; i++) { + Bounds column = mColumns.get(i); + column.offset = totalWidth; + totalWidth += column.size; + } + + mColumnsMeasurmentValid = true; + } + + @NonNull + private List createLastColumnOrderedCells() { + List result = new ArrayList<>(mCells); + Collections.sort(result, mLastColumnCellComparator); + return result; + } + + private boolean isFlexibleSpan(@NonNull Cell cell) { + if (cell.span == 1) { + return false; + } + + for (int i = 0; i < cell.span; i++) { + WeightedBounds column = mColumns.get(cell.column + i); + if (column.isFlexible()) { + return true; + } + } + return false; + } + + private int calcFreeSpace(int minWidth) { + return Math.max(0, mWidthConstraint.max - minWidth); + } + } + + private static final class LastColumnCellComparator implements Comparator { + @Override + public int compare(Cell lhs, Cell rhs) { + int lhsLastColumn = lhs.column + lhs.span; + int rhsLastColumn = rhs.column + rhs.span; + if (lhsLastColumn < rhsLastColumn) { + return -1; + } else if (lhsLastColumn > rhsLastColumn) { + return 1; + } else { + return Integer.compare(lhs.row, rhs.row); + } + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/FooterDivViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/FooterDivViewBuilder.java new file mode 100644 index 000000000..1406a60b7 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/FooterDivViewBuilder.java @@ -0,0 +1,83 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatTextView; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.DivFooterBlock; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.LegacyDivDataUtils; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.dagger.LegacyNames; +import com.yandex.div.view.pooling.ViewPool; +import javax.inject.Inject; +import javax.inject.Named; + +@DivLegacyScope +public class FooterDivViewBuilder extends DivElementDataViewBuilder { + + private static final String FACTORY_TAG_FOOTER = "FooterDivViewBuilder.FOOTER"; + + @NonNull + private final Context mThemedContext; + @NonNull + private final ViewPool mViewPool; + @NonNull + private final DivImageLoader mImageLoader; + @NonNull + private final DivTextStyleProvider mTextStyleProvider; + + @Inject + FooterDivViewBuilder(@NonNull @Named(LegacyNames.THEMED_CONTEXT) Context themedContext, + @NonNull ViewPool viewPool, + @NonNull DivImageLoader imageLoader, + @NonNull DivTextStyleProvider textStyleProvider, + @NonNull TextViewFactory textViewFactory) { + super(); + mThemedContext = themedContext; + mViewPool = viewPool; + mImageLoader = imageLoader; + mTextStyleProvider = textStyleProvider; + + mViewPool.register(FACTORY_TAG_FOOTER, () -> createTextView(textViewFactory, + mThemedContext, + R.attr.legacyFooterStyle, + R.id.div_footer), 8); + } + + public static boolean isValidBlock(@NonNull DivFooterBlock divData) { + return LegacyDivDataUtils.isTextOnlyDiv(divData.text, divData.image) + || LegacyDivDataUtils.isDivImageValid(divData.image); + } + + @Override + @Nullable + protected View build(@NonNull DivView divView, @NonNull DivFooterBlock divData) { + if (!isValidBlock(divData)) { + Assert.fail("Unexpected element [" + divData + "]"); + return null; + } + + final AppCompatTextView itemView = mViewPool.obtain(FACTORY_TAG_FOOTER); + + TextStyle textStyle = mTextStyleProvider.getTextStyle(divData.textStyle); + textStyle.applyWithEllipsizig(itemView); + + if (LegacyDivDataUtils.isTextOnlyDiv(divData.text, divData.image)) { + itemView.setText(divData.text); + } else if (LegacyDivDataUtils.isDivImageValid(divData.image)) { + bind(divView, mImageLoader, itemView, divData.text, divData.image, + R.dimen.div_horizontal_padding, R.dimen.div_horizontal_padding, + R.dimen.div_footer_image_size, R.dimen.div_footer_image_size); + } else { + Assert.fail("How come? Check that #isValidBlock method code is up to date!"); + return null; + } + + return itemView; + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/GalleryDivViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/GalleryDivViewBuilder.java new file mode 100644 index 000000000..f2d4acc84 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/GalleryDivViewBuilder.java @@ -0,0 +1,434 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.annotation.UiThread; +import androidx.appcompat.widget.LinearLayoutCompat; +import androidx.core.graphics.drawable.DrawableCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.alicekit.core.utils.DrawableUtils; +import com.yandex.alicekit.core.utils.Views; +import com.yandex.alicekit.core.views.EllipsizingTextView; +import com.yandex.div.DivContainerBlock; +import com.yandex.div.DivGalleryBlock; +import com.yandex.div.DivNumericSize; +import com.yandex.div.DivPaddingModifier; +import com.yandex.div.DivPosition; +import com.yandex.div.DivPredefinedSize; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.core.images.LoadReference; +import com.yandex.div.legacy.DivBlockWithId; +import com.yandex.div.legacy.DivLogger; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.LegacyDivImageDownloadCallback; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.dagger.LegacyNames; +import com.yandex.div.legacy.state.LegacyDivViewState; +import com.yandex.div.legacy.state.LegacyGalleryState; +import com.yandex.div.legacy.state.LegacyUpdateStateScrollListener; +import com.yandex.div.util.DivKitImageUtils; +import com.yandex.div.view.PaddingItemDecoration; +import com.yandex.div.view.pooling.ViewPool; +import com.yandex.images.CachedBitmap; +import javax.inject.Inject; +import javax.inject.Named; + +@DivLegacyScope +public class GalleryDivViewBuilder extends DivBaseViewBuilder { + + private static final String FACTORY_TAG_GALLERY = "GalleryDivViewBuilder.GALLERY"; + private static final String FACTORY_TAG_ITEM = "GalleryDivViewBuilder.ITEM"; + private static final String FACTORY_TAG_TAIL = "GalleryDivViewBuilder.TAIL"; + + @NonNull + private final Context mContext; + @NonNull + private final ViewPool mViewPool; + @NonNull + private final DivImageLoader mImageLoader; + @NonNull + private final DivTextStyleProvider mTextStyleProvider; + @NonNull + private final ContainerDivBlockViewBuilder mContainerBuilder; + @NonNull + private final DivLogger mDivLogger; + + @Inject + GalleryDivViewBuilder(@NonNull @Named(LegacyNames.CONTEXT) Context context, + @NonNull ViewPool viewPool, + @NonNull DivImageLoader imageLoader, + @NonNull DivTextStyleProvider textStyleProvider, + @NonNull ContainerDivBlockViewBuilder containerBuilder, + @NonNull DivLogger divLogger) { + super(); + mContext = context; + mViewPool = viewPool; + mImageLoader = imageLoader; + mTextStyleProvider = textStyleProvider; + mContainerBuilder = containerBuilder; + mDivLogger = divLogger; + + mViewPool.register(FACTORY_TAG_GALLERY, () -> createGalleryView(mContext), 2); + mViewPool.register(FACTORY_TAG_ITEM, () -> createItemView(mContext), 8); + mViewPool.register(FACTORY_TAG_TAIL, () -> createTailView(mContext), 2); + } + + @Override + @NonNull + protected View build(@NonNull DivView divView, @NonNull DivGalleryBlock divData) { + RecyclerView galleryView = mViewPool.obtain(FACTORY_TAG_GALLERY); + + LinearLayoutManager layoutManager = new LinearLayoutManager(mContext, RecyclerView.HORIZONTAL, false); + galleryView.setLayoutManager(layoutManager); + + GalleryAdapter adapter = new GalleryAdapter(divView, divData); + galleryView.setAdapter(adapter); + + setupScrollState(divView, divData, galleryView, layoutManager); + + Resources resources = mContext.getResources(); + galleryView.addItemDecoration(divData.tail != null + ? buildPaddingDecorationWithTail(resources, divData) + : buildPaddingDecorationNoTail(resources, divData)); + + return galleryView; + } + + private void setupScrollState(@NonNull DivView divView, + @NonNull DivGalleryBlock block, + @NonNull RecyclerView recyclerView, + @NonNull LinearLayoutManager layoutManager) { + LegacyDivViewState viewState = divView.getCurrentState(); + Assert.assertNotNull(viewState); + if (viewState == null) { + return; + } + + LegacyGalleryState blockState = viewState.getBlockState(block.getBlockId()); + if (blockState != null) { + layoutManager.scrollToPositionWithOffset(blockState.getVisibleItemIndex(), blockState.getScrollOffset()); + } + recyclerView.addOnScrollListener( + new LegacyUpdateStateScrollListener(block.getBlockId(), viewState, layoutManager) + ); + recyclerView.addOnScrollListener(new ScrollListener(divView, layoutManager)); + } + + private class ScrollListener extends RecyclerView.OnScrollListener { + @NonNull + private final DivView mDivView; + @NonNull + private final LinearLayoutManager mLayoutManager; + private final int mMinimumSignificantDx; + + private int mTotalDx = 0; + private boolean mAlreadyLogged = false; + + ScrollListener(@NonNull DivView divView, @NonNull LinearLayoutManager layoutManager) { + mDivView = divView; + mLayoutManager = layoutManager; + mMinimumSignificantDx = divView.getConfig().getLogCardScrollSignificantThreshold(); + } + + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + mAlreadyLogged = false; + } + } + + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + int minimumDx = mMinimumSignificantDx > 0 + ? mMinimumSignificantDx + : mLayoutManager.getWidth() / 20; + mTotalDx += Math.abs(dx); + if (mTotalDx > minimumDx) { + mTotalDx = 0; + if (!mAlreadyLogged) { + mAlreadyLogged = true; + mDivLogger.logGalleryScroll(mDivView); + } + } + } + } + + @NonNull + private RecyclerView.ItemDecoration buildPaddingDecorationNoTail(@NonNull Resources resources, @NonNull DivGalleryBlock divData) { + @Px int paddingLeft = resources.getDimensionPixelOffset(R.dimen.div_gallery_horizontal_padding); + @Px int paddingRight = paddingLeft; + + DivPaddingModifier paddingModifier = divData.paddingModifier; + if (paddingModifier != null) { + final int paddingSize = resources.getDimensionPixelOffset(DivViewUtils.getPaddingDimenResBySize(paddingModifier.size)); + + if (DivPosition.LEFT.equals(paddingModifier.position)) { + paddingLeft = paddingSize; + } else { + paddingRight = paddingSize; + } + } + + @Px int itemInternalHorizontalPadding = getItemInternalHorizontalPadding(resources); + @Px final int midPadding = getPaddingBetweenItems(divData.paddingBetweenItems, resources); + @Px final int paddingTop = divSizeToPixelSize(divData.paddingTop, resources); + @Px final int paddingBottom = divSizeToPixelSize(divData.paddingBottom, resources); + return new PaddingItemDecoration(paddingLeft - itemInternalHorizontalPadding, midPadding, paddingRight - itemInternalHorizontalPadding, + paddingTop, paddingBottom); + } + + @NonNull + private RecyclerView.ItemDecoration buildPaddingDecorationWithTail(@NonNull Resources resources, @NonNull DivGalleryBlock divData) { + @Px int itemInternalHorizontalPadding = getItemInternalHorizontalPadding(resources); + @Px int tailPaddingRight = resources.getDimensionPixelOffset(R.dimen.div_gallery_tail_horizontal_padding); + @Px final int tailPaddingLeft = tailPaddingRight - itemInternalHorizontalPadding; + + @Px int paddingLeft = resources.getDimensionPixelOffset(R.dimen.div_gallery_horizontal_padding); + + DivPaddingModifier paddingModifier = divData.paddingModifier; + if (paddingModifier != null) { + final int paddingSize = resources.getDimensionPixelOffset(DivViewUtils.getPaddingDimenResBySize(paddingModifier.size)); + + if (DivPosition.LEFT.equals(paddingModifier.position)) { + paddingLeft = paddingSize; + } else { + tailPaddingRight = paddingSize; + } + } + + @Px final int midPadding = getPaddingBetweenItems(divData.paddingBetweenItems, resources); + @Px final int paddingTop = divSizeToPixelSize(divData.paddingTop, resources); + @Px final int paddingBottom = divSizeToPixelSize(divData.paddingBottom, resources); + return new GalleryWithTailItemDecoration(paddingLeft - itemInternalHorizontalPadding, midPadding, + tailPaddingLeft, tailPaddingRight, + paddingTop, paddingBottom); + } + + private static int getItemInternalHorizontalPadding(@NonNull Resources resources) { + return resources.getDimensionPixelOffset(R.dimen.div_gallery_horizontal_internal_item_padding); + } + + private static int getPaddingBetweenItems(@NonNull DivNumericSize paddingBetweenItems, Resources resources) { + final int midPadding = divSizeToPixelSize(paddingBetweenItems, resources) + - 2 * getItemInternalHorizontalPadding(resources); + + return Math.max(midPadding, 0); + } + + private static int divSizeToPixelSize(@NonNull DivNumericSize divSize, @NonNull Resources resources) { + return DivViewUtils.divSizeToLayoutParamsSize(divSize, resources.getDisplayMetrics()); + } + + @NonNull + private static RecyclerView createGalleryView(@NonNull Context context) { + RecyclerView galleryView = new RecyclerView(context); + galleryView.setId(R.id.div_gallery); + galleryView.setScrollingTouchSlop(RecyclerView.TOUCH_SLOP_PAGING); + + ViewGroup.LayoutParams layoutParams = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + galleryView.setLayoutParams(layoutParams); + return galleryView; + } + + @NonNull + private static View createItemView(@NonNull Context context) { + LinearLayout itemLayout = new LinearLayout(context); + itemLayout.setLayoutParams(new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + itemLayout.setGravity(Gravity.CENTER); + itemLayout.setOrientation(LinearLayout.HORIZONTAL); + return itemLayout; + } + + @NonNull + private static View createTailView(@NonNull Context context) { + return new GalleryTailLayout(context); + } + + private class GalleryAdapter extends RecyclerView.Adapter { + private static final int VIEW_TYPE_ITEM = 0; + private static final int VIEW_TYPE_TAIL = 1; + + @NonNull + private final DivView mDivView; + @NonNull + private final DivGalleryBlock mDivData; + + private int mMaxHeight = -1; + + GalleryAdapter(@NonNull DivView divView, @NonNull DivGalleryBlock divData) { + mDivView = divView; + mDivData = divData; + } + + private void calculateMaxItemsHeight(@NonNull View view) { + DivNumericSize tempHeight = null; + for (DivContainerBlock divBlock : mDivData.items) { + DivNumericSize height = divBlock.height.asDivNumericSize(); + if (tempHeight == null || (height != null && height.value > tempHeight.value)) { + tempHeight = height; + } + } + + if (tempHeight != null) { + mMaxHeight = divSizeToPixelSize(tempHeight, view.getResources()); + } + } + + @Override + public GalleryItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = viewType == VIEW_TYPE_ITEM ? mViewPool.obtain(FACTORY_TAG_ITEM) : mViewPool.obtain(FACTORY_TAG_TAIL); + if (mMaxHeight == -1) { + calculateMaxItemsHeight(parent); + } + + if (mMaxHeight > 0) { + ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, mMaxHeight); + view.setLayoutParams(lp); + } + return new GalleryItemViewHolder(view, mDivView, mDivData); + } + + @Override + public void onBindViewHolder(@NonNull GalleryItemViewHolder holder, int position) { + if (getItemViewType(position) == VIEW_TYPE_ITEM) { + DivContainerBlock divData = mDivData.items.get(position); + holder.bind(divData, position, getItemInternalHorizontalPadding(mDivView.getResources())); + } else if (mDivData.tail != null) { + holder.bind(mDivData.tail); + } else { + Assert.fail("Internal error, gallery tail is null"); + } + } + + @Override + public int getItemCount() { + int count = mDivData.items.size(); + return mDivData.tail == null ? count : count + 1; + } + + @Override + public int getItemViewType(int position) { + return position < mDivData.items.size() ? VIEW_TYPE_ITEM : VIEW_TYPE_TAIL; + } + } + + private class GalleryItemViewHolder extends RecyclerView.ViewHolder { + + @NonNull + private final DivView mDivView; + @NonNull + private final DivGalleryBlock mDivData; + private final int mBackgroundStrokeSize; + + GalleryItemViewHolder(@NonNull View itemView, @NonNull DivView divView, @NonNull DivGalleryBlock divData) { + super(itemView); + mDivView = divView; + mDivData = divData; + mBackgroundStrokeSize = mDivView.getResources().getDimensionPixelSize(R.dimen.div_gallery_tail_image_stroke_size); + } + + public void bind(@NonNull DivContainerBlock divData, int position, int horizontalMargin) { + ViewGroup viewGroup = (ViewGroup) itemView; + viewGroup.removeAllViews(); + + View containerView = mContainerBuilder.build(mDivView, divData, DivBlockWithId.appendId(mDivData.getBlockId(), String.valueOf(position))); + + DivPredefinedSize width = divData.width.asDivPredefinedSize(); + if (width != null && DivPredefinedSize.Value.MATCH_PARENT.equals(width.value)) { + Views.setWidth(itemView, RecyclerView.LayoutParams.MATCH_PARENT); + } + + mDivView.setActionHandlerForView(itemView, divData.action); + + viewGroup.addView(containerView); + + LinearLayout.LayoutParams marginLayoutParams = new LinearLayout.LayoutParams(containerView.getLayoutParams()); + marginLayoutParams.setMargins(horizontalMargin, 0, horizontalMargin, 0); + containerView.setLayoutParams(marginLayoutParams); + } + + public void bind(@NonNull DivGalleryBlock.Tail tailData) { + EllipsizingTextView textView = itemView.findViewById(R.id.div_gallery_tail_text); + if (!TextUtils.isEmpty(tailData.text)) { + textView.setVisibility(View.VISIBLE); + textView.setText(tailData.text); + TextStyle textStyle = mTextStyleProvider.getTextStyle(tailData.textStyle); + textStyle.applyWithEllipsizig(textView); + textView.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); + } else { + textView.setVisibility(View.GONE); + } + + mDivView.setActionHandlerForView(itemView, tailData.action); + + ImageView imageView = itemView.findViewById(R.id.div_gallery_tail_icon); + final DivGalleryBlock.Tail.Icon icon = tailData.icon; + + if (icon.imageUrl != null) { + + LoadReference loadReference = mImageLoader.loadImage( + icon.imageUrl.toString(), + DivKitImageUtils.toDivKitCallback(new LegacyDivImageDownloadCallback(mDivView) { + @UiThread + @Override + public void onSuccess(@NonNull CachedBitmap cachedBitmap) { + imageView.setImageBitmap(cachedBitmap.getBitmap()); + } + })); + mDivView.addLoadReference(loadReference, imageView); + } else { + imageView.setBackground(createIconBackground(icon.fillColor, icon.borderColor)); + imageView.setImageDrawable(createIcon(icon.iconColor)); + } + } + + @NonNull + private Drawable createIconBackground(@ColorInt int fillColor, @ColorInt int borderColor) { + GradientDrawable shape = new GradientDrawable(); + shape.setShape(GradientDrawable.OVAL); + shape.setColor(fillColor); + shape.setDither(true); + + if (borderColor != fillColor) { + shape.setStroke(mBackgroundStrokeSize, borderColor); + } + return shape; + } + + @Nullable + private Drawable createIcon(@ColorInt int color) { + Drawable arrowDrawable = DrawableUtils.getDrawable(mDivView.getContext(), R.drawable.div_gallery_tail_arrow); + if (arrowDrawable == null) { + Assert.fail("Vector drawable parsing error"); + return null; + } + + arrowDrawable.mutate(); + + // need to wrap to change vector drawable color + Drawable wrappedDrawable = DrawableCompat.wrap(arrowDrawable); + DrawableCompat.setTint(wrappedDrawable, color); + DrawableCompat.setTintMode(wrappedDrawable, PorterDuff.Mode.SRC_IN); + + int size = mDivView.getResources().getDimensionPixelSize(R.dimen.div_gallery_tail_arrow_size); + wrappedDrawable.setBounds(0, 0, size, size); + return wrappedDrawable; + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/GalleryTailLayout.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/GalleryTailLayout.kt new file mode 100644 index 000000000..08c827541 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/GalleryTailLayout.kt @@ -0,0 +1,50 @@ +package com.yandex.div.legacy.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.ImageView +import android.widget.LinearLayout +import com.yandex.alicekit.core.views.EllipsizingTextView +import com.yandex.div.legacy.R + +internal class GalleryTailLayout +@JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) { + + init { + layoutParams = createLayoutParams() + orientation = VERTICAL + gravity = Gravity.CENTER + minimumWidth = resources.getDimensionPixelSize(R.dimen.div_gallery_tail_width) + + val imageView = ImageView(context).apply { + id = R.id.div_gallery_tail_icon + layoutParams = createImageLayoutParams() + scaleType = ImageView.ScaleType.CENTER_INSIDE + } + + val textView = EllipsizingTextView(context).apply { + id = R.id.div_gallery_tail_text + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + maxWidth = resources.getDimensionPixelSize(R.dimen.div_gallery_tail_width) + gravity = Gravity.CENTER + } + + addView(imageView) + addView(textView) + } + + private fun createLayoutParams(): LayoutParams { + return LayoutParams(resources.getDimensionPixelSize(R.dimen.div_gallery_tail_width), LayoutParams.WRAP_CONTENT).apply { + gravity = Gravity.CENTER + } + } + + private fun createImageLayoutParams(): LayoutParams { + return LayoutParams( + resources.getDimensionPixelSize(R.dimen.div_gallery_tail_image_size), + resources.getDimensionPixelSize(R.dimen.div_gallery_tail_image_size)).apply { + bottomMargin = resources.getDimensionPixelSize(R.dimen.div_gallery_tail_image_bottom_margin) + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/GalleryWithTailItemDecoration.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/GalleryWithTailItemDecoration.java new file mode 100644 index 000000000..688ad7a3c --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/GalleryWithTailItemDecoration.java @@ -0,0 +1,60 @@ +package com.yandex.div.legacy.view; + +import android.graphics.Rect; +import android.view.View; +import androidx.annotation.Px; +import androidx.recyclerview.widget.RecyclerView; + +class GalleryWithTailItemDecoration extends RecyclerView.ItemDecoration { + + @Px + private final int mFirstItemPaddingLeft; + @Px + private final int mMidItemPaddingRight; + @Px + private final int mTailPaddingLeft; + @Px + private final int mTailPaddingRight; + @Px + private final int mPaddingTop; + @Px + private final int mPaddingBottom; + + public GalleryWithTailItemDecoration(@Px int firstItemPaddingLeft, @Px int midItemPaddingRight, + @Px int tailPaddingLeft, @Px int tailPaddingRight, + @Px int paddingTop, @Px int paddingBottom) { + mFirstItemPaddingLeft = firstItemPaddingLeft; + mMidItemPaddingRight = midItemPaddingRight; + mTailPaddingLeft = tailPaddingLeft; + mTailPaddingRight = tailPaddingRight; + mPaddingTop = paddingTop; + mPaddingBottom = paddingBottom; + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { + int position = parent.getLayoutManager().getPosition(view); + int itemCount = parent.getAdapter().getItemCount(); + + int paddingLeft; + int paddingRight; + + if (itemCount == 1) { + // tail only + paddingLeft = mTailPaddingLeft; + paddingRight = mTailPaddingRight; + } else if (position == itemCount - 1) { + // tail + paddingLeft = 0; + paddingRight = mTailPaddingRight; + } else { + boolean isFirst = position == 0; + boolean isBeforeTail = (position == itemCount - 2); + + paddingLeft = isFirst ? mFirstItemPaddingLeft : 0; + paddingRight = isBeforeTail ? mTailPaddingLeft : mMidItemPaddingRight; + } + + outRect.set(paddingLeft, mPaddingTop, paddingRight, mPaddingBottom); + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/RatioImageView.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/RatioImageView.java new file mode 100644 index 000000000..ce87c719d --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/RatioImageView.java @@ -0,0 +1,202 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import androidx.annotation.FloatRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.core.graphics.drawable.RoundedBitmapDrawable; +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.alicekit.core.utils.Log; +import com.yandex.div.legacy.R; + +/** + * ImageView, which height is based on its width. Calculated dimension will be {@code mRatio} times + * large than the dimension it is based on. + * @see ImageView + * @see R.styleable#RatioImageView_ratio + */ +public class RatioImageView extends AppCompatImageView { + private static final String TAG = "[Y:RatioImageView]"; + + public static final float MINIMAL_RATIO = 0.f; + public static final int APPLY_ON_WIDTH = 0; + public static final int APPLY_ON_HEIGHT = 1; + private static final int SCALE_TOP_CROP = 0; + + @Nullable + @FloatRange(from = MINIMAL_RATIO, fromInclusive = false) + private Float mRatio; + + private int mScaleType = 1; + private int mApplyOn = APPLY_ON_HEIGHT; + + private boolean mHasFrame = false; + + public RatioImageView(@NonNull Context context) { + this(context, null); + } + + public RatioImageView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, R.attr.ratioImageViewStyle); + } + + public RatioImageView(@NonNull Context context, @Nullable AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + + final TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.RatioImageView, defStyleAttr, 0); + for (int i = 0; i < typedArray.getIndexCount(); i++) { + final int attr = typedArray.getIndex(i); + if (attr == R.styleable.RatioImageView_ratio) { + mRatio = fixRatio(typedArray.getFloat(attr, MINIMAL_RATIO)); + } else if (attr == R.styleable.RatioImageView_customScaleType) { + mScaleType = typedArray.getInt(attr, -1); + if (mScaleType == SCALE_TOP_CROP) { + setScaleType(ImageView.ScaleType.MATRIX); + } + } else if (attr == R.styleable.RatioImageView_direction) { //noinspection ResourceType + int applyOn = typedArray.getInteger(attr, APPLY_ON_HEIGHT); + if (applyOn == APPLY_ON_HEIGHT || applyOn == APPLY_ON_WIDTH) { + mApplyOn = applyOn; + } + } + } + typedArray.recycle(); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + int newWidthSpec = widthMeasureSpec; + int newHeightSpec = heightMeasureSpec; + if (mRatio != null) { + int verticalPaddings = getPaddingTop() + getPaddingBottom(); + int horizontalPaddings = getPaddingLeft() + getPaddingRight(); + + if (mApplyOn == APPLY_ON_HEIGHT) { + newHeightSpec = calcHeightMeasureSpecByRatio(widthMeasureSpec, verticalPaddings, horizontalPaddings); + } else { + newWidthSpec = calcWidthMeasureSpecByRatio(heightMeasureSpec, verticalPaddings, horizontalPaddings); + } + } + super.onMeasure(newWidthSpec, newHeightSpec); + } + + private int calcHeightMeasureSpecByRatio(int widthMeasureSpec, int verticalPaddings, int horizontalPaddings) { + int width = View.MeasureSpec.getSize(widthMeasureSpec); + int viewHeight = width; + if (mRatio != null && width > horizontalPaddings) { + viewHeight = Math.round((width - horizontalPaddings) / mRatio) + verticalPaddings; + } + + return View.MeasureSpec.makeMeasureSpec(viewHeight, View.MeasureSpec.EXACTLY); + } + + private int calcWidthMeasureSpecByRatio(int heightMeasureSpec, int verticalPaddings, int horizontalPaddings) { + int height = View.MeasureSpec.getSize(heightMeasureSpec); + int viewWidth = height; + if (mRatio != null && height > verticalPaddings) { + viewWidth = Math.round((height - verticalPaddings) * mRatio) + horizontalPaddings; + } + return View.MeasureSpec.makeMeasureSpec(viewWidth, View.MeasureSpec.EXACTLY); + } + + public void setRatio(@Nullable @FloatRange(from = MINIMAL_RATIO, fromInclusive = false) Float ratio) { + mRatio = fixRatio(ratio); + requestLayout(); + } + + public void setApplyOn(int applyOn) { + mApplyOn = applyOn; + requestLayout(); + } + + @Override + protected boolean setFrame(int left, int top, int right, int bottom) { + boolean result = super.setFrame(left, top, right, bottom); + mHasFrame = true; + topCropIfNeeded(); + return result; + } + + @Override + public void setImageDrawable(@Nullable Drawable drawable) { + super.setImageDrawable(drawable); + topCropIfNeeded(); + } + + /** + * Scale to fit width and place at the top. Bottom of image could be cut off, it's ok + */ + private void topCropIfNeeded() { + if (!mHasFrame || mScaleType != SCALE_TOP_CROP) { + return; + } + + Drawable drawable = getDrawable(); + if (drawable == null) { + return; + } + + Matrix matrix = new Matrix(); + matrix.set(getMatrix()); + + float width = getWidth() - getPaddingLeft() - getPaddingRight(); + float drawableWidth = drawable.getIntrinsicWidth(); + if (width <= 0 || drawableWidth <= 0) { + return; + } + + float scale = width / drawableWidth; + matrix.setScale(scale, scale); + setImageMatrix(matrix); + } + + public void setRoundedImage(@NonNull Bitmap bitmap, @Px int cornerRadius) { + int imageWidth = bitmap.getWidth(); + int imageHeight = bitmap.getHeight(); + if (mRatio != null && mRatio > MINIMAL_RATIO) { + if (mApplyOn == APPLY_ON_HEIGHT) { + imageHeight = Math.round(imageWidth / mRatio); + imageHeight = imageHeight > bitmap.getHeight() ? bitmap.getHeight() : imageHeight; + } else { + imageWidth = Math.round(mRatio / imageHeight); + imageWidth = imageWidth > bitmap.getWidth() ? bitmap.getWidth() : imageWidth; + } + } + + if (imageWidth <= 0 || imageHeight <= 0) { + // Fallback to original size + imageWidth = bitmap.getWidth(); + imageHeight = bitmap.getHeight(); + } + + try { + RoundedBitmapDrawable roundedBitmapDrawable = RoundedBitmapDrawableFactory.create(getResources(), + Bitmap.createBitmap(bitmap, 0, 0, imageWidth, + imageHeight)); + roundedBitmapDrawable.setCornerRadius(cornerRadius); + setImageDrawable(roundedBitmapDrawable); + } catch (OutOfMemoryError e) { + Log.e(TAG, "Not enough memory to scale image", e); + setImageBitmap(bitmap); + } + } + + @Nullable + private Float fixRatio(@Nullable Float ratio) { + if (ratio != null && ratio <= MINIMAL_RATIO) { + Assert.fail("Ratio must be greater than 0.0"); + return null; + } + return ratio; + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/RoundedCornersWithStrokeLayout.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/RoundedCornersWithStrokeLayout.java new file mode 100644 index 000000000..ce2efee87 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/RoundedCornersWithStrokeLayout.java @@ -0,0 +1,123 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.os.Build; +import android.widget.LinearLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +/** + * A layout that clips it's contents with rounded rectangle of specified radius and stroke + * + */ +public class RoundedCornersWithStrokeLayout extends LinearLayout { + private static final float EXTRA_CONTENT_INSET = 1.0f; + private float mCornerRadius; + private int mStrokeWidth; + @NonNull + private final Paint mStrokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + @NonNull + private final Path mPath = new Path(); + @ColorInt + private int mStrokeColor = Color.RED; + @NonNull + private final RectF mRectBounds = new RectF(); + @NonNull + private final RectF mRectBorder = new RectF(); + + public RoundedCornersWithStrokeLayout(@NonNull Context context) { + super(context); + + mStrokePaint.setStyle(Paint.Style.STROKE); + mStrokePaint.setColor(mStrokeColor); + mStrokePaint.setStrokeWidth(mStrokeWidth); + mStrokePaint.setAntiAlias(true); + } + + public void setStrokeColor(@ColorInt int strokeColor) { + mStrokeColor = strokeColor; + mStrokePaint.setColor(mStrokeColor); + invalidate(); + } + + @ColorInt + public int getStrokeColor() { + return mStrokeColor; + } + + public void setStrokeWidth(int strokeWidth) { + mStrokeWidth = strokeWidth; + mStrokePaint.setStrokeWidth(mStrokeWidth); + invalidate(); + } + + public int getStrokeWidth() { + return mStrokeWidth; + } + + public void setCornerRadius(float cornerRadius) { + mCornerRadius = cornerRadius; + invalidate(); + } + + public float getCornerRadius() { + return mCornerRadius; + } + + @Override + public void dispatchDraw(Canvas canvas) { + int saveCount = canvas.save(); + clipPath(canvas); + super.dispatchDraw(canvas); + + canvas.restoreToCount(saveCount); + drawStroke(canvas); + } + + private void clipPath(@NonNull Canvas canvas) { + if (!isRoundedRect()) { + return; + } + int width = getWidth(); + int height = getHeight(); + mPath.reset(); + mRectBounds.set(0, 0, width, height); + insetContentIfNeeded(mRectBounds); + mPath.addRoundRect(mRectBounds, mCornerRadius, mCornerRadius, Path.Direction.CW); + canvas.clipPath(mPath); + } + + private void drawStroke(@NonNull Canvas canvas) { + if (mStrokeWidth <= 0) { + return; + } + + int width = getWidth(); + int height = getHeight(); + mRectBorder.set(0, 0, width, height); + + float strokeHalfWidth = (float) Math.ceil(mStrokeWidth / 2.0f); + mRectBorder.inset(strokeHalfWidth, strokeHalfWidth); + if (isRoundedRect()) { + canvas.drawRoundRect(mRectBorder, mCornerRadius, mCornerRadius, mStrokePaint); + } else { + canvas.drawRect(mRectBorder, mStrokePaint); + } + } + + private static boolean isRoundedRect() { + return Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1; + } + + private void insetContentIfNeeded(@NonNull RectF contentRect) { + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N || (isRoundedRect() && mStrokeWidth > 0)) { + contentRect.inset(EXTRA_CONTENT_INSET, EXTRA_CONTENT_INSET); + } + } + +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/SeparatorDivViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/SeparatorDivViewBuilder.java new file mode 100644 index 000000000..69e0422fc --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/SeparatorDivViewBuilder.java @@ -0,0 +1,77 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.alicekit.core.utils.Views; +import com.yandex.div.DivSeparatorBlock; +import com.yandex.div.DivSize; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.view.SeparatorView; +import javax.inject.Inject; + +@DivLegacyScope +public class SeparatorDivViewBuilder extends DivElementDataViewBuilder { + + @Inject + SeparatorDivViewBuilder() { + super(); + } + + @Override + @NonNull + protected View build(@NonNull DivView divView, @NonNull DivSeparatorBlock divData) { + SeparatorView separatorView = createView(divView, divData); + if (divData.hasDelimiter) { + separatorView.setDividerGravity(Gravity.END); + separatorView.setDividerColor(divData.delimiterColor); + separatorView.setDividerHeightResource(R.dimen.div_separator_delimiter_height); + } + + return separatorView; + } + + private SeparatorView createView(@NonNull DivView divView, @NonNull DivSeparatorBlock divData) { + Context context = divView.getContext(); + SeparatorView separatorView = new SeparatorView(context); + + @DivSize String size = divData.size; + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, getDimensionOffset(context, getViewHeightRes(size))); + layoutParams.weight = DivSize.MATCH_PARENT.equals(size) ? divData.weight : 0; + separatorView.setLayoutParams(layoutParams); + + Views.setPadding(separatorView, R.dimen.div_horizontal_padding, Views.VIEW_SIDE_LEFT); + Views.setPadding(separatorView, R.dimen.div_horizontal_padding, Views.VIEW_SIDE_RIGHT); + + return separatorView; + } + + @DimenRes + private int getViewHeightRes(@NonNull @DivSize String size) { + switch (size) { + case DivSize.XXS: + return R.dimen.div_separator_height_xxs; + case DivSize.XS: + return R.dimen.div_separator_height_xs; + case DivSize.S: + return R.dimen.div_separator_height_s; + case DivSize.M: + return R.dimen.div_separator_height_m; + case DivSize.L: + return R.dimen.div_separator_height_l; + case DivSize.XL: + return R.dimen.div_separator_height_xl; + case DivSize.MATCH_PARENT: + return R.dimen.div_padding_zero; + default: + Assert.fail("Unsupported size " + size); + return R.dimen.div_separator_height_m; + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TextStyle.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TextStyle.java new file mode 100644 index 000000000..2d94b678d --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TextStyle.java @@ -0,0 +1,227 @@ +package com.yandex.div.legacy.view; + +import android.content.res.Resources; +import android.graphics.Typeface; +import android.util.TypedValue; +import android.view.View; +import android.widget.TextView; +import androidx.annotation.ColorRes; +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.core.content.ContextCompat; +import com.yandex.alicekit.core.views.EllipsizingTextView; +import com.yandex.alicekit.core.views.EllipsizingUtils; +import javax.inject.Provider; + +public class TextStyle { + + @NonNull + private final Provider mTypefaceProvider; + @ColorRes + private final int mColor; + + @DimenRes + private final int mTextSizeDimenRes; + private final int mTextAlignment; + + @DimenRes + private final int mLetterSpacingDimenRes; + private final double mLetterSpacing; + + @DimenRes + private final int mLineSpaceExtra; + @Px + private final int mLineHeight; + + @Nullable + private final Integer mMaxLines; + private final char mEllipsis; + + @Px + private int mTextSize; + + private TextStyle(@NonNull Provider typefaceProvider, @ColorRes int color, @Px int textSize, @DimenRes int textSizeDimenRes, + @DimenRes int letterSpacingDimenRes, int textAlignment, double letterSpacing, + @DimenRes int lineSpaceExtra, int lineHeight, @Nullable Integer maxLines, char ellipsis) { + mTypefaceProvider = typefaceProvider; + mColor = color; + mTextSize = textSize; + mTextSizeDimenRes = textSizeDimenRes; + mTextAlignment = textAlignment; + mLetterSpacingDimenRes = letterSpacingDimenRes; + mLetterSpacing = letterSpacing; + mLineSpaceExtra = lineSpaceExtra; + mLineHeight = lineHeight; + mMaxLines = maxLines; + mEllipsis = ellipsis; + } + + public TextStyle(@NonNull TextStyle styleToCopy, Integer maxLines) { + this(styleToCopy.mTypefaceProvider, styleToCopy.mColor, styleToCopy.mTextSize, styleToCopy.mTextSizeDimenRes, + styleToCopy.mLetterSpacingDimenRes, styleToCopy.mTextAlignment, styleToCopy.mLetterSpacing, + styleToCopy.mLineSpaceExtra, + styleToCopy.mLineHeight, maxLines, styleToCopy.mEllipsis); + } + + @Px + public int getTextSize() { + return mTextSize; + } + + public void apply(@NonNull TextView textView) { + textView.setTypeface(mTypefaceProvider.get()); + textView.setTextColor(ContextCompat.getColor(textView.getContext(), mColor)); + + final Resources resources = textView.getResources(); + if (mTextSize <= 0) { + mTextSize = resources.getDimensionPixelSize(mTextSizeDimenRes); + } + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); + if (mLineHeight > 0) { + int lineSpacingExtra = mLineHeight - textView.getLineHeight(); + textView.setLineSpacing(lineSpacingExtra, 1); + } else { + textView.setLineSpacing(resources.getDimensionPixelSize(mLineSpaceExtra), 1); + } + textView.setTextAlignment(mTextAlignment); + float letterSpacing = (float) mLetterSpacing; + if (mLetterSpacingDimenRes != 0) { + final TypedValue outValue = new TypedValue(); + resources.getValue(mLetterSpacingDimenRes, outValue, true); + letterSpacing = outValue.getFloat(); + } + textView.setLetterSpacing(letterSpacing); + } + + void applyWithEllipsizig(@NonNull AppCompatTextView textView) { + apply(textView); + if (mMaxLines != null) { + textView.setMaxLines(mMaxLines); + } + if (textView instanceof EllipsizingTextView) { + ((EllipsizingTextView) textView).setEllipsis(mEllipsis); + } + } + + @Override + public boolean equals(Object object) { + if (object == null) { + return false; + } + + if (object == this) { + return true; + } + + if (!(object instanceof TextStyle)) { + return false; + } + + TextStyle style = (TextStyle) object; + + if (!(mColor == style.mColor && mTextSize == style.mTextSize && mLetterSpacingDimenRes == style.mLetterSpacingDimenRes && + mLineSpaceExtra == style.mLineSpaceExtra && mTypefaceProvider.get().equals( + style.mTypefaceProvider.get()))) { + return false; + } + + return (mMaxLines == null && style.mMaxLines == null) || + (mMaxLines != null && style.mMaxLines != null && mMaxLines.equals(style.mMaxLines)); + } + + @Override + public int hashCode() { + return mTypefaceProvider.get().hashCode(); + } + + public static class Builder { + + private Provider mTypefaceProvider; + private int mColor; + private int mTextSize = 0; + private int mLetterSpacingDimenRes; + private double mLetterSpacing; + private int mLineSpaceExtra; + private int mLineHeight; + private int mTextSizeDimenRes; + private int mTextAlignment = View.TEXT_ALIGNMENT_TEXT_START; + private Integer mMaxLines = null; + private char mEllipsis = EllipsizingUtils.ELLIPSIS; + + @NonNull + public Builder setTypefaceProvider(@NonNull Provider typefaceProvider) { + mTypefaceProvider = typefaceProvider; + return this; + } + + @NonNull + public Builder setColor(int color) { + mColor = color; + return this; + } + + @NonNull + public Builder setTextSize(int textSize) { + mTextSize = textSize; + return this; + } + + @NonNull + public Builder setLetterSpacing(double letterSpacing) { + mLetterSpacing = letterSpacing; + return this; + } + + @NonNull + public Builder setLetterSpacingDimenRes(@DimenRes int letterSpacing) { + mLetterSpacingDimenRes = letterSpacing; + return this; + } + + @NonNull + public Builder setLineSpaceExtra(@DimenRes int lineSpaceExtra) { + mLineSpaceExtra = lineSpaceExtra; + return this; + } + + @NonNull + public Builder setLineHeight(int lineHeight) { + mLineHeight = lineHeight; + return this; + } + + @NonNull + public Builder setTextSizeDimenRes(int textSizeDimenRes) { + mTextSizeDimenRes = textSizeDimenRes; + return this; + } + + @NonNull + public Builder setTextAlignment(int textAlignment) { + mTextAlignment = textAlignment; + return this; + } + + @NonNull + public Builder setMaxLines(@Nullable Integer maxLines) { + mMaxLines = maxLines; + return this; + } + + @NonNull + public Builder setEllipsis(char ellipsis) { + mEllipsis = ellipsis; + return this; + } + + @NonNull + public TextStyle createTextStyle() { + return new TextStyle(mTypefaceProvider, mColor, mTextSize, mTextSizeDimenRes, mLetterSpacingDimenRes, + mTextAlignment, mLetterSpacing, + mLineSpaceExtra, mLineHeight, mMaxLines, mEllipsis); + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TextViewFactory.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TextViewFactory.kt new file mode 100644 index 000000000..35a26b198 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TextViewFactory.kt @@ -0,0 +1,10 @@ +package com.yandex.div.legacy.view + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView + +internal interface TextViewFactory { + + fun create(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0): AppCompatTextView +} \ No newline at end of file diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TitleDivBlockViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TitleDivBlockViewBuilder.java new file mode 100644 index 000000000..53bd11ab2 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TitleDivBlockViewBuilder.java @@ -0,0 +1,114 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.view.Gravity; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatTextView; +import androidx.appcompat.widget.PopupMenu; +import com.yandex.div.DivTitleBlock; +import com.yandex.div.legacy.DivAutoLogger; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.dagger.LegacyNames; +import com.yandex.div.view.menu.OverflowMenuWrapper; +import com.yandex.div.view.pooling.ViewPool; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; + +@DivLegacyScope +public class TitleDivBlockViewBuilder extends DivElementDataViewBuilder { + + private static final String FACTORY_TAG_TITLE = "TitleDivBlockViewBuilder.TITLE"; + + @NonNull + private final Context mThemedContext; + @NonNull + private final ViewPool mViewPool; + @NonNull + private final DivTextStyleProvider mTextStyleProvider; + @NonNull + private final DivAutoLogger mAutoLogger; + + @Inject + TitleDivBlockViewBuilder(@NonNull @Named(LegacyNames.THEMED_CONTEXT) Context themedContext, + @NonNull ViewPool viewPool, + @NonNull DivTextStyleProvider textStyleProvider, + @NonNull DivAutoLogger autoLogger, + @NonNull TextViewFactory textViewFactory) { + super(); + mThemedContext = themedContext; + mViewPool = viewPool; + mTextStyleProvider = textStyleProvider; + mAutoLogger = autoLogger; + + mViewPool.register(FACTORY_TAG_TITLE, () -> createTextView(textViewFactory, + mThemedContext, + R.attr.legacyTitleStyle, + R.id.div_title_text), 8); + } + + @Override + @NonNull + protected View build(@NonNull DivView divView, @NonNull DivTitleBlock divData) { + AppCompatTextView titleView = mViewPool.obtain(FACTORY_TAG_TITLE); + + CharSequence text = divData.text; + boolean hasText = text != null; + if (hasText) { + setTextAndStyle(titleView, text, mTextStyleProvider.getTextStyle(divData.textStyle)); + } + + List menuItems = divData.menuItems; + if (menuItems == null || menuItems.isEmpty() || !divView.getConfig().isContextMenuEnabled()) { + return titleView; + } + + @ColorInt Integer menuColor = divData.menuColor; + final OverflowMenuWrapper overflowMenuWrapper = new OverflowMenuWrapper(mThemedContext, titleView, divView, + R.dimen.div_title_menu_padding, + R.dimen.div_title_menu_padding) + .buttonResourceId(R.drawable.overflow_menu_button) + .color(menuColor) + .listener(new MenuWrapperListener(divView, menuItems)) + .overflowGravity(Gravity.RIGHT | Gravity.TOP); + if (hasText) { + overflowMenuWrapper.horizontallyCompetingViews(titleView); + } + divView.subscribe(overflowMenuWrapper::dismiss); + return overflowMenuWrapper.getView(); + } + + private class MenuWrapperListener extends OverflowMenuWrapper.Listener.Simple { + + @NonNull + private final DivView mDivView; + @NonNull + private final List mItems; + + MenuWrapperListener(@NonNull DivView divView, @NonNull List items) { + mDivView = divView; + mItems = items; + } + + @Override + public void onMenuCreated(@NonNull PopupMenu popupMenu) { + Menu menu = popupMenu.getMenu(); + for (DivTitleBlock.MenuItem itemData : mItems) { + int itemPosition = menu.size(); + + MenuItem menuItem = menu.add(itemData.text); + menuItem.setOnMenuItemClickListener(item -> { + mDivView.handleUri(itemData.url); + mAutoLogger.logPopupMenuItemClick(mDivView, itemPosition, itemData.text, itemData.url); + return true; + }); + } + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TrafficDivViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TrafficDivViewBuilder.java new file mode 100644 index 000000000..80a519c65 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/TrafficDivViewBuilder.java @@ -0,0 +1,191 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.graphics.drawable.GradientDrawable; +import android.view.GestureDetector; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.alicekit.core.utils.Views; +import com.yandex.div.DivAction; +import com.yandex.div.DivTrafficBlock; +import com.yandex.div.legacy.Alignment; +import com.yandex.div.legacy.LegacyDivDataUtils; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.dagger.LegacyNames; +import com.yandex.div.view.PaddingItemDecoration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Named; + +@DivLegacyScope +public class TrafficDivViewBuilder extends DivElementDataViewBuilder { + + @NonNull + private final Context mThemedContext; + + @Inject + TrafficDivViewBuilder(@NonNull @Named(LegacyNames.THEMED_CONTEXT) Context themedContext) { + super(); + mThemedContext = themedContext; + } + + @Override + @Nullable + protected View build(@NonNull DivView divView, @NonNull DivTrafficBlock divData) { + List items = getValidItems(divData); + + if (items.isEmpty()) { + return null; + } + + Context context = divView.getContext(); + + RecyclerView recyclerView = Views.inflate(context, R.layout.div_traffic_list); + recyclerView.setAdapter(new Adapter(items, divData.action)); + recyclerView.setHasFixedSize(true); + + LinearLayoutManager layoutManager = new LinearLayoutManager(context); + layoutManager.setOrientation(LinearLayoutManager.HORIZONTAL); + recyclerView.setLayoutManager(layoutManager); + + if (divData.action != null) { + recyclerView.addOnItemTouchListener(new RecyclerViewClickListener(context, recyclerView)); + } + + int itemPadding = context.getResources().getDimensionPixelSize(R.dimen.div_traffic_item_padding_horizontal); + recyclerView.addItemDecoration(new PaddingItemDecoration(0, itemPadding, 0)); + + LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + final Alignment alignment = DivViewUtils.divAlignmentToAlignment(divData.alignment); + + switch (alignment) { + case LEFT: + layoutParams.gravity = Gravity.LEFT; + break; + case CENTER: + layoutParams.gravity = Gravity.CENTER_HORIZONTAL; + break; + case RIGHT: + layoutParams.gravity = Gravity.RIGHT; + break; + default: + Assert.fail("Unknown value"); + break; + } + recyclerView.setLayoutParams(layoutParams); + + return recyclerView; + } + + @NonNull + private List getValidItems(@NonNull DivTrafficBlock divData) { + if (divData.items.isEmpty()) { + return Collections.emptyList(); + } + + List itemList = new ArrayList<>(); + for (DivTrafficBlock.Item item : divData.items) { + if (LegacyDivDataUtils.isDivTextValid(item.score)) { + itemList.add(item); + } + } + + return itemList; + } + + private class ItemViewHolder extends RecyclerView.ViewHolder { + + @NonNull + private final TextView mScore; + @NonNull + private final TextView mText; + + ItemViewHolder(@NonNull View itemView) { + super(itemView); + mScore = Views.findViewAndCast(itemView, R.id.div_traffic_score); + mText = Views.findViewAndCast(itemView, R.id.div_traffic_text); + } + + void bind(@NonNull DivTrafficBlock.Item divItem) { + mScore.setText(divItem.score); + Views.setTextOrHide(mText, divItem.text); + + GradientDrawable background = (GradientDrawable) mScore.getBackground(); + int strokeWidth = itemView.getResources().getDimensionPixelSize(R.dimen.div_traffic_item_stroke_width); + background.setStroke(strokeWidth, divItem.color); + } + } + + private class Adapter extends RecyclerView.Adapter { + @Nullable + private final DivAction mAction; + + @NonNull + private final List mItems; + + Adapter(@NonNull List items, @Nullable DivAction action) { + mItems = items; + mAction = action; + } + + @Override + public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = Views.inflate(parent, R.layout.div_traffic_item); + + return new ItemViewHolder(view); + } + + @Override + public void onBindViewHolder(ItemViewHolder holder, int position) { + holder.bind(mItems.get(position)); + } + + @Override + public int getItemCount() { + return mItems.size(); + } + } + + // RecyclerView does not support onClickListener, on clicks for items are supported. + // So we detect clicks manually here. + // This detects clicks between items too. + private class RecyclerViewClickListener extends RecyclerView.SimpleOnItemTouchListener { + @NonNull + private final RecyclerView mRecyclerView; + @NonNull + private final GestureDetector mGestureDetector; + + RecyclerViewClickListener(@NonNull Context context, @NonNull RecyclerView recyclerView) { + mRecyclerView = recyclerView; + mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onSingleTapUp(MotionEvent e) { + return true; + } + }); + } + + @Override + public boolean onInterceptTouchEvent(RecyclerView view, MotionEvent e) { + if (mRecyclerView.hasOnClickListeners() && mGestureDetector.onTouchEvent(e)) { + mRecyclerView.callOnClick(); + return true; + } + return false; + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/UniversalDivViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/UniversalDivViewBuilder.java new file mode 100644 index 000000000..458d844bc --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/UniversalDivViewBuilder.java @@ -0,0 +1,391 @@ +package com.yandex.div.legacy.view; + +import android.content.Context; +import android.graphics.Bitmap; +import android.util.TypedValue; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import androidx.annotation.DimenRes; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.Px; +import androidx.annotation.UiThread; +import androidx.appcompat.widget.AppCompatTextView; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.DivImageElement; +import com.yandex.div.DivSize; +import com.yandex.div.DivUniversalBlock; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.core.images.LoadReference; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.LegacyDivDataUtils; +import com.yandex.div.legacy.LegacyDivImageDownloadCallback; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.dagger.LegacyNames; +import com.yandex.div.util.DivKitImageUtils; +import com.yandex.div.util.Position; +import com.yandex.div.view.pooling.ViewPool; +import com.yandex.images.CachedBitmap; +import javax.inject.Inject; +import javax.inject.Named; + +@DivLegacyScope +public class UniversalDivViewBuilder extends DivElementDataViewBuilder { + + private static final int NO_CORNERS = -1; + private static final int CORNERS_FOR_S = 10; + private static final int CORNERS_FOR_M_L = 15; + + private static final String FACTORY_TAG_TITLE_AND_TEXT = "UniversalDivViewBuilder.TITLE_AND_TEXT"; + private static final String FACTORY_TAG_TITLE = "UniversalDivViewBuilder.TITLE"; + private static final String FACTORY_TAG_TEXT = "UniversalDivViewBuilder.TEXT"; + private static final String FACTORY_TAG_IMAGE = "UniversalDivViewBuilder.IMAGE"; + + @NonNull + private final Context mContext; + @NonNull + private final Context mThemedContext; + @NonNull + private final ViewPool mViewPool; + @NonNull + private final DivImageLoader mImageLoader; + @NonNull + private final DivTextStyleProvider mTextStyleProvider; + + @Inject + UniversalDivViewBuilder(@NonNull @Named(LegacyNames.CONTEXT) Context context, + @NonNull @Named(LegacyNames.THEMED_CONTEXT) Context themedContext, + @NonNull ViewPool viewPool, + @NonNull DivImageLoader imageLoader, + @NonNull DivTextStyleProvider textStyleProvider, + @NonNull TextViewFactory textViewFactory) { + super(); + + mContext = context; + mThemedContext = themedContext; + mViewPool = viewPool; + mImageLoader = imageLoader; + mTextStyleProvider = textStyleProvider; + + mViewPool.register(FACTORY_TAG_TITLE_AND_TEXT, () -> new LinearLayout(mThemedContext), 2); + mViewPool.register(FACTORY_TAG_TITLE, () -> createTextView(textViewFactory, mThemedContext, + R.attr.legacyUniversalTitleStyle, + R.id.div_universal_title), 10); + mViewPool.register(FACTORY_TAG_TEXT, () -> createTextView(textViewFactory, mThemedContext, + R.attr.legacyUniversalTextStyle, + R.id.div_universal_text), 10); + mViewPool.register(FACTORY_TAG_IMAGE, () -> new RatioImageView(mThemedContext, null, + R.attr.legacyUniversalImageStyle), 4); + } + + @Override + @Nullable + protected View build(@NonNull DivView divView, @NonNull DivUniversalBlock divData) { + if (!isValidBlock(divData)) { + return null; + } + + TextStyle titleStyle = mTextStyleProvider.getTextStyle(divData.titleStyle, divData.titleMaxLines); + TextStyle textStyle = mTextStyleProvider.getTextStyle(divData.textStyle, divData.textMaxLines); + + DivUniversalBlock.SideElement sideElement = divData.sideElement; + if (sideElement == null) { + return buildTextOnlyDiv(divData.title, titleStyle, divData.text, textStyle); + } + + @DivSize String sideElementSize = sideElement.size; + Position sideElementPosition = DivViewUtils.divPositionToPosition(sideElement.position); + + DivImageElement imageElement = sideElement.element.asDivImageElement(); + if (imageElement != null) { + return buildDivWithImage(divView, imageElement, sideElementSize, sideElementPosition, + divData.title, titleStyle, divData.text, textStyle); + } + + DivUniversalBlock.SideElement.DateElement dateElement = sideElement.element.asDateElement(); + + if (dateElement != null) { + return buildDivWithDate(dateElement, sideElementSize, sideElementPosition, + divData.title, titleStyle, divData.text, textStyle); + } + + Assert.fail("Invalid universal div with side : " + sideElement.element.type); + + return buildTextOnlyDiv(divData.title, titleStyle, divData.text, textStyle); + } + + /** + * Method to check if we can show something from this div. + * + * @param divData div universal block + */ + public static boolean isValidBlock(@NonNull DivUniversalBlock divData) { + if (LegacyDivDataUtils.isDivTextValid(divData.text) || LegacyDivDataUtils.isDivTextValid(divData.title)) { + return true; + } + if (divData.sideElement != null) { + DivImageElement imageElement = divData.sideElement.element.asDivImageElement(); + + if (imageElement != null && LegacyDivDataUtils.isDivImageValid(imageElement)) { + return true; + } + + DivUniversalBlock.SideElement.DateElement dateElement = divData.sideElement.element.asDateElement(); + if (dateElement != null && LegacyDivDataUtils.isDivTextValid(dateElement.dateDay)) { + return true; + } + } + + return false; + } + + @NonNull + private View buildTextOnlyDiv(@Nullable CharSequence title, @NonNull TextStyle titleTextStyle, + @Nullable CharSequence text, @NonNull TextStyle textTextStyle) { + int defaultPaddingBottom = getDimensionOffset(mContext, R.dimen.div_universal_padding_bottom); + int defaultPaddingHorizontal = getDimensionOffset(mContext, R.dimen.div_horizontal_padding); + + if (title != null && text != null) { + View view = createTitleAndTextView(title, titleTextStyle, text, textTextStyle); + view.setPadding(defaultPaddingHorizontal, getDimensionOffset(mContext, R.dimen.div_universal_title_and_text_padding_top), + defaultPaddingHorizontal, defaultPaddingBottom); + return view; + } + + // title only + if (title != null) { + View view = createTitleView(title, titleTextStyle); + int paddingVertical = getDimensionOffset(mContext, R.dimen.div_universal_title_padding_vertical); + view.setPadding(defaultPaddingHorizontal, paddingVertical, defaultPaddingHorizontal, paddingVertical); + return view; + } + + // text only + View view = createTextView(text, textTextStyle); + view.setPadding(defaultPaddingHorizontal, getDimensionOffset(mContext, R.dimen.div_padding_zero), + defaultPaddingHorizontal, defaultPaddingBottom); + return view; + } + + @NonNull + private View createTitleAndTextView(@NonNull CharSequence title, @NonNull TextStyle titleTextStyle, + @NonNull CharSequence text, @NonNull TextStyle textTextStyle) { + LinearLayout linearLayout = mViewPool.obtain(FACTORY_TAG_TITLE_AND_TEXT); + linearLayout.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + linearLayout.setOrientation(LinearLayout.VERTICAL); + + linearLayout.addView(createTitleView(title, titleTextStyle)); + linearLayout.addView(createTextView(text, textTextStyle)); + + return linearLayout; + } + + @NonNull + private TextView createTextView(@Nullable CharSequence text, @NonNull TextStyle textTextStyle) { + AppCompatTextView textView = mViewPool.obtain(FACTORY_TAG_TEXT); + setTextAndStyle(textView, text, textTextStyle); + return textView; + } + + @NonNull + private TextView createTitleView(@Nullable CharSequence title, @NonNull TextStyle titleTextStyle) { + AppCompatTextView textView = mViewPool.obtain(FACTORY_TAG_TITLE); + setTextAndStyle(textView, title, titleTextStyle); + return textView; + } + + @NonNull + private View buildDivWithImage(@NonNull DivView divView, + @NonNull DivImageElement image, + @NonNull @DivSize String sideElementSize, + @NonNull Position sideElementPosition, + @Nullable CharSequence title, + @NonNull TextStyle titleTextStyle, + @Nullable CharSequence text, + @NonNull TextStyle textTextStyle) { + RelativeLayout relativeLayout = createLayoutForDivWithSideElement(); + relativeLayout.addView(createImageView(divView, image, sideElementSize, sideElementPosition)); + addTitleAndTextToViewWithSideElement(relativeLayout, sideElementPosition, R.id.div_universal_image, + title, titleTextStyle, text, textTextStyle); + + return relativeLayout; + } + + private void addTitleAndTextToViewWithSideElement(@NonNull RelativeLayout relativeLayout, @NonNull Position sideElementPosition, + @IdRes int sideViewId, + @Nullable CharSequence title, @NonNull TextStyle titleTextStyle, + @Nullable CharSequence text, @NonNull TextStyle textTextStyle) { + View titleView = createTitleView(title, titleTextStyle); + RelativeLayout.LayoutParams titleLayoutParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + + View textView = createTextView(text, textTextStyle); + RelativeLayout.LayoutParams textLayoutParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + + int textPosition = sideElementPosition == Position.RIGHT ? RelativeLayout.LEFT_OF : RelativeLayout.RIGHT_OF; + + titleLayoutParams.addRule(textPosition, sideViewId); + textLayoutParams.addRule(textPosition, sideViewId); + textLayoutParams.addRule(RelativeLayout.BELOW, R.id.div_universal_title); + + relativeLayout.addView(titleView, titleLayoutParams); + relativeLayout.addView(textView, textLayoutParams); + + } + + @NonNull + private RelativeLayout createLayoutForDivWithSideElement() { + int defaultPaddingTop = getDimensionOffset(mContext, R.dimen.div_universal_padding_top); + int defaultPaddingBottom = getDimensionOffset(mContext, R.dimen.div_universal_padding_bottom); + int defaultPaddingHorizontal = getDimensionOffset(mContext, R.dimen.div_horizontal_padding); + + RelativeLayout relativeLayout = new RelativeLayout(mContext); + relativeLayout.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + relativeLayout.setPadding(defaultPaddingHorizontal, defaultPaddingTop, defaultPaddingHorizontal, defaultPaddingBottom); + return relativeLayout; + } + + @NonNull + private RatioImageView createImageView(@NonNull DivView divView, + @NonNull DivImageElement image, + @NonNull @DivSize String sideElementSize, + @NonNull Position sideElementPosition) { + RatioImageView imageView = mViewPool.obtain(FACTORY_TAG_IMAGE); + imageView.setId(R.id.div_universal_image); + + @Px int imageWidth = getSideElementPixelSize(sideElementSize); + RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(imageWidth, ViewGroup.LayoutParams.WRAP_CONTENT); + imageView.setMaxWidth(imageWidth); + imageView.setRatio(DivViewUtils.getImageRatio(image)); + + LoadReference loadReference = mImageLoader.loadImage( + image.imageUrl.toString(), + DivKitImageUtils.toDivKitCallback(new LegacyDivImageDownloadCallback(divView) { + @UiThread + @Override + public void onSuccess(@NonNull CachedBitmap cachedBitmap) { + Bitmap bitmap = cachedBitmap.getBitmap(); + int cornerPixelSize = getCornerPixelSize(sideElementSize, bitmap.getWidth()); + if (cornerPixelSize == NO_CORNERS) { + imageView.setImageBitmap(bitmap); + } else { + imageView.setRoundedImage(bitmap, cornerPixelSize); + } + } + })); + divView.addLoadReference(loadReference, imageView); + + alignSideElementView(layoutParams, sideElementPosition); + imageView.setLayoutParams(layoutParams); + return imageView; + } + + private void alignSideElementView(@NonNull RelativeLayout.LayoutParams layoutParams, + @NonNull Position sideElementPosition) { + int sideMargin = getDimensionOffset(mContext, R.dimen.div_universal_image_horizontal_margin); + if (sideElementPosition == Position.RIGHT) { + layoutParams.leftMargin = sideMargin; + layoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); + } else { + layoutParams.rightMargin = sideMargin; + } + } + + @NonNull + private View buildDivWithDate(@NonNull DivUniversalBlock.SideElement.DateElement dateElement, + @NonNull @DivSize String sideElementSize, + @NonNull Position sideElementPosition, + @Nullable CharSequence title, + @NonNull TextStyle titleTextStyle, + @Nullable CharSequence text, + @NonNull TextStyle textTextStyle) { + RelativeLayout relativeLayout = createLayoutForDivWithSideElement(); + addDateViews(relativeLayout, dateElement, sideElementSize, sideElementPosition); + addTitleAndTextToViewWithSideElement(relativeLayout, sideElementPosition, R.id.div_universal_date_day, + title, titleTextStyle, text, textTextStyle); + return relativeLayout; + } + + private void addDateViews(@NonNull RelativeLayout relativeLayout, + @NonNull DivUniversalBlock.SideElement.DateElement dateElement, + @NonNull @DivSize String sideElementSize, + @NonNull Position sideElementPosition) { + @Px int dateWidth = getSideElementPixelSize(sideElementSize); + RelativeLayout.LayoutParams dayLayoutParams = new RelativeLayout.LayoutParams(dateWidth, ViewGroup.LayoutParams.WRAP_CONTENT); + alignSideElementView(dayLayoutParams, sideElementPosition); + + TextView dayView = createDayView(dateElement.dateDay, sideElementSize); + relativeLayout.addView(dayView, dayLayoutParams); + + if (dateElement.dateMonth != null) { + TextView monthView = createMonthView(dateElement.dateMonth); + RelativeLayout.LayoutParams monthLayoutParams = new RelativeLayout.LayoutParams(dateWidth, ViewGroup.LayoutParams.WRAP_CONTENT); + alignSideElementView(monthLayoutParams, sideElementPosition); + monthLayoutParams.addRule(RelativeLayout.BELOW, R.id.div_universal_date_day); + + relativeLayout.addView(monthView, monthLayoutParams); + } + } + + @NonNull + private TextView createDayView(@NonNull CharSequence dayText, @NonNull @DivSize String sideElementSize) { + TextView dayView = new TextView(mThemedContext, null, R.attr.legacyUniversalDayStyle); + dayView.setId(R.id.div_universal_date_day); + dayView.setText(dayText); + + @DimenRes int dayTextSizeId = DivSize.S.equals(sideElementSize) + ? R.dimen.div_universal_day_text_size_s + : R.dimen.div_universal_day_text_size; + dayView.setTextSize(TypedValue.COMPLEX_UNIT_PX, getDimensionSize(mContext, dayTextSizeId)); + + return dayView; + } + + @NonNull + private TextView createMonthView(@NonNull CharSequence monthText) { + TextView monthView = new TextView(mThemedContext, null, R.attr.legacyUniversalMonthStyle); + monthView.setId(R.id.div_universal_date_month); + monthView.setText(monthText); + return monthView; + } + + @Px + private int getSideElementPixelSize(@NonNull @DivSize String size) { + @DimenRes int id; + switch (size) { + case DivSize.S: + id = R.dimen.div_universal_image_size_s; + break; + case DivSize.M: + id = R.dimen.div_universal_image_size_m; + break; + case DivSize.L: + id = R.dimen.div_universal_image_size_l; + break; + default: + Assert.fail("Unknown size"); + id = R.dimen.div_universal_image_size_s; + break; + } + return getDimensionSize(mContext, id); + } + + @Px + private int getCornerPixelSize(@NonNull @DivSize String sideElementSize, @Px int bitmapWidth) { + switch (sideElementSize) { + case DivSize.S: + return bitmapWidth / CORNERS_FOR_S; + case DivSize.M: + case DivSize.L: + return bitmapWidth / CORNERS_FOR_M_L; + default: + return NO_CORNERS; + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/BaseDivTabbedCardUi.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/BaseDivTabbedCardUi.java new file mode 100644 index 000000000..51c0a84d6 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/BaseDivTabbedCardUi.java @@ -0,0 +1,653 @@ +package com.yandex.div.legacy.view.tab; + +import android.os.Build; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.SparseArray; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.CallSuper; +import androidx.annotation.ColorInt; +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.ArrayMap; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.alicekit.core.utils.Log; +import com.yandex.alicekit.core.utils.Views; +import com.yandex.alicekit.core.views.HeightCalculatorFactory; +import com.yandex.alicekit.core.views.ScrollableViewPager; +import com.yandex.alicekit.core.views.ViewPagerFixedSizeLayout; +import com.yandex.alicekit.core.views.ViewPagerFixedSizeLayout.HeightCalculator; +import com.yandex.alicekit.core.widget.TypefaceProvider; +import com.yandex.div.legacy.DivAutoLogger; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.view.TextStyle; +import com.yandex.div.view.pooling.ViewPool; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * A UI component that renders a tabbed view. For input it takes {@link BaseDivTabbedCardUi.Input} + * with list {@link TAB_DATA}. Each {@link TAB_DATA} gets converted into a stateful object + * {@link TAB_VIEW} with the general management from {@link PagerAdapter}. + * + * @param data source for a tab; it must provide tab's title and whatever other data + * @param stateful object that wraps actual view; can be just a plain View + */ +public abstract class BaseDivTabbedCardUi, TAB_VIEW, ACTION> { + + private static final String TAG = "BaseDivTabbedCardUi"; + private static final int NO_POS = -1; + + private static boolean mReuseMeasuringTabs = true; + + @NonNull + private final ViewPool mViewPool; + @NonNull + private final View mView; + @NonNull + private final AbstractTabBar mAbstractTabBar; + @NonNull + private final BaseTabTitleBarHost mTabTitleBarHost; + @NonNull + protected final ScrollableViewPager mPager; + @NonNull + private HeightCalculatorFactory mHeightCalculatorFactory; + @Nullable + private final ViewPagerFixedSizeLayout mViewPagerFixedSizeLayout; + @Nullable + private HeightCalculator mViewPagerHeightCalculator; + @NonNull + private final Map mBindings = new ArrayMap<>(); + /** + * mBindingByPosition is populated both by PagerAdapter.instantiateItem() and + * measureTabHeight() so that tab bindings created by one can be reused by other. + * Unlike mBindingByPosition mBindings contains only items created by PagerAdapter. + */ + @NonNull + private final Map mBindingByPosition = new ArrayMap<>(); + @NonNull + private final DivAutoLogger mAutoLogger; + @NonNull + private final String mTabHeaderTag; + @NonNull + private final String mTabItemTag; + @NonNull + private final ActiveTabClickListener mActiveTabClickListener; + + private final PagerAdapter mPagerAdapter = new PagerAdapter() { + private static final String KEY_CHILD_STATES = "div_tabs_child_states"; + @Nullable + private SparseArray mChildStates; + + @Override + public int getCount() { + return mCurrentData == null ? 0 : mCurrentData.getTabs().size(); + } + + @Override + public boolean isViewFromObject(View view, Object object) { + return object == view; + } + + @Override + public Object instantiateItem(ViewGroup container, int position) { + Log.d(TAG, "instantiateItem pos " + position); + ViewGroup child; + Binding binding = mBindingByPosition.get(position); + if (binding != null) { + // Binding was already created on measuring step. + child = binding.mContainer; + Assert.assertNull(binding.mContainer.getParent()); + } else { + child = mViewPool.obtain(mTabItemTag); + TAB_DATA tabData = mCurrentData.getTabs().get(position); + binding = new Binding(child, tabData, position); + mBindingByPosition.put(position, binding); + } + container.addView(child); + mBindings.put(child, binding); + if (position == mPager.getCurrentItem()) { + binding.bind(); + } + if (mChildStates != null) { + child.restoreHierarchyState(mChildStates); + } + + return child; + } + + @Override + public void destroyItem(ViewGroup container, int position, Object object) { + ViewGroup view = (ViewGroup) object; + mBindings.remove(view).unbind(); + mBindingByPosition.remove(position); + + Log.d(TAG, "destroyItem pos " + position); + container.removeView(view); + } + + @Override + public int getItemPosition(Object object) { + return POSITION_NONE; + } + + @NonNull + @Override + public Parcelable saveState() { + SparseArray childStates = new SparseArray<>(mBindings.size()); + for (ViewGroup child : mBindings.keySet()) { + child.saveHierarchyState(childStates); + } + Bundle bundle = new Bundle(); + bundle.putSparseParcelableArray(KEY_CHILD_STATES, childStates); + return bundle; + } + + @Override + public void restoreState(@Nullable Parcelable state, @Nullable ClassLoader loader) { + if (!(state instanceof Bundle)) { + mChildStates = null; + return; + } + Bundle stateBundle = ((Bundle) state); + stateBundle.setClassLoader(getClass().getClassLoader()); + mChildStates = stateBundle.getSparseParcelableArray(KEY_CHILD_STATES); + } + }; + + /** + * Details on actual listener calls. + * External switch: + * onPageScrollStateChanged 2 SCROLL_STATE_SETTLING + * onPageSelected 3 + * onPageScrolled 0.9984374 + * onPageScrolled 0.0 + * onPageScrollStateChanged 0 SCROLL_STATE_IDLE + *

    + * Drag (not enough) + * onPageScrolled 0.021874905 + * onPageScrollStateChanged 2 SCROLL_STATE_SETTLING + * onPageSelected 6 + * onPageScrolled 0.1640625 + * onPageSelected 5 + * onPageScrolled 0.015625 + * onPageScrolled 0.0 + * onPageScrollStateChanged 0 SCROLL_STATE_IDLE + *

    + * Drag (enough) + * onPageScrolled 0.015625 + * onPageScrollStateChanged 2 SCROLL_STATE_SETTLING + * onPageSelected 6 + * onPageScrolled 0.0062499046 + * onPageScrolled 0.0 + * onPageScrollStateChanged 0 SCROLL_STATE_IDLE + *

    + * Init + * onPageScrolled 0.0 + */ + + private boolean mTabTitleBarIgnoreScrollEvents = false; + private Input mCurrentData = null; + + private boolean mInSetData = false; + private static boolean mTransformPageFixEnabled = true; + + public BaseDivTabbedCardUi(@NonNull ViewPool viewPool, + @NonNull View view, + @NonNull TabbedCardConfig tabbedCardConfig, + @NonNull HeightCalculatorFactory heightCalculatorFactory, + @NonNull DivTextStyleProvider textStyleProvider, + @NonNull DivAutoLogger autoLogger, + @Nullable ViewPager.OnPageChangeListener extendedListener, + @NonNull ActiveTabClickListener activeTabClickListener) { + mViewPool = viewPool; + mView = view; + mHeightCalculatorFactory = heightCalculatorFactory; + mAutoLogger = autoLogger; + mActiveTabClickListener = activeTabClickListener; + + mTabTitleBarHost = new BaseTabTitleBarHost(); + + mTabHeaderTag = tabbedCardConfig.getTabHeaderTag(); + mTabItemTag = tabbedCardConfig.getTabItemTag(); + + mAbstractTabBar = Views.findViewAndCast(mView, tabbedCardConfig.getCardTitleContainerScrollerId()); + mAbstractTabBar.setHost(mTabTitleBarHost); + mAbstractTabBar.setTypefaceProvider(textStyleProvider.getTypefaceProvider()); + mAbstractTabBar.setViewPool(viewPool, mTabHeaderTag); + + mPager = Views.findViewAndCast(mView, tabbedCardConfig.getCardPagerContainerId()); + mPager.clearOnPageChangeListeners(); + mPager.addOnPageChangeListener(new PagerChangeListener()); + ViewPager.OnPageChangeListener customPageChangeListener = mAbstractTabBar.getCustomPageChangeListener(); + if (customPageChangeListener != null) { + mPager.addOnPageChangeListener(customPageChangeListener); + } + if (extendedListener != null) { + mPager.addOnPageChangeListener(extendedListener); + } + mPager.setScrollEnabled(tabbedCardConfig.isViewPagerScrollable()); + mPager.setEdgeScrollEnabled(tabbedCardConfig.isViewPagerEdgeScrollable()); + mPager.setPageTransformer(false, new DataBindingTransformer()); + + final int pageContainerHelperId = tabbedCardConfig.getCardPagerContainerHelperId(); + mViewPagerFixedSizeLayout = Views.findViewAndCast(mView, pageContainerHelperId); + initializeViewPagerFixedSizeLayout(); + } + + private void initializeViewPagerFixedSizeLayout() { + if (mViewPagerFixedSizeLayout == null) { + return; + } + + final ViewGroup view = mViewPool.obtain(mTabItemTag); + mViewPagerHeightCalculator = mHeightCalculatorFactory.getCardHeightCalculator(view, this::measureTabHeight, + this::getTabCount); + mViewPagerFixedSizeLayout.setHeightCalculator(mViewPagerHeightCalculator); + } + + public void setData(@Nullable Input data) { + final int newPos = findCorrespondingTab(mPager.getCurrentItem(), data); + + mBindingByPosition.clear(); + mCurrentData = data; // actually this changes mPagerAdapter contents + if (mPager.getAdapter() != null) { + mInSetData = true; + try { + mPagerAdapter.notifyDataSetChanged(); // need to be called before abstractTabBar.setData, or we get crash + } finally { + mInSetData = false; + } + } + + List tabs; + if (data == null) { + tabs = Collections.emptyList(); + } else { + tabs = data.getTabs(); + } + + mAbstractTabBar.setData(tabs, newPos); // this also changes ViewPager position, so should go before setAdapter + + // if it is the first launch - set adapter and use nested restore state functionality + // pager.setAdapter has side effect of restoring correct ViewPager position, so don't call setCurrentItem or tabBar.setData after it + if (mPager.getAdapter() == null) { + mPager.setAdapter(mPagerAdapter); + } else { + if (!tabs.isEmpty() && newPos != NO_POS) { + mPager.setCurrentItem(newPos); + // Normally pager notifies title bar about every tab change, but not this time, since + // we might not actually change current item value. + mAbstractTabBar.manuallyScroll(newPos); + } + } + + requestViewPagerLayout(); + } + + public void setDisabledScrollPages(@NonNull Set disabledPages) { + mPager.setDisabledScrollPages(disabledPages); + } + + public void setTabColors(@ColorInt int activeTextColor, @ColorInt int activeBackgroundColor, @ColorInt int inactiveTextColor) { + mAbstractTabBar.setTabColors(activeTextColor, activeBackgroundColor, inactiveTextColor); + } + + @NonNull + protected abstract TAB_VIEW bindTabData(@NonNull ViewGroup tabView, @NonNull TAB_DATA tab, int tabNumber); + + protected abstract void unbindTabData(@NonNull TAB_VIEW tabView); + + protected abstract void fillMeasuringTab(@NonNull ViewGroup view, @NonNull TAB_DATA tab, final int tabNumber); + + protected void recycleMeasuringTabChildren(@NonNull ViewGroup view) { + } + + public void requestViewPagerLayout() { + Log.d(TAG, "requestViewPagerLayout"); + if (mViewPagerHeightCalculator != null) { + mViewPagerHeightCalculator.dropMeasureCache(); + } + if (mViewPagerFixedSizeLayout != null) { + mViewPagerFixedSizeLayout.requestLayout(); + } + } + + private int findCorrespondingTab(int oldTabPos, Input newData) { + // Simply keep integer value. Theoretically we can match data of actual tabs. + if (newData == null) { + return NO_POS; + } + int size = newData.getTabs().size(); + + // When size==0 value of size-1 is effectively NO_POS. + return Math.min(oldTabPos, size - 1); + } + + private int getTabCount() { + if (mCurrentData == null) { + return 0; + } + + return mCurrentData.getTabs().size(); + } + + private int measureTabHeight(@NonNull ViewGroup tabView, int width, int tabIndex) { + if (mCurrentData == null) { + return -1; + } + + final int collapsiblePaddingBottom; + if (mViewPagerFixedSizeLayout != null) { + collapsiblePaddingBottom = mViewPagerFixedSizeLayout.getCollapsiblePaddingBottom(); + } else { + collapsiblePaddingBottom = 0; + } + + List tabs = mCurrentData.getTabs(); + Assert.assertTrue("Tab index is out ouf bounds!", tabIndex >= 0 && tabIndex < tabs.size()); + + TAB_DATA tab = tabs.get(tabIndex); + Integer tabHeight = tab.getTabHeight(); + if (tabHeight != null) { + return tabHeight + collapsiblePaddingBottom; + } + + if (mReuseMeasuringTabs) { + Binding binding = mBindingByPosition.get(tabIndex); + if (binding == null) { + tabView = mViewPool.obtain(mTabItemTag); + binding = new Binding(tabView, tab, tabIndex); + mBindingByPosition.put(tabIndex, binding); + } else { + tabView = binding.mContainer; + } + binding.bind(); + } else { + fillMeasuringTab(tabView, tab, tabIndex); + } + tabView.forceLayout(); + tabView.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + int result = tabView.getMeasuredHeight() + collapsiblePaddingBottom; + if (!mReuseMeasuringTabs) { + recycleMeasuringTabChildren(tabView); + } + return result; + } + + @CallSuper + public void saveInstanceState(@NonNull SparseArray container) { + if (mViewPagerHeightCalculator != null) { + mViewPagerHeightCalculator.saveInstanceState(container); + } + } + + public static void setTransformPageFixEnabled(boolean enabled) { + mTransformPageFixEnabled = enabled; + } + + @CallSuper + public void restoreInstanceState(@NonNull SparseArray container) { + if (mViewPagerHeightCalculator != null) { + mViewPagerHeightCalculator.restoreInstanceState(container); + } + } + + public static void enableReuseMeasuringTabs(boolean enable) { + mReuseMeasuringTabs = enable; + } + + /** + * A generic input, that is described as a list of tabs. The {@link BaseDivTabbedCardUi} + * only requires tab to provide tab's title, all other specifics are fixed elsewhere. + */ + public interface Input { + @NonNull + List getTabs(); + + /** + * Tabtitle data class + * + * @param - actionable for tab title + */ + interface TabBase { + String getTitle(); + @Nullable + ACTION getActionable(); + @Nullable + TextStyle getTextStyle(); + @Nullable + Integer getTabHeight(); + } + + /** + * A possible implementation of the tab concept: a plain list of items. + */ + interface SimpleTab extends TabBase { + @NonNull + ITM getItem(); + } + } + + /** + * Config for tabs - id of views, scrollable flags + */ + public static class TabbedCardConfig { + @IdRes + private final int mCardTitleContainerScrollerId; + @IdRes + private final int mCardPagerContainerId; + @IdRes + private final int mCardPagerContainerHelperId; + private final boolean mIsViewPagerScrollable; + private final boolean mIsViewPagerEdgeScrollable; + @NonNull + private final String mTabHeaderTag; + @NonNull + private final String mTabItemTag; + + public TabbedCardConfig(@IdRes int cardTitleContainerScrollerId, + @IdRes int cardPagerContainerId, + @IdRes int cardPagerContainerHelperId, + boolean isViewPagerScrollable, + boolean isViewEdgePagerScrollable, + @NonNull String tabHeaderTag, + @NonNull String tabItemTag) { + mCardTitleContainerScrollerId = cardTitleContainerScrollerId; + mCardPagerContainerId = cardPagerContainerId; + mCardPagerContainerHelperId = cardPagerContainerHelperId; + mIsViewPagerScrollable = isViewPagerScrollable; + mIsViewPagerEdgeScrollable = isViewEdgePagerScrollable; + mTabHeaderTag = tabHeaderTag; + mTabItemTag = tabItemTag; + } + + @IdRes + int getCardTitleContainerScrollerId() { + return mCardTitleContainerScrollerId; + } + + @IdRes + int getCardPagerContainerId() { + return mCardPagerContainerId; + } + + @IdRes + int getCardPagerContainerHelperId() { + return mCardPagerContainerHelperId; + } + + boolean isViewPagerScrollable() { + return mIsViewPagerScrollable; + } + + boolean isViewPagerEdgeScrollable() { + return mIsViewPagerEdgeScrollable; + } + + @NonNull + String getTabHeaderTag() { + return mTabHeaderTag; + } + + @NonNull + String getTabItemTag() { + return mTabItemTag; + } + } + + private class BaseTabTitleBarHost implements AbstractTabBar.Host { + @Override + public void setCurrentPage(int pos, boolean muteEvents) { + if (muteEvents) { + mTabTitleBarIgnoreScrollEvents = true; + } + mPager.setCurrentItem(pos); + } + + @Override + public void onActiveTabClicked(@NonNull ACTION action, int tabPosition) { + mActiveTabClickListener.onActiveTabClicked(action, tabPosition); + } + } + + public interface ActiveTabClickListener { + void onActiveTabClicked(@NonNull ACTION action, int tabPosition); + } + + private class PagerChangeListener implements ViewPager.OnPageChangeListener { + + int mCurrentState = ViewPager.SCROLL_STATE_IDLE; + + @Override + public void onPageSelected(int position) { + if (mViewPagerHeightCalculator == null) { + mPager.requestLayout(); + } else if (mCurrentState == ViewPager.SCROLL_STATE_IDLE) { + fixViewPagerHeightOnScrollEnd(position); + } + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + if (mCurrentState != ViewPager.SCROLL_STATE_IDLE) { + updateViewPagerHeightOnScroll(position, positionOffset); + } + if (mTabTitleBarIgnoreScrollEvents) { + return; + } + mAbstractTabBar.setIntermediateState(position, positionOffset); + } + + private void updateViewPagerHeightOnScroll(int position, float positionOffset) { + if (mViewPagerFixedSizeLayout == null || mViewPagerHeightCalculator == null) { + return; + } + if (mViewPagerHeightCalculator.shouldRequestLayoutOnScroll(position, positionOffset)) { + mViewPagerHeightCalculator.setPositionAndOffsetForMeasure(position, positionOffset); + if (mViewPagerFixedSizeLayout.isInLayout()) { + mViewPagerFixedSizeLayout.post(mViewPagerFixedSizeLayout::requestLayout); + } else { + mViewPagerFixedSizeLayout.requestLayout(); + } + } + } + + @Override + public void onPageScrollStateChanged(int state) { + mCurrentState = state; + if (state == ViewPager.SCROLL_STATE_IDLE) { + int pos = mPager.getCurrentItem(); + fixViewPagerHeightOnScrollEnd(pos); + if (!mTabTitleBarIgnoreScrollEvents) { + mAbstractTabBar.fixScrollPosition(pos); + } + mTabTitleBarIgnoreScrollEvents = false; + } + } + + private void fixViewPagerHeightOnScrollEnd(int position) { + if (mViewPagerHeightCalculator != null && mViewPagerFixedSizeLayout != null) { + mViewPagerHeightCalculator.setPositionAndOffsetForMeasure(position, 0); + mViewPagerFixedSizeLayout.requestLayout(); + } + } + } + + private class DataBindingTransformer implements ViewPager.PageTransformer { + @Override + public void transformPage(View page, float position) { + if (mTransformPageFixEnabled && mInSetData) { + /** + * MOBSEARCHANDROID-32776: transformPage with position=0 may be called from inside + * PageAdapter.notifyDataSetChanged() for invisible pages. + */ + return; + } + + if (position > -1.0f && position < 1.0f) { + mBindings.get(page).bind(); + } + } + } + + private class Binding { + @NonNull + private final ViewGroup mContainer; + @NonNull + private final TAB_DATA mData; + private final int mPosition; + + @Nullable + private TAB_VIEW mView; + + private Binding(@NonNull ViewGroup container, @NonNull TAB_DATA data, int position) { + mContainer = container; + mData = data; + mPosition = position; + } + + void bind() { + if (mView != null) { + return; + } + mView = bindTabData(mContainer, mData, mPosition); + mAutoLogger.setId((View) mView, mData.getTitle()); + } + + void unbind() { + if (mView == null) { + return; + } + unbindTabData(mView); + mView = null; + } + } + + public interface AbstractTabBar { + void setViewPool(@NonNull ViewPool viewPool, @NonNull String tabHeaderTag); + void setHost(@NonNull Host host); + void setData(@NonNull List> dataList, int pos); + void manuallyScroll(int pos); + void setIntermediateState(int pos, float positionOffset); + void fixScrollPosition(int pos); + void setTabColors(@ColorInt int activeTextColor, @ColorInt int activeBackgroundColor, @ColorInt int inactiveTextColor); + @Nullable + ViewPager.OnPageChangeListener getCustomPageChangeListener(); + void resetScroll(); + void setTypefaceProvider(@NonNull TypefaceProvider typefaceProvider); + + interface Host { + void setCurrentPage(int pos, boolean muteEvents); + + void onActiveTabClicked(@NonNull ACTION action, int tabPosition); + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabItemLayout.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabItemLayout.kt new file mode 100644 index 000000000..b9773d311 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabItemLayout.kt @@ -0,0 +1,21 @@ +package com.yandex.div.legacy.view.tab + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.widget.LinearLayout +import com.yandex.div.legacy.R + +//todo make internal later +class TabItemLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : LinearLayout(context, attrs) { + + init { + id = R.id.div_tabbed_tab_title_item + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + orientation = VERTICAL + gravity = Gravity.TOP and Gravity.START + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabTitlesLayoutView.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabTitlesLayoutView.java new file mode 100644 index 000000000..a1f78d1ce --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabTitlesLayoutView.java @@ -0,0 +1,222 @@ +package com.yandex.div.legacy.view.tab; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.viewpager.widget.ViewPager; +import com.yandex.alicekit.core.widget.TabView; +import com.yandex.alicekit.core.widget.TypefaceProvider; +import com.yandex.alicekit.core.widget.YandexCoreIndicatorTabLayout; +import com.yandex.div.view.pooling.PseudoViewPool; +import com.yandex.div.view.pooling.ViewFactory; +import com.yandex.div.view.pooling.ViewPool; +import com.yandex.div2.DivTabs; +import java.util.List; + +import static com.yandex.div.legacy.view.BaseDivViewExtensionsKt.applyStyle; + +public class TabTitlesLayoutView + extends YandexCoreIndicatorTabLayout + implements BaseDivTabbedCardUi.AbstractTabBar { + + private static final String FACTORY_TAG_TAB_HEADER = "TabTitlesLayoutView.TAB_HEADER"; + + @Nullable + private Host mHost; + @Nullable + private List> mDataList; + + @NonNull + private final PseudoViewPool mDefaultViewPool; + @NonNull + private ViewPool mViewPool; + @NonNull + private String mTabHeaderTag; + @Nullable + private DivTabs.TabTitleStyle mTabTitleStyle; + @Nullable + private OnScrollChangedListener mOnScrollChangedListener; + private boolean mShouldDispatchScroll = false; + + public TabTitlesLayoutView(Context context) { + this(context, null, 0); + } + + public TabTitlesLayoutView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public TabTitlesLayoutView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setTabMode(YandexCoreIndicatorTabLayout.MODE_SCROLLABLE); + setSelectedTabIndicatorHeight(0); + setOnTabSelectedListener(new OnTabSelectedListener() { + @Override + public void onTabSelected(Tab tab) { + if (mHost == null) { + return; + } + + int pos = tab.getPosition(); + mHost.setCurrentPage(pos, false); + } + + @Override + public void onTabUnselected(Tab tab) { + // not implemented + } + + @Override + public void onTabReselected(Tab tab) { + if (mHost == null) { + return; + } + + int pos = tab.getPosition(); + BaseDivTabbedCardUi.Input.TabBase tabBase; + if (mDataList != null) { + tabBase = mDataList.get(pos); + + ACTION action = tabBase == null ? null : tabBase.getActionable(); + if (action != null) { + mHost.onActiveTabClicked(action, pos); + } + } + } + }); + + mDefaultViewPool = new PseudoViewPool(); + mDefaultViewPool.register(FACTORY_TAG_TAB_HEADER, new TabViewFactory(getContext()), 0); + + mViewPool = mDefaultViewPool; + mTabHeaderTag = FACTORY_TAG_TAB_HEADER; + } + + @Override + public void setViewPool(@NonNull ViewPool viewPool, @NonNull String tabHeaderTag) { + mViewPool = viewPool; + mTabHeaderTag = tabHeaderTag; + } + + @Override + protected TabView createTabView(@NonNull Context context) { + return mViewPool.obtain(mTabHeaderTag); + } + + @Override + public void setHost(@NonNull Host host) { + mHost = host; + } + + @Override + public void setData(@NonNull List> dataList, int pos) { + mDataList = dataList; + removeAllTabs(); + final int size = dataList.size(); + final int posToSelect = pos >= 0 && pos < size ? pos : 0; + for (int i = 0; i < size; ++i) { + Tab tab = newTab().setText(dataList.get(i).getTitle()); + applyTabTitleStyle(tab.getTabView()); + addTab(tab, i == posToSelect); + } + } + + private void applyTabTitleStyle(TabView tabView) { + if (mTabTitleStyle == null) { + return; + } + applyStyle(tabView, mTabTitleStyle); + } + + @Override + public void manuallyScroll(int pos) { + selectTab(pos); + } + + @Override + public void setIntermediateState(int pos, float positionOffset) { + // not implemented + } + + @Override + public void fixScrollPosition(int pos) { + selectTab(pos); + } + + @Override + public void setTabColors(@ColorInt int activeTextColor, @ColorInt int activeBackgroundColor, @ColorInt int inactiveTextColor) { + setTabTextColors(inactiveTextColor, activeTextColor); + setSelectedTabIndicatorColor(activeBackgroundColor); + } + + @Nullable + @Override + public ViewPager.OnPageChangeListener getCustomPageChangeListener() { + TabLayoutOnPageChangeListener pageChangeListener = getPageChangeListener(); + pageChangeListener.reset(); + return pageChangeListener; + } + + @Override + public void resetScroll() { + scrollTo(0, 0); + manuallyScroll(0); + } + + @Override + protected void onScrollChanged(int l, int t, int oldl, int oldt) { + super.onScrollChanged(l, t, oldl, oldt); + if (mOnScrollChangedListener != null && mShouldDispatchScroll) { + mOnScrollChangedListener.onScrolled(); + mShouldDispatchScroll = false; + } + } + + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + boolean result = super.dispatchTouchEvent(ev); + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + mShouldDispatchScroll = true; + } + return result; + } + + public void setOnScrollChangedListener(@Nullable OnScrollChangedListener onScrollChangedListener) { + mOnScrollChangedListener = onScrollChangedListener; + } + + @Override + public void setTypefaceProvider(@NonNull TypefaceProvider typefaceProvider) { + bindTypefaceProvider(typefaceProvider); + } + + public void setTabTitleStyle(@Nullable DivTabs.TabTitleStyle tabTitleStyle) { + mTabTitleStyle = tabTitleStyle; + } + + /** + * Listener for title layout scrolls + */ + public interface OnScrollChangedListener { + void onScrolled(); + } + + public static class TabViewFactory implements ViewFactory { + + @NonNull + private final Context mContext; + + public TabViewFactory(@NonNull Context context) { + mContext = context; + } + + @NonNull + @Override + public TabView createView() { + return new TabView(mContext); + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabsDivBlockViewBuilder.java b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabsDivBlockViewBuilder.java new file mode 100644 index 000000000..fcd361508 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabsDivBlockViewBuilder.java @@ -0,0 +1,296 @@ +package com.yandex.div.legacy.view.tab; + +import android.content.Context; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.viewpager.widget.ViewPager; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.alicekit.core.utils.Views; +import com.yandex.alicekit.core.views.HeightCalculatorFactory; +import com.yandex.alicekit.core.views.MaxCardHeightCalculator; +import com.yandex.div.DivAction; +import com.yandex.div.legacy.DivBlockWithId; +import com.yandex.div.DivContainerBlock; +import com.yandex.div.DivNumericSize; +import com.yandex.div.DivTabsBlock; +import com.yandex.div.legacy.DivAutoLogger; +import com.yandex.div.legacy.DivLogger; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.R; +import com.yandex.div.legacy.dagger.DivLegacyScope; +import com.yandex.div.legacy.dagger.LegacyNames; +import com.yandex.div.legacy.state.LegacyDivViewState; +import com.yandex.div.legacy.state.LegacyTabsState; +import com.yandex.div.legacy.view.ContainerDivBlockViewBuilder; +import com.yandex.div.legacy.view.DivBaseViewBuilder; +import com.yandex.div.legacy.view.DivView; +import com.yandex.div.legacy.view.DivViewLegacyUtils; +import com.yandex.div.legacy.view.TextStyle; +import com.yandex.div.view.pooling.ViewPool; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import javax.inject.Inject; +import javax.inject.Named; + +/** + * Builder for DivTabsBlock + */ +@DivLegacyScope +public class TabsDivBlockViewBuilder extends DivBaseViewBuilder { + + private static final String FACTORY_TAG_TAB_LAYOUT = "TabsDivBlockViewBuilder.TAB_LAYOUT"; + private static final String FACTORY_TAG_TAB_HEADER = "TabsDivBlockViewBuilder.TAB_HEADER"; + private static final String FACTORY_TAG_TAB_ITEM = "TabsDivBlockViewBuilder.TAB_ITEM"; + + @NonNull + private final Context mContext; + @NonNull + private final ViewPool mViewPool; + @NonNull + private final DivTextStyleProvider mTextStyleProvider; + @NonNull + private final DivAutoLogger mAutoLogger; + @NonNull + private final ContainerDivBlockViewBuilder mContainerBuilder; + @NonNull + private final DivLogger mDivLogger; + @Nullable + private DivTabsBlock mBlock; + + @Inject + TabsDivBlockViewBuilder(@NonNull @Named(LegacyNames.THEMED_CONTEXT) Context context, + @NonNull ViewPool viewPool, + @NonNull DivTextStyleProvider textStyleProvider, + @NonNull DivAutoLogger autoLogger, + @NonNull ContainerDivBlockViewBuilder containerBuilder, + @NonNull DivLogger divLogger) { + super(); + + mContext = context; + mViewPool = viewPool; + mTextStyleProvider = textStyleProvider; + mAutoLogger = autoLogger; + mContainerBuilder = containerBuilder; + mDivLogger = divLogger; + + mViewPool.register(FACTORY_TAG_TAB_LAYOUT, () -> new TabsLayout(mContext), 2); + mViewPool.register(FACTORY_TAG_TAB_HEADER, new TabTitlesLayoutView.TabViewFactory(mContext), 24); + mViewPool.register(FACTORY_TAG_TAB_ITEM, () -> new TabItemLayout(mContext), 4); + } + + @Override + @NonNull + protected View build(@NonNull DivView divView, @NonNull DivTabsBlock divData) { + mBlock = divData; + TabsLayout view = mViewPool.obtain(FACTORY_TAG_TAB_LAYOUT); + view.getTitleLayout().setOnScrollChangedListener(() -> mDivLogger.logTabTitlesScroll(divView)); + BaseDivTabbedCardUi.ActiveTabClickListener actionActiveTabClickListener = (action, position) -> { + divView.handleUri(action.url); + mDivLogger.logActiveTabTitleClicked(divView, position, action); + }; + DivTabsUiImpl divTabsUi = new DivTabsUiImpl(mViewPool, view, getTabbedCardLayoutIds(), + MaxCardHeightCalculator::new, + divView, mTextStyleProvider, mAutoLogger, + actionActiveTabClickListener); + divTabsUi.setDisabledScrollPages(getDisabledScrollPages(divData)); + divTabsUi.setData(() -> { + List list = new ArrayList<>(divData.items.size()); + for (DivTabsBlock.Item item : divData.items) { + list.add(new DivSimpleTab(item, view.getResources().getDisplayMetrics())); + } + return list; + }); + divTabsUi.setTabColors(divData.activeTabColor, divData.activeTabBgColor, divData.inactiveTabColor); + View separator = Views.findViewAndCast(view, R.id.div_tabs_divider); + separator.setVisibility(divData.hasDelimiter ? View.VISIBLE : View.GONE); + separator.setBackgroundColor(divData.delimiterColor); + return view; + } + + @NonNull + private static Set getDisabledScrollPages(@NonNull DivTabsBlock divData) { + Set result = new HashSet<>(); + for (int i = 0; i < divData.items.size(); ++i) { + if (hasGalleryInContainer(divData.items.get(i).content)) { + result.add(i); + } + } + return result; + } + + private static boolean hasGalleryInContainer(@NonNull DivContainerBlock divContainer) { + for (DivContainerBlock.Children child : divContainer.children) { + if (child.asDivGalleryBlock() != null) { + return true; + } + DivContainerBlock containerChild = child.asDivContainerBlock(); + if (containerChild != null && hasGalleryInContainer(containerChild)) { + return true; + } + } + return false; + } + + @NonNull + private BaseDivTabbedCardUi.TabbedCardConfig getTabbedCardLayoutIds() { + return new BaseDivTabbedCardUi.TabbedCardConfig(R.id.base_tabbed_title_container_scroller, + R.id.div_tabs_pager_container, + R.id.div_tabs_container_helper, + true, + false, + FACTORY_TAG_TAB_HEADER, + FACTORY_TAG_TAB_ITEM); + } + + private class DivTabsUiImpl extends BaseDivTabbedCardUi, ViewGroup, DivAction> { + + @NonNull + private final DivView mDivView; + + DivTabsUiImpl(@NonNull ViewPool viewPool, + @NonNull View view, + @NonNull TabbedCardConfig tabbedCardConfig, + @NonNull HeightCalculatorFactory heightCalculatorFactory, + @NonNull DivView divView, + @NonNull DivTextStyleProvider textStyleProvider, + @NonNull DivAutoLogger autoLogger, + @NonNull ActiveTabClickListener activeTabClickListener) { + super( + viewPool, + view, + tabbedCardConfig, + heightCalculatorFactory, + textStyleProvider, + autoLogger, + null, + activeTabClickListener + ); + mDivView = divView; + } + + @Override + public void setData(@Nullable Input> data) { + super.setData(data); + setupCurrentPage(); // can to switch to page only after pages are populated + } + + @NonNull + @Override + protected ViewGroup bindTabData(@NonNull ViewGroup tabView, @NonNull Input.SimpleTab tab, int tabNumber) { + tabView.removeAllViews(); + DivContainerBlock divData = tab.getItem().content; + mDivView.setActionHandlerForView(tabView, divData.action); + addContainerView(tabView, divData, tabNumber); + return tabView; + } + + private void setupCurrentPage() { + LegacyDivViewState viewState = mDivView.getCurrentState(); + Assert.assertNotNull(viewState); + DivTabsBlock block = mBlock; + Assert.assertNotNull(block); + if (viewState == null || block == null) { + return; + } + LegacyTabsState blockState = viewState.getBlockState(block.getBlockId()); + if (blockState != null) { + mPager.setCurrentItem(blockState.getCurrentPage()); + } + mPager.addOnPageChangeListener(new UpdateStatePageChangeListener(block, viewState, mDivView, mDivLogger)); + } + + @Override + protected void unbindTabData(@NonNull ViewGroup viewGroup) { + viewGroup.removeAllViews(); + } + + @Override + protected void fillMeasuringTab(@NonNull ViewGroup tabView, @NonNull Input.SimpleTab tab, int tabNumber) { + tabView.removeAllViews(); + DivContainerBlock divData = tab.getItem().content; + addContainerView(tabView, divData, tabNumber); + } + + private void addContainerView(@NonNull ViewGroup tabView, @NonNull DivContainerBlock divData, int tabNumber) { + if (mBlock == null) { + Assert.fail("mBlock should have been initialized in the build() method"); + } + View view = mContainerBuilder.build(mDivView, divData, DivBlockWithId.appendId(mBlock.getBlockId(), + String.valueOf(tabNumber))); + tabView.addView(view); + } + + } + + private static class UpdateStatePageChangeListener extends ViewPager.SimpleOnPageChangeListener { + @NonNull + private final DivTabsBlock mBlock; + @NonNull + private final LegacyDivViewState mDivViewState; + @NonNull + private final DivLogger mDivLogger; + @NonNull + private final DivView mDivView; + + UpdateStatePageChangeListener(@NonNull DivTabsBlock block, @NonNull LegacyDivViewState divViewState, + @NonNull DivView divView, @NonNull DivLogger divLogger) { + mBlock = block; + mDivViewState = divViewState; + mDivLogger = divLogger; + mDivView = divView; + } + + @Override + public void onPageSelected(int page) { + mDivViewState.putBlockState(mBlock.getBlockId(), new LegacyTabsState(page)); + mDivLogger.logTabPageChanged(mDivView, page); + } + } + + private static class DivSimpleTab implements BaseDivTabbedCardUi.Input.SimpleTab { + + private final DivTabsBlock.Item mItem; + private final DisplayMetrics mMetrics; + + DivSimpleTab(DivTabsBlock.Item item, DisplayMetrics metrics) { + mItem = item; + mMetrics = metrics; + } + + @Override + public String getTitle() { + return mItem.title.text; + } + + @Nullable + @Override + public DivAction getActionable() { + return mItem.title.action; + } + + @Nullable + @Override + public TextStyle getTextStyle() { + // will be support in future releases + return null; + } + + @Nullable + @Override + public Integer getTabHeight() { + DivNumericSize divSize = mItem.content.height.asDivNumericSize(); + return divSize != null ? DivViewLegacyUtils.divSizeToLayoutParamsSize(divSize, mMetrics) : null; + } + + @NonNull + @Override + public DivTabsBlock.Item getItem() { + return mItem; + } + } +} diff --git a/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabsLayout.kt b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabsLayout.kt new file mode 100644 index 000000000..ed129adc5 --- /dev/null +++ b/client/android/div-legacy/src/main/java/com/yandex/div/legacy/view/tab/TabsLayout.kt @@ -0,0 +1,98 @@ +package com.yandex.div.legacy.view.tab + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import com.yandex.alicekit.core.annotations.Mockable +import com.yandex.alicekit.core.views.ScrollableViewPager +import com.yandex.alicekit.core.views.ViewPagerFixedSizeLayout +import com.yandex.div.legacy.R + +//todo Вернуть internal после переезда +@Mockable +class TabsLayout @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : LinearLayout(context, attrs) { + val titleLayout: TabTitlesLayoutView<*> + val divider: View + val pagerLayout: ViewPagerFixedSizeLayout + val viewPager: ScrollableViewPager + + init { + id = R.id.div_tabs_block + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + orientation = VERTICAL + + titleLayout = + TabTitlesLayoutView(context, null, R.attr.legacyTabIndicatorLayoutStyle).apply { + id = R.id.base_tabbed_title_container_scroller + layoutParams = createTitleLayoutParams() + val vertical = + resources.getDimensionPixelSize(R.dimen.title_tab_title_margin_vertical) + val horizontal = + resources.getDimensionPixelSize(R.dimen.title_tab_title_margin_horizontal) + setPadding(horizontal, vertical, horizontal, vertical) + clipToPadding = false + } + + divider = View(context).apply { + id = R.id.div_tabs_divider + layoutParams = createDividerLayoutParams() + setBackgroundResource(R.color.div_separator_color) + } + + viewPager = ScrollableViewPager(context).apply { + id = R.id.div_tabs_pager_container + layoutParams = + ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + overScrollMode = View.OVER_SCROLL_NEVER + isNestedScrollingEnabled = true + } + + pagerLayout = ViewPagerFixedSizeLayout(context).apply { + id = R.id.div_tabs_container_helper + layoutParams = + ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + collapsiblePaddingBottom = 0 + + val bottomView = FrameLayout(context).apply { + layoutParams = + ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + visibility = GONE + } + + addView(viewPager) + addView(bottomView) + } + + addView(titleLayout) + addView(divider) + addView(pagerLayout) + } + + private fun createTitleLayoutParams(): LayoutParams { + return LayoutParams( + LayoutParams.MATCH_PARENT, + resources.getDimensionPixelSize(R.dimen.title_tab_title_height) + ).apply { + gravity = Gravity.START + } + } + + private fun createDividerLayoutParams(): LayoutParams { + return LayoutParams( + LayoutParams.MATCH_PARENT, + resources.getDimensionPixelSize(R.dimen.div_separator_delimiter_height) + ).apply { + leftMargin = resources.getDimensionPixelSize(R.dimen.div_horizontal_padding) + rightMargin = leftMargin + topMargin = + resources.getDimensionPixelSize(R.dimen.title_tab_title_separator_margin_top) + bottomMargin = resources.getDimensionPixelSize(R.dimen.title_tab_title_margin_vertical) + } + } +} diff --git a/client/android/div-legacy/src/main/res/drawable/button_background.xml b/client/android/div-legacy/src/main/res/drawable/button_background.xml new file mode 100644 index 000000000..75a952703 --- /dev/null +++ b/client/android/div-legacy/src/main/res/drawable/button_background.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/client/android/div-legacy/src/main/res/drawable/div_button_background.xml b/client/android/div-legacy/src/main/res/drawable/div_button_background.xml new file mode 100644 index 000000000..85d8f2c87 --- /dev/null +++ b/client/android/div-legacy/src/main/res/drawable/div_button_background.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/client/android/div-legacy/src/main/res/drawable/div_gallery_tail_arrow.xml b/client/android/div-legacy/src/main/res/drawable/div_gallery_tail_arrow.xml new file mode 100644 index 000000000..bbaeb68c9 --- /dev/null +++ b/client/android/div-legacy/src/main/res/drawable/div_gallery_tail_arrow.xml @@ -0,0 +1,13 @@ + + + + diff --git a/client/android/div-legacy/src/main/res/drawable/div_traffic_item_background.xml b/client/android/div-legacy/src/main/res/drawable/div_traffic_item_background.xml new file mode 100644 index 000000000..85deb8ea6 --- /dev/null +++ b/client/android/div-legacy/src/main/res/drawable/div_traffic_item_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/client/android/div-legacy/src/main/res/layout/div_traffic_item.xml b/client/android/div-legacy/src/main/res/layout/div_traffic_item.xml new file mode 100644 index 000000000..22acc86c6 --- /dev/null +++ b/client/android/div-legacy/src/main/res/layout/div_traffic_item.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/client/android/div-legacy/src/main/res/layout/div_traffic_list.xml b/client/android/div-legacy/src/main/res/layout/div_traffic_list.xml new file mode 100644 index 000000000..b245cfa06 --- /dev/null +++ b/client/android/div-legacy/src/main/res/layout/div_traffic_list.xml @@ -0,0 +1,6 @@ + + + diff --git a/client/android/div-legacy/src/main/res/values/attrs.xml b/client/android/div-legacy/src/main/res/values/attrs.xml new file mode 100644 index 000000000..4b17c83f9 --- /dev/null +++ b/client/android/div-legacy/src/main/res/values/attrs.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/android/div-legacy/src/main/res/values/colors.xml b/client/android/div-legacy/src/main/res/values/colors.xml new file mode 100644 index 000000000..61c387027 --- /dev/null +++ b/client/android/div-legacy/src/main/res/values/colors.xml @@ -0,0 +1,12 @@ + + + + #33919cb5 + #CC000000 + #80000000 + #66000000 + + #edf0f2 + #ffdc60 + @color/div_tab_indicator + diff --git a/client/android/div-legacy/src/main/res/values/dimens.xml b/client/android/div-legacy/src/main/res/values/dimens.xml new file mode 100644 index 000000000..0c81f0c40 --- /dev/null +++ b/client/android/div-legacy/src/main/res/values/dimens.xml @@ -0,0 +1,113 @@ + + + + 1dp + 12dp + + 68dp + 88dp + 148dp + + 0dp + + 40dp + 8dp + 8dp + 4dp + + 14dp + 8dp + 8dp + 36sp + 24sp + + 40sp + 28sp + 24sp + 16sp + 18sp + 20sp + 13sp + 14sp + 16sp + 12sp + 11sp + + 8dp + 6dp + + 4dp + 8dp + 12dp + 16dp + 20dp + 40dp + + 6dp + 104dp + 1dp + 40dp + 24dp + 9dp + 12dp + 16dp + 2dp + + 8dp + + 24dp + + 11sp + 13sp + + 16dp + 18dp + 20dp + 24dp + 36dp + 40dp + + 0dp + 4dp + 8dp + 12dp + 16dp + 20dp + 32dp + 48dp + + 0dp + + 12dp + 2dp + + 12dp + 40dp + 60dp + 120dp + 32dp + 40dp + 4dp + 8dp + 4dp + 5dp + + 0.0 + -0.06 + -0.04 + -0.04 + 0.04 + 0.09 + + 0sp + 2sp + 4sp + 1sp + 2sp + -1sp + 4sp + 1sp + 2sp + 4sp + 5sp + diff --git a/client/android/div-legacy/src/main/res/values/ids.xml b/client/android/div-legacy/src/main/res/values/ids.xml new file mode 100644 index 000000000..4ec367ee2 --- /dev/null +++ b/client/android/div-legacy/src/main/res/values/ids.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/client/android/div-legacy/src/main/res/values/styles.xml b/client/android/div-legacy/src/main/res/values/styles.xml new file mode 100644 index 000000000..cd5a3e1e9 --- /dev/null +++ b/client/android/div-legacy/src/main/res/values/styles.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivContextTest.kt b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivContextTest.kt new file mode 100644 index 000000000..0937d42f4 --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivContextTest.kt @@ -0,0 +1,54 @@ +package com.yandex.div.legacy + +import android.app.Activity +import com.yandex.alicekit.core.experiments.ExperimentConfig +import com.yandex.alicekit.core.widget.TypefaceProvider +import com.yandex.div.core.images.DivImageLoader +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DivContextTest { + + val activity = Robolectric.setupActivity(Activity::class.java) + val divConfiguration = mock() + val autoLogger = mock() + val divLogger = mock() + val imageLoader = mock() + val typefaceProvider = mock() + val actionHandler = mock() + val experimentConfig = mock() + + @Before + fun setUp() { + whenever(divConfiguration.autoLogger).thenReturn(autoLogger) + whenever(divConfiguration.divLogger).thenReturn(divLogger) + whenever(divConfiguration.imageLoader).thenReturn(imageLoader) + whenever(divConfiguration.actionHandler).thenReturn(actionHandler) + whenever(divConfiguration.experimentConfig).thenReturn(experimentConfig) + + // Enable all flags + whenever(experimentConfig.getBooleanValue(any())).thenReturn(true) + } + + @Test(expected = AssertionError::class) + fun `context creation in background failed`() { + runAsync { + DivContext(activity, divConfiguration) + } + } + + @Test + fun `context warm up in background successful`() { + val context = DivContext(activity, divConfiguration) + runAsync { + context.warmUp() + } + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivDataMockUtils.java b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivDataMockUtils.java new file mode 100644 index 000000000..c59bc14dc --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivDataMockUtils.java @@ -0,0 +1,256 @@ +package com.yandex.div.legacy; + +import android.graphics.Color; +import android.net.Uri; +import androidx.annotation.ColorInt; +import androidx.annotation.FloatRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.div.DivAction; +import com.yandex.div.DivAlignment; +import com.yandex.div.DivBackground; +import com.yandex.div.DivBaseBlock; +import com.yandex.div.DivButtonsBlock; +import com.yandex.div.DivData; +import com.yandex.div.DivGradientBackground; +import com.yandex.div.DivImageBackground; +import com.yandex.div.DivImageElement; +import com.yandex.div.DivPaddingModifier; +import com.yandex.div.DivPosition; +import com.yandex.div.DivPredefinedSize; +import com.yandex.div.DivSeparatorBlock; +import com.yandex.div.DivSize; +import com.yandex.div.DivSizeTrait; +import com.yandex.div.DivSolidBackground; +import com.yandex.div.DivTextStyle; +import com.yandex.div.DivTitleBlock; +import com.yandex.div.DivUniversalBlock; +import java.util.List; +import org.json.JSONException; +import org.json.JSONObject; +import org.robolectric.util.ReflectionHelpers; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class DivDataMockUtils { + + // Mocks for backgrounds + + public static DivBackground createSolidBackground(@ColorInt int color) { + final DivBackground solidBackground = mock(DivBackground.class); + DivSolidBackground background = mock(DivSolidBackground.class); + when(solidBackground.asDivSolidBackground()).thenReturn(background); + ReflectionHelpers.setField(background, "color", color); + + return solidBackground; + } + + public static DivBackground createGradientBackground(@ColorInt int startColor, @ColorInt int endColor) { + final DivBackground gradientBackground = mock(DivBackground.class); + DivGradientBackground background = mock(DivGradientBackground.class); + when(gradientBackground.asDivGradientBackground()).thenReturn(background); + + ReflectionHelpers.setField(background, "startColor", startColor); + ReflectionHelpers.setField(background, "endColor", endColor); + + return gradientBackground; + } + + public static DivBackground createImageBackground(String url) { + final DivBackground imageBackground = mock(DivBackground.class); + + DivImageBackground background = mock(DivImageBackground.class); + when(imageBackground.asDivImageBackground()).thenReturn(background); + ReflectionHelpers.setField(background, "imageUrl", Uri.parse(url)); + + return imageBackground; + } + + // Mocks for DivData + + public static DivData createDivData(@Nullable List backgroundList, @NonNull List stateList, + @NonNull DivSizeTrait predefinedSize) { + final DivData divData = mock(DivData.class); + + ReflectionHelpers.setField(divData, "background", backgroundList); + ReflectionHelpers.setField(divData, "states", stateList); + ReflectionHelpers.setField(divData, "width", predefinedSize); + + return divData; + } + + public static DivSizeTrait createDivPredefinedSize() { + DivPredefinedSize predefinedSize; + DivSizeTrait divSizeTrait = null; + try { + predefinedSize = new DivPredefinedSize(new JSONObject("{\n" + + "\"value\": \"match_parent\",\n" + + "\"type\": \"predefined\"\n" + + "}"), null); + divSizeTrait = new DivSizeTrait(predefinedSize.writeToJSON(), null); + } catch (JSONException e) { + e.printStackTrace(); + } + + + return divSizeTrait; + } + + public static DivData.State createDivState(int stateId, @Nullable DivAction action, @Nullable List blockList) { + final DivData.State divState = mock(DivData.State.class); + + ReflectionHelpers.setField(divState, "stateId", stateId); + ReflectionHelpers.setField(divState, "action", action); + ReflectionHelpers.setField(divState, "blocks", blockList); + + return divState; + } + + @NonNull + public static DivPaddingModifier createPaddingModifier(@DivSize @Nullable final String size, @DivPosition String position) { + final DivPaddingModifier paddingModifier = mock(DivPaddingModifier.class); + ReflectionHelpers.setField(paddingModifier, "size", size); + ReflectionHelpers.setField(paddingModifier, "position", position); + return paddingModifier; + } + + @NonNull + public static DivAction createDivAction(@Nullable final String logId, @NonNull final String url) { + final DivAction action = mock(DivAction.class); + ReflectionHelpers.setField(action, "logId", logId); + ReflectionHelpers.setField(action, "url", Uri.parse(url)); + return action; + } + + public static DivData.State.Block createStateBlock(@NonNull @DivData.State.Block.Type String type, + @NonNull DivBaseBlock baseBlock) { + DivData.State.Block block = mock(DivData.State.Block.class); + + when(block.asDivTitleBlock()).thenCallRealMethod(); + when(block.asDivButtonsBlock()).thenCallRealMethod(); + when(block.asDivFooterBlock()).thenCallRealMethod(); + when(block.asDivContainerBlock()).thenCallRealMethod(); + when(block.asDivTableBlock()).thenCallRealMethod(); + when(block.asDivGalleryBlock()).thenCallRealMethod(); + when(block.asDivImageBlock()).thenCallRealMethod(); + when(block.asDivSeparatorBlock()).thenCallRealMethod(); + when(block.asDivTrafficBlock()).thenCallRealMethod(); + when(block.asDivUniversalBlock()).thenCallRealMethod(); + + ReflectionHelpers.setField(block, "value", baseBlock); + ReflectionHelpers.setField(block, "type", type); + + return block; + } + + // Mocks for separate types of divs + + public static DivTitleBlock createDivTitleBlock(@Nullable final String text, @NonNull @DivTextStyle final String textStyle, + @NonNull final String menuColor, @Nullable final DivPaddingModifier paddingModifier, + @Nullable DivAction action) { + final DivTitleBlock block = mock(DivTitleBlock.class); + + ReflectionHelpers.setField(block, "text", text); + ReflectionHelpers.setField(block, "textStyle", textStyle); + ReflectionHelpers.setField(block, "menuColor", Color.parseColor(menuColor)); + ReflectionHelpers.setField(block, "paddingModifier", paddingModifier); + ReflectionHelpers.setField(block, "action", action); + + return block; + } + + public static DivSeparatorBlock createDivSeparatorBlock(@ColorInt int delimiterColor, boolean hasDelmiter, @DivSize String size) { + final DivSeparatorBlock block = mock(DivSeparatorBlock.class); + + ReflectionHelpers.setField(block, "delimiterColor", delimiterColor); + ReflectionHelpers.setField(block, "hasDelimiter", hasDelmiter); + ReflectionHelpers.setField(block, "size", size); + + return block; + } + + @NonNull + public static DivButtonsBlock.Item createButtonItem(@NonNull final DivAction action, + @NonNull final String backgroundColor, + @Nullable final DivImageElement image, + @Nullable final String text) { + DivButtonsBlock.Item result = mock(DivButtonsBlock.Item.class); + ReflectionHelpers.setField(result, "action", action); + ReflectionHelpers.setField(result, "backgroundColor", Color.parseColor(backgroundColor)); + ReflectionHelpers.setField(result, "image", image); + ReflectionHelpers.setField(result, "text", text); + return result; + } + + public static void setButtonData(@NonNull final DivButtonsBlock buttonBlock, @Nullable final DivAction action, + @NonNull @DivAlignment final String alignment, @NonNull final List items, + final boolean isFullwidth) { + ReflectionHelpers.setField(buttonBlock, "action", action); + ReflectionHelpers.setField(buttonBlock, "alignment", alignment); + ReflectionHelpers.setField(buttonBlock, "items", items); + ReflectionHelpers.setField(buttonBlock, "isFullwidth", isFullwidth); + } + + public static DivUniversalBlock createDivUniversalBlock(@Nullable String title, @DivTextStyle String titleStyle, @Nullable Integer titleMaxLines, + @Nullable String text, @DivTextStyle String textStyle, @Nullable Integer textMaxLines) { + final DivUniversalBlock block = mock(DivUniversalBlock.class); + + ReflectionHelpers.setField(block, "title", title); + ReflectionHelpers.setField(block, "titleStyle", titleStyle); + ReflectionHelpers.setField(block, "titleMaxLines", titleMaxLines); + + ReflectionHelpers.setField(block, "text", text); + ReflectionHelpers.setField(block, "textStyle", textStyle); + ReflectionHelpers.setField(block, "textMaxLines", textMaxLines); + return block; + } + + public static DivImageElement createDivImageElement(@NonNull Uri imageUrl, @FloatRange(from=0.1, to=100) double ratio) { + DivImageElement element = mock(DivImageElement.class); + ReflectionHelpers.setField(element, "imageUrl", imageUrl); + ReflectionHelpers.setField(element, "ratio", ratio); + + return element; + } + + public static void addDateToDivUniversalBlock(DivUniversalBlock block, @DivPosition String position, @NonNull @DivSize String size, + @NonNull String dateDay, @Nullable String dateMonth) { + final DivUniversalBlock.SideElement sideElement = mock(DivUniversalBlock.SideElement.class); + final DivUniversalBlock.SideElement.Element element = mock(DivUniversalBlock.SideElement.Element.class); + final DivUniversalBlock.SideElement.DateElement value = mock(DivUniversalBlock.SideElement.DateElement.class); + + ReflectionHelpers.setField(sideElement, "element", element); + ReflectionHelpers.setField(sideElement, "position", position); + ReflectionHelpers.setField(sideElement, "size", size); + + ReflectionHelpers.setField(element, "type", "date_element"); + ReflectionHelpers.setField(element, "value", value); + + when(sideElement.element.asDateElement()).thenCallRealMethod(); + when(sideElement.element.asDivImageElement()).thenCallRealMethod(); + + ReflectionHelpers.setField(value, "dateDay", dateDay); + ReflectionHelpers.setField(value, "dateMonth", dateMonth); + + ReflectionHelpers.setField(block, "sideElement", sideElement); + } + + public static void addImageToDivUniversalBlock(DivUniversalBlock block, @DivPosition String position, @NonNull @DivSize String size, + @NonNull DivImageElement imageElement) { + final DivUniversalBlock.SideElement sideElement = mock(DivUniversalBlock.SideElement.class); + final DivUniversalBlock.SideElement.Element element = mock(DivUniversalBlock.SideElement.Element.class); + + ReflectionHelpers.setField(sideElement, "element", element); + ReflectionHelpers.setField(sideElement, "position", position); + ReflectionHelpers.setField(sideElement, "size", size); + + ReflectionHelpers.setField(element, "type", "div-image-element"); + ReflectionHelpers.setField(element, "value", imageElement); + + when(sideElement.element.asDateElement()).thenCallRealMethod(); + when(sideElement.element.asDivImageElement()).thenCallRealMethod(); + + ReflectionHelpers.setField(block, "sideElement", sideElement); + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivDataValidatorTest.kt b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivDataValidatorTest.kt new file mode 100644 index 000000000..9171a2ac3 --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivDataValidatorTest.kt @@ -0,0 +1,68 @@ +package com.yandex.div.legacy + +import com.yandex.alicekit.core.json.ParsingErrorLogger +import com.yandex.alicekit.core.utils.IOUtils +import com.yandex.div.DivData +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DivDataValidatorTest { + + private val validator = DivDataValidator() + + @Test + fun invalid_table() { + com.yandex.alicekit.core.utils.Assert.setEnabled(false) + val data = loadData("invalid_table.json") + val validate = validator.validate(data, null) + assertEquals(0, validate[0]) + com.yandex.alicekit.core.utils.Assert.setEnabled(true) + } + + @Test + fun invalid_traffic() { + val data = loadData("invalid_traffic.json") + val validate = validator.validate(data, null) + assertEquals(0, validate[0]) + } + + @Test + fun only_separators() { + val data = loadData("only_separators.json") + val validate = validator.validate(data, null) + assertEquals(0, validate[0]) + } + + @Test + fun valid_table() { + val data = loadData("valid_table.json") + val validate = validator.validate(data, null) + assertEquals(1, validate[0]) + } + + @Test + fun valid_title_universal_buttons_footer() { + val data = loadData("valid_title_universal_buttons_footer.json") + val validate = validator.validate(data, null) + assertEquals(4, validate[0]) + } + + @Test + fun valid_traffic() { + val data = loadData("valid_traffic.json") + val validate = validator.validate(data, null) + assertEquals(1, validate[0]) + } + + private fun loadData(resourceName: String): DivData { + return DivData(JSONObject(String(readFromResource(javaClass, resourceName))), ParsingErrorLogger.LOG) + } + + private fun readFromResource(clz: Class, resourceName: String): ByteArray { + return IOUtils.toByteArray(clz.getResourceAsStream(resourceName)) ?: throw AssertionError("Could not get bytes from resource") + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivTableMockUtils.kt b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivTableMockUtils.kt new file mode 100644 index 000000000..5cb30b92d --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivTableMockUtils.kt @@ -0,0 +1,171 @@ +package com.yandex.div.legacy + +import android.net.Uri +import androidx.annotation.ColorInt +import com.yandex.div.DivAlignment +import com.yandex.div.DivAlignmentVertical +import com.yandex.div.DivImageElement +import com.yandex.div.DivPosition +import com.yandex.div.DivSize +import com.yandex.div.DivTableBlock +import com.yandex.div.DivTextStyle +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.util.ReflectionHelpers + +@DslMarker +@Target(AnnotationTarget.TYPE) +annotation class DivTableDslMarker + +typealias ColumnList = MutableList +typealias RowList = MutableList +typealias CellList = MutableList + +inline fun divTable(init: @DivTableDslMarker DivTableBlock.() -> Unit): DivTableBlock { + val tableBlock = mock() + tableBlock.init() + return tableBlock +} + +inline fun DivTableBlock.columns(init: @DivTableDslMarker ColumnList.() -> Unit) { + val columns = arrayListOf() + columns.init() + ReflectionHelpers.setField(this, "columns", columns) +} + + +fun ColumnList.column( + weight: Int = 0, + @DivSize leftPadding: String = DivSize.XS, + @DivSize rightPadding: String = DivSize.XS +) { + val column = mock() + ReflectionHelpers.setField(column, "weight", weight) + ReflectionHelpers.setField(column, "leftPadding", leftPadding) + ReflectionHelpers.setField(column, "rightPadding", rightPadding) + add(column) +} + +inline fun DivTableBlock.rows(init: @DivTableDslMarker RowList.() -> Unit) { + val rows = arrayListOf() + rows.init() + ReflectionHelpers.setField(this, "rows", rows) +} + +fun RowList.separator(@ColorInt color: Int = 0x14000000) { + val separator = mock() + ReflectionHelpers.setField(separator, "color", color) + + val row = mock() + ReflectionHelpers.setField(row, "type", DivTableBlock.Row.Type.SEPARATOR_ELEMENT) + ReflectionHelpers.setField(row, "value", separator) + whenever(row.asSeparatorElement()).thenReturn(separator) + add(row) +} + +inline fun RowList.row( + @DivSize topPadding: String = DivSize.ZERO, + @DivSize bottomPadding: String = DivSize.XXS, + init: @DivTableDslMarker CellList.() -> Unit +) { + val cells = arrayListOf() + cells.init() + + val rowElement = mock() + ReflectionHelpers.setField(rowElement, "topPadding", topPadding) + ReflectionHelpers.setField(rowElement, "bottomPadding", bottomPadding) + ReflectionHelpers.setField(rowElement, "cells", cells) + + val row = mock() + ReflectionHelpers.setField(row, "type", DivTableBlock.Row.Type.ROW_ELEMENT) + ReflectionHelpers.setField(row, "value", rowElement) + whenever(row.asRowElement()).thenReturn(rowElement) + add(row) +} + +fun CellList.text( + text: String, + @DivTextStyle textStyle: String = DivTextStyle.TEXT_M, + @DivAlignment horizontalAlignment: String = DivAlignment.LEFT, + @DivAlignmentVertical verticalAlignment: String = DivAlignmentVertical.CENTER +) { + cell( + text = text, + textStyle = textStyle, + imageUrl = null, + imageRatio = 1.0f, + imageSize = DivSize.M, + imagePosition = DivPosition.LEFT, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment + ) +} + +fun CellList.image( + imageUrl: String, + imageRatio: Float = 1.0f, + @DivSize imageSize: String = DivSize.M, + @DivAlignment horizontalAlignment: String = DivAlignment.LEFT, + @DivAlignmentVertical verticalAlignment: String = DivAlignmentVertical.CENTER +) { + cell( + text = null, + textStyle = DivTextStyle.TEXT_M, + imageUrl = imageUrl, + imageRatio = imageRatio, + imageSize = imageSize, + imagePosition = DivPosition.LEFT, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment + ) +} + +fun CellList.imageAndText( + text: String, + @DivTextStyle textStyle: String = DivTextStyle.TEXT_M, + imageUrl: String, + imageRatio: Float = 1.0f, + @DivSize imageSize: String = DivSize.M, + @DivPosition imagePosition: String = DivPosition.LEFT, + @DivAlignment horizontalAlignment: String = DivAlignment.LEFT, + @DivAlignmentVertical verticalAlignment: String = DivAlignmentVertical.CENTER +) { + cell( + text = text, + textStyle = textStyle, + imageUrl = imageUrl, + imageRatio = imageRatio, + imageSize = imageSize, + imagePosition = imagePosition, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment + ) +} + +private fun CellList.cell( + text: String?, + @DivTextStyle textStyle: String, + imageUrl: String?, + imageRatio: Float, + @DivSize imageSize: String, + @DivPosition imagePosition: String, + @DivAlignment horizontalAlignment: String, + @DivAlignmentVertical verticalAlignment: String +) { + val image: DivImageElement? = imageUrl?.let { url -> + mock().also { image -> + ReflectionHelpers.setField(image, "imageUrl", Uri.parse(url)) + ReflectionHelpers.setField(image, "ratio", imageRatio) + } + } + + val cell = mock() + ReflectionHelpers.setField(cell, "text", text) + ReflectionHelpers.setField(cell, "textStyle", textStyle) + ReflectionHelpers.setField(cell, "image", image) + ReflectionHelpers.setField(cell, "imageSize", imageSize) + ReflectionHelpers.setField(cell, "imagePosition", imagePosition) + ReflectionHelpers.setField(cell, "horizontalAlignment", horizontalAlignment) + ReflectionHelpers.setField(cell, "verticalAlignment", verticalAlignment) + add(cell) +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivTestUtils.kt b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivTestUtils.kt new file mode 100644 index 000000000..eef31f29d --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivTestUtils.kt @@ -0,0 +1,50 @@ +@file:JvmName("DivTestUtils") + +package com.yandex.div.legacy + +import android.view.View +import android.widget.TextView +import com.yandex.alicekit.core.utils.Assert +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +internal fun runAsync(action: () -> Unit) { + val executor = Executors.newSingleThreadExecutor() + var error: Throwable? = null + executor.execute { + try { + action.invoke() + } catch (e: Throwable) { + error = e + } + } + + executor.shutdown() + val finished = executor.awaitTermination(10, TimeUnit.SECONDS) + if (!finished) throw TimeoutException("Test execution takes more than 10 seconds.") + + error?.let { throw it } +} + +internal fun compareViews(first: View, second: View): Boolean { + Assert.assertEquals(first.width, second.width) + Assert.assertEquals(first.height, second.height) + + Assert.assertEquals(first.paddingTop, second.paddingTop) + Assert.assertEquals(first.paddingLeft, second.paddingLeft) + Assert.assertEquals(first.paddingRight, second.paddingRight) + Assert.assertEquals(first.paddingBottom, second.paddingBottom) + + Assert.assertEquals(first.alpha, second.alpha) + + Assert.assertEquals(first.hasOnClickListeners(), second.hasOnClickListeners()) + when (first) { + is TextView -> { + Assert.assertEquals(first.textSize, (second as TextView).textSize) + Assert.assertEquals(first.text, second.text) + Assert.assertEquals(first.maxLines, second.maxLines) + } + } + return true +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivViewTest.java b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivViewTest.java new file mode 100644 index 000000000..1325b8243 --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/DivViewTest.java @@ -0,0 +1,154 @@ +package com.yandex.div.legacy; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.net.Uri; +import android.view.View; +import androidx.annotation.NonNull; +import com.yandex.alicekit.core.experiments.ExperimentConfig; +import com.yandex.div.DivBackground; +import com.yandex.div.DivData; +import com.yandex.div.DivDataTag; +import com.yandex.div.DivPosition; +import com.yandex.div.DivSize; +import com.yandex.div.DivSizeTrait; +import com.yandex.div.DivTextStyle; +import com.yandex.div.DivTitleBlock; +import com.yandex.div.core.images.BitmapSource; +import com.yandex.div.core.images.CachedBitmap; +import com.yandex.div.core.images.DivImageDownloadCallback; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.core.images.LoadReference; +import com.yandex.div.legacy.state.LegacyDivStateCache; +import com.yandex.div.legacy.view.DivView; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import kotlin.Unit; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class DivViewTest { + + private static final String MENU_COLOR = "blue"; + private static final String BACKGROUND_IMAGE_URL = "http://ya.ru/cat.png"; + + @Mock + private DivLegacyConfiguration mDivConfiguration; + @Mock + private DivImageLoader mImageLoader; + @Mock + private DivAutoLogger mAutoLogger; + @Mock + private DivLogger mDivLogger; + @Mock + private LegacyDivActionHandler mActionHandler; + @Mock + private ExperimentConfig mExperimentConfig; + @Mock + private LoadReference mLoadReference; + @Mock + private LegacyDivStateCache mDivStateCache; + + private final Activity mActivity = Robolectric.setupActivity(Activity.class); + private DivContext mDivContext; + + @Before + public void before() { + MockitoAnnotations.initMocks(this); + when(mImageLoader.loadImage(anyString(), any(DivImageDownloadCallback.class))).thenReturn(mLoadReference); + when(mDivConfiguration.getImageLoader()).thenReturn(mImageLoader); + when(mDivConfiguration.getAutoLogger()).thenReturn(mAutoLogger); + when(mDivConfiguration.getDivLogger()).thenReturn(mDivLogger); + when(mDivConfiguration.getActionHandler()).thenReturn(mActionHandler); + when(mDivConfiguration.getExperimentConfig()).thenReturn(mExperimentConfig); + when(mDivConfiguration.getDivStateCache()).thenReturn(mDivStateCache); + + mDivContext = new DivContext(mActivity, mDivConfiguration); + } + + @Test + public void testDivViewLifecycle() { + DivView divView = createDivView(mDivContext); + divView.switchToState(1337); + + Assert.assertEquals(1, divView.getChildCount()); + final View addedChild = divView.getChildAt(0); + Assert.assertNotNull(divView.getBackground()); + { + final ArgumentCaptor captor = ArgumentCaptor.forClass(DivImageDownloadCallback.class); + verify(mImageLoader).loadImage(same(BACKGROUND_IMAGE_URL), captor.capture()); + final Bitmap bitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888); + captor.getValue().onSuccess(new CachedBitmap(bitmap, Uri.parse("https://uri.ru"), BitmapSource.NETWORK)); + Assert.assertNotNull(divView.getBackground()); + } + { + divView.switchToState(239); + Assert.assertEquals(1, divView.getChildCount()); + Assert.assertNotSame(addedChild, divView.getChildAt(0)); + } + } + + @Test + public void testAsyncCreateAndBind() throws Exception { + DivTestUtils.runAsync(() -> { + createDivView(mDivContext); + return Unit.INSTANCE; + }); + } + + @NonNull + private DivView createDivView(DivContext context) { + final DivView divView = new DivView(context); + + final List backgrounds = new ArrayList<>(3); + backgrounds.add(DivDataMockUtils.createSolidBackground(Color.RED)); + backgrounds.add(DivDataMockUtils.createImageBackground(BACKGROUND_IMAGE_URL)); + backgrounds.add(DivDataMockUtils.createGradientBackground(Color.RED, Color.WHITE)); + + final DivTitleBlock titleBlock1 = DivDataMockUtils.createDivTitleBlock("Title 1", DivTextStyle.TEXT_M, MENU_COLOR, + DivDataMockUtils.createPaddingModifier(DivSize.M, DivPosition.LEFT), + null); + + final DivData.State.Block blocks1 = DivDataMockUtils.createStateBlock( + DivData.State.Block.Type.DIV_TITLE_BLOCK, titleBlock1); + final DivData.State state1 = DivDataMockUtils.createDivState(1337, null, Collections.singletonList(blocks1)); + + final DivTitleBlock titleBlock2 = DivDataMockUtils.createDivTitleBlock("Title 2", DivTextStyle.TEXT_S, MENU_COLOR, + DivDataMockUtils.createPaddingModifier(DivSize.M, DivPosition.LEFT), + null); + final DivData.State.Block blocks2 = DivDataMockUtils.createStateBlock( + DivData.State.Block.Type.DIV_TITLE_BLOCK, titleBlock2); + final DivData.State state2 = DivDataMockUtils.createDivState(239, null, Collections.singletonList(blocks2)); + + final List stateList = Arrays.asList(state1, state2); + final DivSizeTrait predefinedSize = DivDataMockUtils.createDivPredefinedSize(); + final DivData divData = DivDataMockUtils.createDivData(backgrounds, stateList, predefinedSize); + final DivDataTag divDataTag = new DivDataTag("0"); + + Assert.assertEquals(0, divView.getChildCount()); + Assert.assertNull(divView.getBackground()); + LegacyDivViewConfig divConfig = mock(LegacyDivViewConfig.class); + when(divConfig.isContextMenuEnabled()).thenReturn(true); + divView.setConfig(divConfig); + divView.setDivData(divData, divDataTag); + return divView; + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/TestApplication.kt b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/TestApplication.kt new file mode 100644 index 000000000..af15c82df --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/TestApplication.kt @@ -0,0 +1,11 @@ +package com.yandex.div.legacy + +import android.app.Application +import com.yandex.alicekit.core.utils.Assert + +class TestApplication : Application() { + + init { + Assert.setEnabled(true) + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/ButtonsDivBlockViewBuilderTest.java b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/ButtonsDivBlockViewBuilderTest.java new file mode 100644 index 000000000..25a8e7e4d --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/ButtonsDivBlockViewBuilderTest.java @@ -0,0 +1,129 @@ +package com.yandex.div.legacy.view; + +import android.app.Activity; +import android.net.Uri; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.recyclerview.widget.RecyclerView; +import com.yandex.alicekit.core.widget.YandexSansTypefaceProvider; +import com.yandex.div.DivAction; +import com.yandex.div.DivAlignment; +import com.yandex.div.DivButtonsBlock; +import com.yandex.div.DivImageElement; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.legacy.DivDataMockUtils; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.view.pooling.PseudoViewPool; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.util.ReflectionHelpers; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class ButtonsDivBlockViewBuilderTest { + + private static final String ACTION_URL = "ACTION URL"; + private static final String ACTION_LOGID = "ACTION LOGID"; + @DivAlignment + private static final String ALIGNMENT = DivAlignment.RIGHT; + + private static final Uri IMAGE_URL = Uri.parse("http://url.ru"); + + @Mock + private DivImageLoader mDivImageLoader; + @Mock + private DivView mDivView; + + private ButtonsDivBlockViewBuilder mButtonsDivBlockViewBuilder; + + @Before + public void before() { + MockitoAnnotations.initMocks(this); + + Activity activity = Robolectric.setupActivity(Activity.class); + DivTextStyleProvider textStyleProvider = new DivTextStyleProvider(new YandexSansTypefaceProvider(activity)); + when(mDivView.getContext()).thenReturn(activity); + + mButtonsDivBlockViewBuilder = new ButtonsDivBlockViewBuilder(activity, new PseudoViewPool(), mDivImageLoader, textStyleProvider); + } + + @Test + public void testEmptyData() { + final DivButtonsBlock buttonsBlock = mock(DivButtonsBlock.class); + + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + DivDataMockUtils.setButtonData(buttonsBlock, divAction, ALIGNMENT, Collections.emptyList(), false); + + Assert.assertNull(mButtonsDivBlockViewBuilder.build(mDivView, buttonsBlock)); + } + + @Test + public void testBindText() { + final DivButtonsBlock buttonsBlock = mock(DivButtonsBlock.class); + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + + final DivAction itemAction1 = DivDataMockUtils.createDivAction("ID1", "URL1"); + final DivButtonsBlock.Item item1 = DivDataMockUtils.createButtonItem(itemAction1, "black", null, "text1"); + + DivDataMockUtils.setButtonData(buttonsBlock, divAction, ALIGNMENT, Arrays.asList(item1), true); + + final View build = mButtonsDivBlockViewBuilder.build(mDivView, buttonsBlock); + final TextView textView = (TextView) ((FrameLayout) ((FrameLayout) build).getChildAt(0)).getChildAt(0); + Assert.assertEquals("text1", textView.getText().toString()); + } + + @Test + public void testBindImage() { + final DivButtonsBlock buttonsBlock = mock(DivButtonsBlock.class); + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + + final DivAction itemAction1 = DivDataMockUtils.createDivAction("ID1", "URL1"); + final DivImageElement image1 = mock(DivImageElement.class); + ReflectionHelpers.setField(image1, "imageUrl", IMAGE_URL); + final DivButtonsBlock.Item item1 = DivDataMockUtils.createButtonItem(itemAction1, "black", image1, null); + + DivDataMockUtils.setButtonData(buttonsBlock, divAction, ALIGNMENT, Arrays.asList(item1), true); + + final View build = ((FrameLayout) ((FrameLayout) mButtonsDivBlockViewBuilder.build(mDivView, buttonsBlock)).getChildAt(0)).getChildAt(0); + Assert.assertTrue(build instanceof ImageView); + verify(mDivImageLoader).loadImage(same(IMAGE_URL.toString()), any(ImageView.class)); + } + + @Test + public void testBindSeveralElements() { + final DivButtonsBlock buttonsBlock = mock(DivButtonsBlock.class); + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + + final DivAction itemAction1 = DivDataMockUtils.createDivAction("ID1", "URL1"); + final DivAction itemAction2 = DivDataMockUtils.createDivAction("ID2", "URL2"); + final DivAction itemAction3 = DivDataMockUtils.createDivAction("ID2", "URL3"); + final DivImageElement image1 = mock(DivImageElement.class); + ReflectionHelpers.setField(image1, "imageUrl", Uri.parse("IMAGE1 URL")); + final DivButtonsBlock.Item item1 = DivDataMockUtils.createButtonItem(itemAction1, "black", image1, "text1"); + final DivButtonsBlock.Item item2 = DivDataMockUtils.createButtonItem(itemAction2, "green", null, "text2"); + final DivButtonsBlock.Item item3 = DivDataMockUtils.createButtonItem(itemAction3, "red", image1, "text3"); + + DivDataMockUtils.setButtonData(buttonsBlock, divAction, ALIGNMENT, Arrays.asList(item1, item2, item3), true); + + final View build = mButtonsDivBlockViewBuilder.build(mDivView, buttonsBlock); + Assert.assertTrue(build instanceof RecyclerView); + final RecyclerView recyclerView = (RecyclerView) build; + RecyclerView.Adapter adapter = recyclerView.getAdapter(); + Assert.assertEquals(3, adapter.getItemCount()); + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/DivElementDataViewBuilderTest.java b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/DivElementDataViewBuilderTest.java new file mode 100644 index 000000000..c1abe17b0 --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/DivElementDataViewBuilderTest.java @@ -0,0 +1,77 @@ +package com.yandex.div.legacy.view; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.net.Uri; +import android.widget.TextView; +import com.yandex.alicekit.core.utils.Assert; +import com.yandex.div.DivDataTag; +import com.yandex.div.DivImageElement; +import com.yandex.div.core.images.BitmapSource; +import com.yandex.div.core.images.CachedBitmap; +import com.yandex.div.core.images.DivImageDownloadCallback; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.legacy.DivDataMockUtils; +import com.yandex.div.legacy.R; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.same; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class DivElementDataViewBuilderTest { + + private static final Uri IMAGE_URL = Uri.parse("http://image_url.ru"); + private static final String TEXT = "text123"; + + @Mock + private DivImageLoader mDivImageLoader; + @Mock + private DivView mDivView; + + private TextView mTextView; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + + Activity activity = Robolectric.setupActivity(Activity.class); + when(mDivView.getContext()).thenReturn(activity); + when(mDivView.getDivTag()).thenReturn(DivDataTag.INVALID); + + mTextView = spy(new TextView(activity)); + } + + @Test + public void testBindTextAndImageDivElementData() { + // set + final Bitmap bitmap = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888); + final DivImageElement imageElement = DivDataMockUtils.createDivImageElement(IMAGE_URL, 0.5f); + + // do + DivElementDataViewBuilder.bind(mDivView, mDivImageLoader, mTextView, TEXT, imageElement, + R.dimen.div_horizontal_padding, R.dimen.div_horizontal_padding, + R.dimen.div_table_image_size_l, R.dimen.div_table_image_size_l); + + // check + Assert.assertEquals(TEXT, mTextView.getText().toString()); + + verify(mTextView).setCompoundDrawables(any(), eq(null), eq(null), eq(null)); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(DivImageDownloadCallback.class); + verify(mDivImageLoader).loadImage(same(IMAGE_URL.toString()), argumentCaptor.capture()); + argumentCaptor.getValue().onSuccess(new CachedBitmap(bitmap, Uri.parse("https://uri.ru"), BitmapSource.NETWORK)); + verify(mTextView, times(2)).setCompoundDrawables(any(), eq(null), eq(null), eq(null)); + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/DivTableBlockViewBuilderTest.kt b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/DivTableBlockViewBuilderTest.kt new file mode 100644 index 000000000..6a52d2447 --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/DivTableBlockViewBuilderTest.kt @@ -0,0 +1,233 @@ +package com.yandex.div.legacy.view + +import android.app.Activity +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.TextView +import com.yandex.alicekit.core.widget.YandexSansTypefaceProvider +import com.yandex.div.DivAlignment +import com.yandex.div.DivAlignmentVertical +import com.yandex.div.DivSize +import com.yandex.div.core.images.DivImageLoader +import com.yandex.div.legacy.DivTextStyleProvider +import com.yandex.div.legacy.R +import com.yandex.div.legacy.column +import com.yandex.div.legacy.columns +import com.yandex.div.legacy.divTable +import com.yandex.div.legacy.image +import com.yandex.div.legacy.imageAndText +import com.yandex.div.legacy.row +import com.yandex.div.legacy.rows +import com.yandex.div.legacy.separator +import com.yandex.div.legacy.text +import com.yandex.div.view.SeparatorView +import com.yandex.div.view.pooling.PseudoViewPool +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DivTableBlockViewBuilderTest { + + companion object { + private const val IMAGE_URL = "http://example.com" + private const val TEXT = "TEST TEXT" + private val TEXT_STYLE = TextStyle.Builder() + .setTypefaceProvider { YandexSansTypefaceProvider(Robolectric.buildActivity(Activity::class.java).get()).regular } + .setColor(R.color.div_text_dark_disabled_80) + .setTextSizeDimenRes(R.dimen.div_style_text_size_text_m) + .setLetterSpacingDimenRes(R.dimen.div_style_text_letter_spacing_no) + .setLineSpaceExtra(R.dimen.div_style_text_line_space_extra_text_m) + .createTextStyle() + } + + private val activity = Robolectric.setupActivity(Activity::class.java) + private val textStyleProvider = mock() + private val imageLoader = mock() + private val divView = mock() + + private lateinit var builder: DivTableBlockViewBuilder + + @Before + fun setUp() { + whenever(textStyleProvider.getTextStyle(any())).thenReturn(TEXT_STYLE) + whenever(textStyleProvider.textM).thenReturn(TEXT_STYLE) + + builder = DivTableBlockViewBuilder(activity, + PseudoViewPool(), imageLoader, textStyleProvider) + } + + @Test(expected = AssertionError::class) + fun testInconsistentColumnsAndCellsFailed() { + val tableBlock = divTable { + columns { + column(); column(weight = 1); column() + } + rows { + row { + text(TEXT); text(TEXT) + } + } + } + + Assert.assertNull(builder.build(divView, tableBlock)) + } + + @Test(expected = AssertionError::class) + fun testInconsistentCellsInDifferentRowsFailed() { + val tableBlock = divTable { + rows { + row { + text(TEXT); text(TEXT) + } + row { + image(IMAGE_URL); text(TEXT); image(IMAGE_URL) + } + } + } + + Assert.assertNull(builder.build(divView, tableBlock)) + } + + @Test(expected = AssertionError::class) + fun testTableWithoutRowsFailed() { + val tableBlock = divTable { + rows {} + } + + Assert.assertNull(builder.build(divView, tableBlock)) + } + + @Test(expected = AssertionError::class) + fun testTableWithOnlySeparatorFailed() { + val tableBlock = divTable { + rows { + separator() + } + } + + Assert.assertNull(builder.build(divView, tableBlock)) + } + + @Test + fun testTableWithValidDataCreated() { + val tableBlock = divTable { + columns { + column(); column(weight = 1); column() + } + rows { + separator() + row { + text(TEXT); text(TEXT); text(TEXT) + } + separator() + row { + image(IMAGE_URL); text(TEXT); image(IMAGE_URL) + } + separator() + row { + text(TEXT); imageAndText(text = TEXT, imageUrl = IMAGE_URL); text(TEXT) + } + separator() + } + } + + Assert.assertNotNull(builder.build(divView, tableBlock)) + } + + @Test + fun testTableChildCount() { + val tableBlock = divTable { + rows { + row { + text(TEXT); text(TEXT); text(TEXT) + } + } + } + + val tableLayout = builder.build(divView, tableBlock) as FitTableLayout + Assert.assertEquals(3, tableLayout.childCount) + } + + @Test + fun testTableChildTypes() { + val tableBlock = divTable { + rows { + row { + text(TEXT); image(IMAGE_URL); imageAndText(text = TEXT, imageUrl = IMAGE_URL) + } + separator() + } + } + + val tableLayout = builder.build(divView, tableBlock) as FitTableLayout + Assert.assertTrue(tableLayout.getChildAt(0) is TextView) + Assert.assertTrue(tableLayout.getChildAt(1) is RatioImageView) + Assert.assertTrue(tableLayout.getChildAt(2) is LinearLayout) + Assert.assertTrue(tableLayout.getChildAt(3) is SeparatorView) + } + + + @Test + fun testCellAlignment() { + val tableBlock = divTable { + rows { + row { + text( + TEXT, + horizontalAlignment = DivAlignment.CENTER, + verticalAlignment = DivAlignmentVertical.BOTTOM + ) + } + } + } + + val tableLayout = builder.build(divView, tableBlock) as FitTableLayout + val textCell = tableLayout.getChildAt(0) as TextView + val layoutParams = textCell.layoutParams as FitTableLayout.LayoutParams + Assert.assertEquals(Gravity.CENTER_HORIZONTAL.or(Gravity.BOTTOM), layoutParams.gravity) + } + + @Test + fun testRowPaddings() { + val tableBlock = divTable { + rows { + row(topPadding = DivSize.XS, bottomPadding = DivSize.S) { + text(TEXT) + } + } + } + + val tableLayout = builder.build(divView, tableBlock) as FitTableLayout + val textCell = tableLayout.getChildAt(0) as TextView + val layoutParams = textCell.layoutParams as FitTableLayout.LayoutParams + Assert.assertEquals(8, layoutParams.topMargin) + Assert.assertEquals(12, layoutParams.bottomMargin) + } + + @Test + fun testColumnPaddings() { + val tableBlock = divTable { + columns { + column(leftPadding = DivSize.M, rightPadding = DivSize.L) + } + rows { + row { + text(TEXT) + } + } + } + + val tableLayout = builder.build(divView, tableBlock) as FitTableLayout + val textCell = tableLayout.getChildAt(0) as TextView + val layoutParams = textCell.layoutParams as FitTableLayout.LayoutParams + Assert.assertEquals(16, layoutParams.leftMargin) + Assert.assertEquals(20, layoutParams.rightMargin) + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/DivViewUtilsTest.java b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/DivViewUtilsTest.java new file mode 100644 index 000000000..db6990f7d --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/DivViewUtilsTest.java @@ -0,0 +1,159 @@ +package com.yandex.div.legacy.view; + +import android.app.Activity; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.view.Gravity; +import com.yandex.alicekit.core.widget.YandexSansTypefaceProvider; +import com.yandex.div.DivAlignment; +import com.yandex.div.DivAlignmentVertical; +import com.yandex.div.DivBackground; +import com.yandex.div.DivDataTag; +import com.yandex.div.DivGradientBackground; +import com.yandex.div.DivImageBackground; +import com.yandex.div.DivPosition; +import com.yandex.div.DivSolidBackground; +import com.yandex.div.DivTextStyle; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.legacy.Alignment; +import com.yandex.div.legacy.BackgroundImageDrawable; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.util.Position; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.util.ReflectionHelpers; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class DivViewUtilsTest { + + private static final String TEST_URI = "test://TEST_URI/test"; + + @Mock + private DivImageLoader mDivImageLoader; + + @Before + public void before() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void testDivBackgroundToBackground() { + final DivView divView = mock(DivView.class); + when(divView.getDivTag()).thenReturn(DivDataTag.INVALID); + + final DivBackground solidBackground = mock(DivBackground.class); + final DivBackground gradientBackground = mock(DivBackground.class); + final DivBackground imageBackground = mock(DivBackground.class); + + final DivBackground invalidBackground = mock(DivBackground.class); + + { // set up solidBackground + DivSolidBackground background = mock(DivSolidBackground.class); + when(solidBackground.asDivSolidBackground()).thenReturn(background); + ReflectionHelpers.setField(background, "color", Color.parseColor("blue")); + } + { // set up gradientBackground + DivGradientBackground background = mock(DivGradientBackground.class); + when(gradientBackground.asDivGradientBackground()).thenReturn(background); + ReflectionHelpers.setField(background, "startColor", Color.parseColor("red")); + ReflectionHelpers.setField(background, "endColor", Color.parseColor("black")); + } + { // set up imageBackground + DivImageBackground background = mock(DivImageBackground.class); + when(imageBackground.asDivImageBackground()).thenReturn(background); + ReflectionHelpers.setField(background, "imageUrl", Uri.parse("IMAGE URL")); + } + + { // check solid + Drawable drawable = DivViewUtils.divBackgroundToDrawable(solidBackground, mDivImageLoader, divView); + Assert.assertTrue(drawable instanceof ColorDrawable); + } + { // check gradient + Drawable drawable = DivViewUtils.divBackgroundToDrawable(gradientBackground, mDivImageLoader, divView); + Assert.assertTrue(drawable instanceof GradientDrawable); + } + { // check image + Drawable drawable = DivViewUtils.divBackgroundToDrawable(imageBackground, mDivImageLoader, divView); + Assert.assertTrue(drawable instanceof BackgroundImageDrawable); + } + + { // check invalid + Assert.assertNull(DivViewUtils.divBackgroundToDrawable(invalidBackground, mDivImageLoader, divView)); + } + } + + @Test + public void testGetUri() { + Assert.assertNull(DivViewUtils.getUri("")); + Assert.assertNull(DivViewUtils.getUri(null)); + Assert.assertEquals(Uri.parse(TEST_URI), DivViewUtils.getUri(TEST_URI)); + } + + @Test + public void testDivPositionToPosition() { + Assert.assertEquals(Position.LEFT, DivViewUtils.divPositionToPosition(null)); + Assert.assertEquals(Position.LEFT, DivViewUtils.divPositionToPosition(DivPosition.LEFT)); + Assert.assertEquals(Position.RIGHT, DivViewUtils.divPositionToPosition(DivPosition.RIGHT)); + } + + @Test + public void testDivAlignmentToAlignment() { + Assert.assertEquals(Alignment.LEFT, DivViewUtils.divAlignmentToAlignment(null)); + Assert.assertEquals(Alignment.LEFT, DivViewUtils.divAlignmentToAlignment(DivAlignment.LEFT)); + Assert.assertEquals(Alignment.RIGHT, DivViewUtils.divAlignmentToAlignment(DivAlignment.RIGHT)); + Assert.assertEquals(Alignment.CENTER, DivViewUtils.divAlignmentToAlignment(DivAlignment.CENTER)); + } + + @Test + public void testHorizontalAlignmentToGravity() { + Assert.assertEquals(Gravity.LEFT, DivViewUtils.horizontalAlignmentToGravity(null)); + Assert.assertEquals(Gravity.LEFT, DivViewUtils.horizontalAlignmentToGravity(DivAlignment.LEFT)); + Assert.assertEquals(Gravity.RIGHT, DivViewUtils.horizontalAlignmentToGravity(DivAlignment.RIGHT)); + Assert.assertEquals(Gravity.CENTER_HORIZONTAL, DivViewUtils.horizontalAlignmentToGravity(DivAlignment.CENTER)); + } + + @Test + public void testVerticalAlignmentToGravity() { + Assert.assertEquals(Gravity.CENTER_VERTICAL, DivViewUtils.verticalAlignmentToGravity(null)); + Assert.assertEquals(Gravity.CENTER_VERTICAL, DivViewUtils.verticalAlignmentToGravity(DivAlignmentVertical.CENTER)); + Assert.assertEquals(Gravity.TOP, DivViewUtils.verticalAlignmentToGravity(DivAlignmentVertical.TOP)); + Assert.assertEquals(Gravity.BOTTOM, DivViewUtils.verticalAlignmentToGravity(DivAlignmentVertical.BOTTOM)); + } + + @Test + public void testDivTextStyleToTextStyle() { + DivTextStyleProvider styleFactory = new DivTextStyleProvider( + new YandexSansTypefaceProvider(Robolectric.buildActivity(Activity.class).get())); + Assert.assertEquals(styleFactory.getTextS(), styleFactory.getTextStyle(DivTextStyle.TEXT_S)); + Assert.assertEquals(styleFactory.getTextM(), styleFactory.getTextStyle(DivTextStyle.TEXT_M)); + Assert.assertEquals(styleFactory.getTextL(), styleFactory.getTextStyle(DivTextStyle.TEXT_L)); + Assert.assertEquals(styleFactory.getTextMMedium(), styleFactory.getTextStyle(DivTextStyle.TEXT_M_MEDIUM)); + + Assert.assertEquals(styleFactory.getTitleS(), styleFactory.getTextStyle(DivTextStyle.TITLE_S)); + Assert.assertEquals(styleFactory.getTitleM(), styleFactory.getTextStyle(DivTextStyle.TITLE_M)); + Assert.assertEquals(styleFactory.getTitleL(), styleFactory.getTextStyle(DivTextStyle.TITLE_L)); + + Assert.assertEquals(styleFactory.getNumbersS(), styleFactory.getTextStyle(DivTextStyle.NUMBERS_S)); + Assert.assertEquals(styleFactory.getNumbersM(), styleFactory.getTextStyle(DivTextStyle.NUMBERS_M)); + Assert.assertEquals(styleFactory.getNumbersL(), styleFactory.getTextStyle(DivTextStyle.NUMBERS_L)); + + Assert.assertEquals(styleFactory.getCardHeader(), styleFactory.getTextStyle(DivTextStyle.CARD_HEADER)); + + Assert.assertEquals(styleFactory.getButton(), styleFactory.getTextStyle(DivTextStyle.BUTTON)); + + Assert.assertNotEquals(styleFactory.getTextS(), styleFactory.getTextStyle(DivTextStyle.TEXT_S, 5)); + Assert.assertNotEquals(styleFactory.getTextM(), styleFactory.getTextStyle(DivTextStyle.TEXT_M, 3)); + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/FitTableLayoutTest.java b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/FitTableLayoutTest.java new file mode 100644 index 000000000..143bcd83a --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/FitTableLayoutTest.java @@ -0,0 +1,504 @@ +package com.yandex.div.legacy.view; + +import android.app.Activity; +import android.content.Context; +import android.view.Gravity; +import android.view.View; +import android.view.View.MeasureSpec; +import androidx.annotation.NonNull; +import com.yandex.alicekit.core.utils.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class FitTableLayoutTest { + + private static final float FLOAT_COMPARISON_ERROR = 0.001f; + + private Context mContext; + private FitTableLayout mTableLayout; + + @Before + public void setUp() { + mContext = Robolectric.buildActivity(Activity.class).get(); + mTableLayout = new FitTableLayout(mContext); + } + + @Test + public void testExactCellSizeApplied() { + View cellView = createCellView(200, 40); + + mTableLayout.addView(cellView); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(60, MeasureSpec.EXACTLY)); + + Assert.assertEquals(200, cellView.getMeasuredWidth()); + Assert.assertEquals(40, cellView.getMeasuredHeight()); + } + + @Test + public void testTableWrapsContent() { + View cellView = createCellView(200, 40); + + mTableLayout.addView(cellView); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(60, MeasureSpec.AT_MOST)); + + Assert.assertEquals(200, mTableLayout.getMeasuredWidth()); + Assert.assertEquals(40, mTableLayout.getMeasuredHeight()); + } + + @Test + public void testPaddingApplied() { + View cellView1 = createCellView(40, 40); + View cellView2 = createCellView(40, 40); + + mTableLayout.setColumnCount(2); + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + mTableLayout.setPadding(10, 0, 10, 0); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(60, MeasureSpec.AT_MOST)); + + Assert.assertEquals(100, mTableLayout.getMeasuredWidth()); + Assert.assertEquals(40, mTableLayout.getMeasuredHeight()); + } + + @Test + public void testCellMarginsApplied() { + View cellView = createCellView(80, 40); + setMargins(cellView, 10, 4, 10, 4); + + mTableLayout.addView(cellView); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(60, MeasureSpec.AT_MOST)); + + Assert.assertEquals(100, mTableLayout.getMeasuredWidth()); + Assert.assertEquals(48, mTableLayout.getMeasuredHeight()); + } + + @Test + public void testMaxColumnHorizontalMarginsApplied() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(60, 40); + setMargins(cellView1, 10, 0, 10, 0); + setMargins(cellView2, 20, 0, 20, 0); + + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(120, mTableLayout.getMeasuredWidth()); + Assert.assertEquals(80, mTableLayout.getMeasuredHeight()); + } + + @Test + public void testMaxRowVerticalMarginsApplied() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(80, 40); + setMargins(cellView1, 0, 10, 0, 20); + setMargins(cellView2, 0, 20, 0, 10); + + mTableLayout.setColumnCount(2); + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(160, mTableLayout.getMeasuredWidth()); + Assert.assertEquals(80, mTableLayout.getMeasuredHeight()); + } + + @Test + public void testMaxColumnWidthApplied() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(160, 40); + + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(160, mTableLayout.getMeasuredWidth()); + } + + @Test + public void testMaxRowHeightApplied() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(80, 80); + + mTableLayout.setColumnCount(2); + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(80, mTableLayout.getMeasuredHeight()); + } + + @Test + public void testMaxColumnWithSpanWidthApplied() { + View cellView1_1 = createCellView(80, 40); + View cellView1_2 = createCellView(80, 40); + View cellView2_1 = createCellView(200, 60); + getLayoutParams(cellView2_1).span = 2; + + mTableLayout.setColumnCount(2); + mTableLayout.addView(cellView1_1); + mTableLayout.addView(cellView1_2); + mTableLayout.addView(cellView2_1); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(200, mTableLayout.getMeasuredWidth()); + } + + @Test + public void testSpannedCellWidthCorrectness() { + View cellView1_1 = createCellView(80, 40); + View cellView1_2 = createCellView(80, 40); + View cellView2_1 = createCellView(FitTableLayout.LayoutParams.MATCH_PARENT, 40); + getLayoutParams(cellView2_1).span = 2; + + mTableLayout.setColumnCount(2); + mTableLayout.addView(cellView1_1); + mTableLayout.addView(cellView1_2); + mTableLayout.addView(cellView2_1); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(160, cellView2_1.getMeasuredWidth()); + } + + @Test + public void testGoneChildIsNotAffectsGridSize() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(160, 60); + cellView2.setVisibility(View.GONE); + + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(80, mTableLayout.getMeasuredWidth()); + Assert.assertEquals(40, mTableLayout.getMeasuredHeight()); + } + + @Test + public void testCellSpanConstraintedByColumnCount() { + View cellView1_1 = createCellView(80, 40); + View cellView1_2 = createCellView(80, 40); + View cellView1_3 = createCellView(80, 40); + View cellView2_1 = createCellView(80, 40); + View cellView2_2 = createCellView(FitTableLayout.LayoutParams.MATCH_PARENT, 40); + getLayoutParams(cellView2_2).span = 10; + + mTableLayout.setColumnCount(3); + mTableLayout.addView(cellView1_1); + mTableLayout.addView(cellView1_2); + mTableLayout.addView(cellView1_3); + mTableLayout.addView(cellView2_1); + mTableLayout.addView(cellView2_2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(160, cellView2_2.getMeasuredWidth()); + } + + @Test + public void testWeightedColumnFillsFreeSpace() { + View cellView1 = createCellView(40, 40); + View cellView2 = createCellView(FitTableLayout.LayoutParams.MATCH_PARENT, 40); + View cellView3 = createCellView(40, 40); + getLayoutParams(cellView2).weight = 1; + + mTableLayout.setColumnCount(3); + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + mTableLayout.addView(cellView3); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(160, cellView2.getMeasuredWidth()); + } + + @Test + public void testWeightedColumnsProportionsPreserved() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(FitTableLayout.LayoutParams.MATCH_PARENT, 40); + getLayoutParams(cellView1).weight = 1; + getLayoutParams(cellView2).weight = 2; + + mTableLayout.setColumnCount(2); + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(160, cellView2.getMeasuredWidth()); + } + + @Test + public void testWeightedColumnsProportionsPreservedWithFreeSpace() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(FitTableLayout.LayoutParams.MATCH_PARENT, 40); + getLayoutParams(cellView1).weight = 1; + getLayoutParams(cellView2).weight = 2; + + mTableLayout.setColumnCount(2); + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(270, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(180, cellView2.getMeasuredWidth()); + } + + @Test + public void testMaxColumnWeightApplied() { + View cellView1_1 = createCellView(40, 40); + View cellView1_2 = createCellView(FitTableLayout.LayoutParams.MATCH_PARENT, 40); + View cellView1_3 = createCellView(40, 40); + View cellView1_4 = createCellView(FitTableLayout.LayoutParams.MATCH_PARENT, 40); + View cellView2_1 = createCellView(40, 40); + View cellView2_2 = createCellView(FitTableLayout.LayoutParams.MATCH_PARENT, 40); + View cellView2_3 = createCellView(40, 40); + View cellView2_4 = createCellView(FitTableLayout.LayoutParams.MATCH_PARENT, 40); + getLayoutParams(cellView1_2).weight = 1; + getLayoutParams(cellView1_4).weight = 1; + getLayoutParams(cellView2_2).weight = 1; + getLayoutParams(cellView2_4).weight = 3; + + mTableLayout.setColumnCount(4); + mTableLayout.addView(cellView1_1); + mTableLayout.addView(cellView1_2); + mTableLayout.addView(cellView1_3); + mTableLayout.addView(cellView1_4); + mTableLayout.addView(cellView2_1); + mTableLayout.addView(cellView2_2); + mTableLayout.addView(cellView2_3); + mTableLayout.addView(cellView2_4); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + + Assert.assertEquals(40, cellView1_2.getMeasuredWidth()); + Assert.assertEquals(40, cellView2_2.getMeasuredWidth()); + Assert.assertEquals(120, cellView1_4.getMeasuredWidth()); + Assert.assertEquals(120, cellView2_4.getMeasuredWidth()); + } + + @Test + public void testColumnLayout() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(80, 40); + + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + mTableLayout.onLayout(true, 0, mTableLayout.getMeasuredWidth(), 0, mTableLayout.getMeasuredHeight()); + + Assert.assertEquals(0, cellView2.getLeft()); + Assert.assertEquals(40, cellView2.getTop()); + } + + @Test + public void testRowLayout() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(80, 40); + + mTableLayout.setColumnCount(2); + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + mTableLayout.onLayout(true, 0, mTableLayout.getMeasuredWidth(), 0, mTableLayout.getMeasuredHeight()); + + Assert.assertEquals(80, cellView2.getLeft()); + Assert.assertEquals(0, cellView2.getTop()); + } + + @Test + public void testSpannedCellLayout() { + View cellView1_1 = createCellView(80, 40); + View cellView1_2 = createCellView(80, 40); + View cellView1_3 = createCellView(80, 40); + View cellView2_1 = createCellView(80, 40); + View cellView2_2 = createCellView(80, 40); + getLayoutParams(cellView2_1).span = 2; + + mTableLayout.setColumnCount(3); + mTableLayout.addView(cellView1_1); + mTableLayout.addView(cellView1_2); + mTableLayout.addView(cellView1_3); + mTableLayout.addView(cellView2_1); + mTableLayout.addView(cellView2_2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + mTableLayout.onLayout(true, 0, mTableLayout.getMeasuredWidth(), 0, mTableLayout.getMeasuredHeight()); + + Assert.assertEquals(160, cellView2_2.getLeft()); + Assert.assertEquals(40, cellView2_2.getTop()); + } + + @Test + public void testCellHorizontalGravityApplied() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(160, 40); + getLayoutParams(cellView1).gravity = Gravity.CENTER_HORIZONTAL; + + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + mTableLayout.onLayout(true, 0, mTableLayout.getMeasuredWidth(), 0, mTableLayout.getMeasuredHeight()); + + Assert.assertEquals(40, cellView1.getLeft()); + Assert.assertEquals(0, cellView1.getTop()); + } + + @Test + public void testCellVerticalGravityApplied() { + View cellView1 = createCellView(80, 40); + View cellView2 = createCellView(80, 60); + getLayoutParams(cellView1).gravity = Gravity.BOTTOM; + + mTableLayout.setColumnCount(2); + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(240, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + mTableLayout.onLayout(true, 0, mTableLayout.getMeasuredWidth(), 0, mTableLayout.getMeasuredHeight()); + + Assert.assertEquals(0, cellView1.getLeft()); + Assert.assertEquals(20, cellView1.getTop()); + } + + @Test + public void testDownscaleApplied() { + View cellView = createCellView(320, 40); + + mTableLayout.addView(cellView); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(160, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(40, MeasureSpec.EXACTLY)); + mTableLayout.onLayout(true, 0, mTableLayout.getMeasuredWidth(), 0, mTableLayout.getMeasuredHeight()); + + float expectedScale = 0.5f; + Assert.assertTrue(equalFloats(cellView.getScaleX(), expectedScale)); + Assert.assertTrue(equalFloats(cellView.getScaleY(), expectedScale)); + } + + @Test + public void testDownscaleAppliedToAllCellsInColumn() { + View cellView1 = createCellView(160, 40); + View cellView2 = createCellView(80, 40); + + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(80, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(100, MeasureSpec.AT_MOST)); + mTableLayout.onLayout(true, 0, mTableLayout.getMeasuredWidth(), 0, mTableLayout.getMeasuredHeight()); + + float expectedScale = 0.5f; + Assert.assertTrue(equalFloats(cellView1.getScaleX(), expectedScale)); + Assert.assertTrue(equalFloats(cellView1.getScaleY(), expectedScale)); + Assert.assertTrue(equalFloats(cellView2.getScaleX(), expectedScale)); + Assert.assertTrue(equalFloats(cellView2.getScaleY(), expectedScale)); + } + + @Test + public void testDownscaleAppliedToAllCellsInRow() { + View cellView1 = createCellView(160, 40); + View cellView2 = createCellView(80, 40); + + mTableLayout.setColumnCount(2); + mTableLayout.addView(cellView1); + mTableLayout.addView(cellView2); + + mTableLayout.onMeasure( + MeasureSpec.makeMeasureSpec(160, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(40, MeasureSpec.EXACTLY)); + mTableLayout.onLayout(true, 0, mTableLayout.getMeasuredWidth(), 0, mTableLayout.getMeasuredHeight()); + + float expectedScale = 0.6667f; + Assert.assertTrue(equalFloats(cellView1.getScaleX(), expectedScale)); + Assert.assertTrue(equalFloats(cellView1.getScaleY(), expectedScale)); + Assert.assertTrue(equalFloats(cellView2.getScaleX(), expectedScale)); + Assert.assertTrue(equalFloats(cellView2.getScaleY(), expectedScale)); + } + + @NonNull + private View createCellView(int widht, int height) { + View cellView = new View(mContext); + cellView.setLayoutParams(new FitTableLayout.LayoutParams(widht, height)); + return cellView; + } + + @NonNull + private static FitTableLayout.LayoutParams getLayoutParams(@NonNull View view) { + return (FitTableLayout.LayoutParams) view.getLayoutParams(); + } + + private static void setMargins(@NonNull View view, int left, int top, int right, int bottom) { + FitTableLayout.LayoutParams layoutParams = getLayoutParams(view); + layoutParams.leftMargin = left; + layoutParams.topMargin = top; + layoutParams.rightMargin = right; + layoutParams.bottomMargin = bottom; + } + + private static boolean equalFloats(float lhs, float rhs) { + return Math.abs(lhs - rhs) < FLOAT_COMPARISON_ERROR; + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/SeparatorDivViewBuilderTest.java b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/SeparatorDivViewBuilderTest.java new file mode 100644 index 000000000..d511bcc6c --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/SeparatorDivViewBuilderTest.java @@ -0,0 +1,60 @@ +package com.yandex.div.legacy.view; + +import android.app.Activity; +import android.graphics.Color; +import androidx.annotation.ColorInt; +import com.yandex.div.DivSeparatorBlock; +import com.yandex.div.DivSize; +import com.yandex.div.legacy.DivDataMockUtils; +import com.yandex.div.view.SeparatorView; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class SeparatorDivViewBuilderTest { + + @Mock + private DivView mDivView; + + private SeparatorDivViewBuilder mBuilder; + + @Before + public void before() { + MockitoAnnotations.initMocks(this); + + Activity activity = Robolectric.setupActivity(Activity.class); + when(mDivView.getContext()).thenReturn(activity); + + mBuilder = new SeparatorDivViewBuilder(); + } + + @Test + public void testSeparatorWithDivider() { + @ColorInt int delimiterColor = Color.BLACK; + @DivSize String size = DivSize.L; + + DivSeparatorBlock data = DivDataMockUtils.createDivSeparatorBlock(delimiterColor, true, size); + + SeparatorView view = (SeparatorView) mBuilder.build(mDivView, data); + Assert.assertTrue(view.getDividerThickness() > 0); + } + + @Test + public void testSeparatorWithoutDivider() { + @ColorInt int delimiterColor = Color.BLACK; + @DivSize String size = DivSize.L; + + DivSeparatorBlock data = DivDataMockUtils.createDivSeparatorBlock(delimiterColor, false, size); + + SeparatorView view = (SeparatorView) mBuilder.build(mDivView, data); + Assert.assertEquals(0, view.getDividerThickness()); + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/TitleDivBlockViewBuilderTest.java b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/TitleDivBlockViewBuilderTest.java new file mode 100644 index 000000000..bba01beaf --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/TitleDivBlockViewBuilderTest.java @@ -0,0 +1,138 @@ +package com.yandex.div.legacy.view; + +import android.app.Activity; +import android.graphics.Color; +import android.net.Uri; +import android.text.TextUtils; +import android.view.View; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.alicekit.core.widget.YandexSansTypefaceProvider; +import com.yandex.div.DivAction; +import com.yandex.div.DivPaddingModifier; +import com.yandex.div.DivPosition; +import com.yandex.div.DivSize; +import com.yandex.div.DivTextStyle; +import com.yandex.div.DivTitleBlock; +import com.yandex.div.legacy.DivAutoLogger; +import com.yandex.div.legacy.DivDataMockUtils; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.LegacyDivViewConfig; +import com.yandex.div.legacy.R; +import com.yandex.div.view.pooling.PseudoViewPool; +import java.util.Arrays; +import java.util.List; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.util.ReflectionHelpers; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class TitleDivBlockViewBuilderTest { + + private static final String TEXT = "SOME TEXT"; + private static final String MENU_COLOR = "blue"; + private static final String ACTION_URL = "ACTION URL"; + private static final String ACTION_LOGID = "ACTION LOGID"; + @DivTextStyle + private static final String TEXT_STYLE = DivTextStyle.CARD_HEADER; + @DivSize + private static final String PAD_TOP = DivSize.L; + + @Mock + private DivAutoLogger mDivAutoLogger; + @Mock + private LegacyDivViewConfig mDivConfig; + @Mock + private DivView mDivView; + + private final TextViewFactory mTextViewFactory = new DivLineHeightTextViewFactory(); + + private TitleDivBlockViewBuilder mBuilder; + + @Before + public void before() { + MockitoAnnotations.initMocks(this); + + Activity activity = Robolectric.setupActivity(Activity.class); + DivTextStyleProvider textStyleProvider = new DivTextStyleProvider(new YandexSansTypefaceProvider(activity)); + when(mDivView.getContext()).thenReturn(activity); + when(mDivView.getConfig()).thenReturn(mDivConfig); + when(mDivConfig.isContextMenuEnabled()).thenReturn(true); + + mBuilder = new TitleDivBlockViewBuilder(activity, new PseudoViewPool(), textStyleProvider, + mDivAutoLogger, mTextViewFactory); + } + + @Test + public void testEmptyData() { + final DivTitleBlock titleBlock = mock(DivTitleBlock.class); + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + final DivPaddingModifier paddingModifier = DivDataMockUtils.createPaddingModifier(PAD_TOP, DivPosition.LEFT); + setPrimaryData(titleBlock, null, TEXT_STYLE, MENU_COLOR, paddingModifier, divAction); + + Assert.assertNull(titleBlock.text); + View build = mBuilder.build(mDivView, titleBlock); + Assert.assertTrue(build instanceof TextView); + Assert.assertEquals("", ((TextView) build).getText()); + } + + @Test + public void testBindTextOnly() { + final DivTitleBlock titleBlock = mock(DivTitleBlock.class); + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + final DivPaddingModifier paddingModifier = DivDataMockUtils.createPaddingModifier(PAD_TOP, DivPosition.LEFT); + setPrimaryData(titleBlock, TEXT, TEXT_STYLE, MENU_COLOR, paddingModifier, divAction); + + View build = mBuilder.build(mDivView, titleBlock); + + Assert.assertTrue(build instanceof TextView); + Assert.assertEquals(TEXT, ((TextView) build).getText().toString()); + } + + @Test + public void testBindTextAndMenu() { + final DivTitleBlock titleBlock = mock(DivTitleBlock.class); + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + final DivPaddingModifier paddingModifier = DivDataMockUtils.createPaddingModifier(PAD_TOP, DivPosition.LEFT); + setPrimaryData(titleBlock, TEXT, TEXT_STYLE, MENU_COLOR, paddingModifier, divAction); + + final DivTitleBlock.MenuItem item1 = createMenuItem("TEXT1", "URL1"); + final DivTitleBlock.MenuItem item2 = createMenuItem("TEXT2", ""); + final DivTitleBlock.MenuItem item3 = createMenuItem("TEXT3", "URL3"); + final List items = Arrays.asList(item1, item2, item3); + ReflectionHelpers.setField(titleBlock, "menuItems", items); + + View build = mBuilder.build(mDivView, titleBlock); + + Assert.assertFalse(build instanceof TextView); + Assert.assertNotNull(build.findViewById(R.id.overflow_menu)); + Assert.assertEquals(TEXT, ((TextView) build.findViewById(R.id.div_title_text)).getText().toString()); + } + + private static void setPrimaryData(@NonNull final DivTitleBlock titleBlock, @Nullable final String text, + @NonNull @DivTextStyle final String textStyle, @NonNull final String menuColor, + @Nullable final DivPaddingModifier paddingModifier, @Nullable DivAction action) { + ReflectionHelpers.setField(titleBlock, "text", text); + ReflectionHelpers.setField(titleBlock, "textStyle", textStyle); + ReflectionHelpers.setField(titleBlock, "menuColor", Color.parseColor(menuColor)); + ReflectionHelpers.setField(titleBlock, "paddingModifier", paddingModifier); + ReflectionHelpers.setField(titleBlock, "action", action); + } + + private static DivTitleBlock.MenuItem createMenuItem(@NonNull final String text, @NonNull final String url) { + final DivTitleBlock.MenuItem menuItem = mock(DivTitleBlock.MenuItem.class); + ReflectionHelpers.setField(menuItem, "text", text); + ReflectionHelpers.setField(menuItem, "url", TextUtils.isEmpty(url) ? null : Uri.parse(url)); + return menuItem; + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/TrafficDivViewBuilderTest.java b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/TrafficDivViewBuilderTest.java new file mode 100644 index 000000000..4acc3aca0 --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/TrafficDivViewBuilderTest.java @@ -0,0 +1,163 @@ +package com.yandex.div.legacy.view; + +import android.app.Activity; +import android.graphics.Color; +import android.view.View; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; +import com.yandex.div.DivAction; +import com.yandex.div.DivAlignment; +import com.yandex.div.DivTrafficBlock; +import com.yandex.div.legacy.DivDataMockUtils; +import com.yandex.div.legacy.R; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.util.ReflectionHelpers; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class TrafficDivViewBuilderTest { + + private static final String ACTION_URL = "ACTION URL"; + private static final String ACTION_LOGID = "ACTION LOGID"; + @DivAlignment + private static final String ALIGNMENT = DivAlignment.RIGHT; + + @Mock + private DivView mDivView; + + private TrafficDivViewBuilder mTrafficDivViewBuilder; + + @Before + public void before() { + MockitoAnnotations.initMocks(this); + + Activity activity = Robolectric.setupActivity(Activity.class); + when(mDivView.getContext()).thenReturn(activity); + + mTrafficDivViewBuilder = new TrafficDivViewBuilder(activity); + } + + @Test + public void testEmptyData() { + final DivTrafficBlock trafficBlock = mock(DivTrafficBlock.class); + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + setData(trafficBlock, divAction, ALIGNMENT, Collections.emptyList()); + + final View build = mTrafficDivViewBuilder.build(mDivView, trafficBlock); + + Assert.assertNull(build); + } + + @Test + public void testIncorrectBlock() { + final DivTrafficBlock trafficBlock = mock(DivTrafficBlock.class); + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + + final DivTrafficBlock.Item item1 = createItem("blue", "", "text1"); + setData(trafficBlock, divAction, ALIGNMENT, Collections.singletonList(item1)); + + final View build = mTrafficDivViewBuilder.build(mDivView, trafficBlock); + + Assert.assertNull(build); + } + + @SuppressWarnings("unchecked") + @Test + public void testBind() { + final DivTrafficBlock trafficBlock = mock(DivTrafficBlock.class); + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + final DivTrafficBlock.Item item1 = createItem("blue", "12", "14"); + setData(trafficBlock, divAction, ALIGNMENT, Collections.singletonList(item1)); + + final View build = mTrafficDivViewBuilder.build(mDivView, trafficBlock); + + Assert.assertNotNull(build); + + RecyclerView recyclerView = build.findViewById(R.id.div_traffic_list); + RecyclerView.Adapter adapter = recyclerView.getAdapter(); + Assert.assertEquals(1, adapter.getItemCount()); + RecyclerView.ViewHolder viewHolder = adapter.onCreateViewHolder(recyclerView, 0); + adapter.bindViewHolder(viewHolder, 0); + + TextView score = viewHolder.itemView.findViewById(R.id.div_traffic_score); + Assert.assertEquals("12", score.getText().toString()); + TextView text = viewHolder.itemView.findViewById(R.id.div_traffic_text); + Assert.assertEquals("14", text.getText().toString()); + } + + @Test + public void testMultipleItems() { + final DivTrafficBlock trafficBlock = mock(DivTrafficBlock.class); + final DivAction divAction = DivDataMockUtils.createDivAction(ACTION_LOGID, ACTION_URL); + final DivTrafficBlock.Item item1 = createItem("blue", "so cool", "text1"); + final DivTrafficBlock.Item item2 = createItem("red", "such impressive", "text2"); + final DivTrafficBlock.Item item3 = createItem("black", "wow", "text3"); + setData(trafficBlock, divAction, ALIGNMENT, Arrays.asList(item1, item2, item3)); + + final View build = mTrafficDivViewBuilder.build(mDivView, trafficBlock); + + Assert.assertNotNull(build); + + RecyclerView recyclerView = build.findViewById(R.id.div_traffic_list); + RecyclerView.Adapter adapter = recyclerView.getAdapter(); + Assert.assertEquals(3, adapter.getItemCount()); + + // item 1 + RecyclerView.ViewHolder viewHolder = adapter.onCreateViewHolder(recyclerView, 0); + adapter.bindViewHolder(viewHolder, 0); + + TextView score = viewHolder.itemView.findViewById(R.id.div_traffic_score); + Assert.assertEquals("so cool", score.getText().toString()); + TextView text = viewHolder.itemView.findViewById(R.id.div_traffic_text); + Assert.assertEquals("text1", text.getText().toString()); + + // item 2 + viewHolder = adapter.onCreateViewHolder(recyclerView, 1); + adapter.bindViewHolder(viewHolder, 1); + + score = viewHolder.itemView.findViewById(R.id.div_traffic_score); + Assert.assertEquals("such impressive", score.getText().toString()); + text = viewHolder.itemView.findViewById(R.id.div_traffic_text); + Assert.assertEquals("text2", text.getText().toString()); + + // item 3 + viewHolder = adapter.onCreateViewHolder(recyclerView, 2); + adapter.bindViewHolder(viewHolder, 2); + + score = viewHolder.itemView.findViewById(R.id.div_traffic_score); + Assert.assertEquals("wow", score.getText().toString()); + text = viewHolder.itemView.findViewById(R.id.div_traffic_text); + Assert.assertEquals("text3", text.getText().toString()); + } + + @NonNull + private static DivTrafficBlock.Item createItem(@NonNull final String color, @NonNull final String score, @Nullable final String text) { + final DivTrafficBlock.Item item = mock(DivTrafficBlock.Item.class); + ReflectionHelpers.setField(item, "color", Color.parseColor(color)); + ReflectionHelpers.setField(item, "score", score); + ReflectionHelpers.setField(item, "text", text); + return item; + } + + private static void setData(@NonNull final DivTrafficBlock trafficBlock, @Nullable final DivAction action, + @NonNull @DivAlignment final String alignment, + @NonNull final List items) { + ReflectionHelpers.setField(trafficBlock, "action", action); + ReflectionHelpers.setField(trafficBlock, "alignment", alignment); + ReflectionHelpers.setField(trafficBlock, "items", items); + } +} diff --git a/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/UniversalDivViewBuilderTest.java b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/UniversalDivViewBuilderTest.java new file mode 100644 index 000000000..88871aa44 --- /dev/null +++ b/client/android/div-legacy/src/test/java/com/yandex/div/legacy/view/UniversalDivViewBuilderTest.java @@ -0,0 +1,209 @@ +package com.yandex.div.legacy.view; + +import android.app.Activity; +import android.net.Uri; +import android.view.View; +import android.widget.TextView; +import com.yandex.alicekit.core.utils.Views; +import com.yandex.alicekit.core.widget.YandexSansTypefaceProvider; +import com.yandex.div.DivDataTag; +import com.yandex.div.DivImageElement; +import com.yandex.div.DivPosition; +import com.yandex.div.DivSize; +import com.yandex.div.DivTextStyle; +import com.yandex.div.DivUniversalBlock; +import com.yandex.div.core.images.DivImageDownloadCallback; +import com.yandex.div.core.images.DivImageLoader; +import com.yandex.div.legacy.DivDataMockUtils; +import com.yandex.div.legacy.DivTextStyleProvider; +import com.yandex.div.legacy.R; +import com.yandex.div.view.pooling.PseudoViewPool; +import com.yandex.div.view.pooling.ViewPool; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class UniversalDivViewBuilderTest { + + private static final Uri IMAGE_URL = Uri.parse("https://image_url.ru"); + private static final String TEXT = "text"; + private static final String TITLE = "title"; + + @Mock + private DivImageLoader mImageLoader; + @Mock + private DivView mDivView; + + private final TextViewFactory mTextViewFactory = new DivLineHeightTextViewFactory(); + private final ViewPool mViewPool = new PseudoViewPool(); + + private UniversalDivViewBuilder mUniversalDivViewBuilder; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + + when(mDivView.getDivTag()).thenReturn(DivDataTag.INVALID); + + Activity activity = Robolectric.setupActivity(Activity.class); + DivTextStyleProvider textStyleProvider = new DivTextStyleProvider(new YandexSansTypefaceProvider(activity)); + mUniversalDivViewBuilder = new UniversalDivViewBuilder(activity, activity, mViewPool, + mImageLoader, textStyleProvider, mTextViewFactory); + } + + @Test + public void testBind_dateTextTitle() { + final DivUniversalBlock universalDivBlockData = DivDataMockUtils.createDivUniversalBlock( + TITLE, + DivTextStyle.TITLE_M, + null, + TEXT, + DivTextStyle.TEXT_S, + 5); + + DivDataMockUtils.addDateToDivUniversalBlock(universalDivBlockData, DivPosition.RIGHT, DivSize.M, "1", "2"); + + final View build = mUniversalDivViewBuilder.build(mDivView, universalDivBlockData); + Assert.assertNotNull(build); + + final TextView text = Views.findViewAndCast(build, R.id.div_universal_text); + Assert.assertEquals(TEXT, text.getText().toString()); + final TextView title = Views.findViewAndCast(build, R.id.div_universal_title); + Assert.assertEquals(TITLE, title.getText().toString()); + final TextView day = build.findViewById(R.id.div_universal_date_day); + Assert.assertEquals("1", day.getText().toString()); + final TextView month = build.findViewById(R.id.div_universal_date_month); + Assert.assertEquals("2", month.getText().toString()); + } + + @Test + public void testBind_imageTextTitle() { + final DivImageElement imageElement = DivDataMockUtils.createDivImageElement(IMAGE_URL, 1.f); + final DivUniversalBlock universalDivBlockData = DivDataMockUtils.createDivUniversalBlock( + TITLE, + DivTextStyle.TITLE_M, + null, + TEXT, + DivTextStyle.TEXT_S, + 5); + + DivDataMockUtils.addImageToDivUniversalBlock(universalDivBlockData, DivPosition.RIGHT, DivSize.M, imageElement); + + final View build = mUniversalDivViewBuilder.build(mDivView, universalDivBlockData); + verify(mImageLoader).loadImage(eq(IMAGE_URL.toString()), any(DivImageDownloadCallback.class)); + final TextView text = Views.findViewAndCast(build, R.id.div_universal_text); + Assert.assertEquals(TEXT, text.getText().toString()); + final TextView title = Views.findViewAndCast(build, R.id.div_universal_title); + Assert.assertEquals(TITLE, title.getText().toString()); + Assert.assertNotNull(build.findViewById(R.id.div_universal_image)); + + Assert.assertNull(build.findViewById(R.id.div_universal_date_day)); + Assert.assertNull(build.findViewById(R.id.div_universal_date_month)); + } + + @Test + public void testBind_textTitle() { + final DivUniversalBlock universalDivBlockData = DivDataMockUtils.createDivUniversalBlock( + TITLE, + DivTextStyle.TITLE_M, + null, + TEXT, + DivTextStyle.TEXT_S, + 5); + + final View build = mUniversalDivViewBuilder.build(mDivView, universalDivBlockData); + final TextView text = Views.findViewAndCast(build, R.id.div_universal_text); + Assert.assertEquals(TEXT, text.getText().toString()); + Assert.assertEquals(View.VISIBLE, text.getVisibility()); + final TextView title = Views.findViewAndCast(build, R.id.div_universal_title); + Assert.assertEquals(TITLE, title.getText().toString()); + Assert.assertEquals(View.VISIBLE, title.getVisibility()); + + Assert.assertNull(build.findViewById(R.id.div_universal_image)); + Assert.assertNull(build.findViewById(R.id.div_universal_date_day)); + Assert.assertNull(build.findViewById(R.id.div_universal_date_month)); + } + + @Test + public void testBind_title() { + final DivUniversalBlock universalDivBlockData = DivDataMockUtils.createDivUniversalBlock( + TITLE, + DivTextStyle.TITLE_M, + null, + null, + DivTextStyle.TEXT_S, + 5); + + final View build = mUniversalDivViewBuilder.build(mDivView, universalDivBlockData); + final TextView title = Views.findViewAndCast(build, R.id.div_universal_title); + Assert.assertEquals(TITLE, title.getText().toString()); + + Assert.assertNull(build.findViewById(R.id.div_universal_text)); + Assert.assertNull(build.findViewById(R.id.div_universal_image)); + Assert.assertNull(build.findViewById(R.id.div_universal_date_day)); + Assert.assertNull(build.findViewById(R.id.div_universal_date_month)); + } + + @Test + public void testBind_text() { + final DivUniversalBlock universalDivBlockData = DivDataMockUtils.createDivUniversalBlock( + null, + DivTextStyle.TITLE_M, + null, + TEXT, + DivTextStyle.TEXT_S, + 5); + + final View build = mUniversalDivViewBuilder.build(mDivView, universalDivBlockData); + final TextView text = Views.findViewAndCast(build, R.id.div_universal_text); + Assert.assertEquals(TEXT, text.getText().toString()); + + Assert.assertNull(build.findViewById(R.id.div_universal_title)); + Assert.assertNull(build.findViewById(R.id.div_universal_image)); + Assert.assertNull(build.findViewById(R.id.div_universal_date_day)); + Assert.assertNull(build.findViewById(R.id.div_universal_date_month)); + } + + @Test + public void testTitleMaxLines() { + int titleMaxLines = 10; + final DivUniversalBlock universalDivBlockData = DivDataMockUtils.createDivUniversalBlock( + TITLE, + DivTextStyle.TITLE_M, + titleMaxLines, + TEXT, + DivTextStyle.TEXT_S, + null); + + final View build = mUniversalDivViewBuilder.build(mDivView, universalDivBlockData); + final TextView title = Views.findViewAndCast(build, R.id.div_universal_title); + Assert.assertEquals(titleMaxLines, title.getMaxLines()); + } + + @Test + public void testTextMaxLines() { + int textMaxLines = 10; + final DivUniversalBlock universalDivBlockData = DivDataMockUtils.createDivUniversalBlock( + TITLE, + DivTextStyle.TITLE_M, + null, + TEXT, + DivTextStyle.TEXT_S, + textMaxLines); + + final View build = mUniversalDivViewBuilder.build(mDivView, universalDivBlockData); + final TextView text = Views.findViewAndCast(build, R.id.div_universal_text); + Assert.assertEquals(textMaxLines, text.getMaxLines()); + } +} diff --git a/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/invalid_table.json b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/invalid_table.json new file mode 100644 index 000000000..ae4241b33 --- /dev/null +++ b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/invalid_table.json @@ -0,0 +1,37 @@ +{ + "states": [ + { + "state_id": 1, + "blocks": [ + { + "rows": [ + { + "cells": [ + { + "text": "", + "text_style": "numbers_l", + "horizontal_alignment": "left" + }, + { + "text": "", + "text_style": "numbers_l", + "horizontal_alignment": "left" + } + ], + "type": "row_element" + } + ], + "columns": [ + { + "right_padding": "l" + }, + { + "right_padding": "l" + } + ], + "type": "div-table-block" + } + ] + } + ] +} diff --git a/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/invalid_traffic.json b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/invalid_traffic.json new file mode 100644 index 000000000..f9a3653c8 --- /dev/null +++ b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/invalid_traffic.json @@ -0,0 +1,40 @@ +{ + "states": [ + { + "blocks": [ + { + "type": "div-universal-block" + }, + { + "items": [ + { + "text": "+25<\/font>" + }, + { + "color": "#ffce55", + "score": "", + "text": "21" + }, + { + "color": "#69a942", + "score": "", + "text": "22" + }, + { + "color": "#69a942", + "score": "", + "text": "23" + }, + { + "color": "#E45C57", + "score": "", + "text": "24" + } + ], + "type": "div-traffic-block" + } + ], + "state_id": 1 + } + ] +} diff --git a/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/only_separators.json b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/only_separators.json new file mode 100644 index 000000000..eba409d3c --- /dev/null +++ b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/only_separators.json @@ -0,0 +1,45 @@ +{ + "states": [ + { + "state_id": 1, + "blocks": [ + { + "has_delimiter": 1, + "delimiter_color": "#FF00FF00", + "action": { + "url": "https://afisha.yandex.ru/", + "log_id": "super_div/separator" + }, + "type": "div-separator-block" + }, + { + "has_delimiter": 1, + "delimiter_color": "#FF00FF00", + "action": { + "url": "https://afisha.yandex.ru/", + "log_id": "super_div/separator" + }, + "type": "div-separator-block" + }, + { + "has_delimiter": 1, + "delimiter_color": "#FF00FF00", + "action": { + "url": "https://afisha.yandex.ru/", + "log_id": "super_div/separator" + }, + "type": "div-separator-block" + }, + { + "has_delimiter": 1, + "delimiter_color": "#FF00FF00", + "action": { + "url": "https://afisha.yandex.ru/", + "log_id": "super_div/separator" + }, + "type": "div-separator-block" + } + ] + } + ] +} diff --git a/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/valid_table.json b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/valid_table.json new file mode 100644 index 000000000..b0d38b08c --- /dev/null +++ b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/valid_table.json @@ -0,0 +1,49 @@ +{ + "states": [ + { + "state_id": 1, + "blocks": [ + { + "rows": [ + { + "cells": [ + { + "text": "OSL<\/font>", + "text_style": "numbers_l", + "horizontal_alignment": "left" + }, + { + "image": { + "image_url": "https://pp.userapi.com/c836527/v836527128/50e5e/LqD_aSLzARQ.jpg", + "ratio": 4.22, + "type": "div-image-element" + }, + "image_size": "xl", + "horizontal_alignment": "center" + }, + { + "text": "LGW<\/font>", + "text_style": "numbers_l", + "horizontal_alignment": "left" + } + ], + "type": "row_element" + } + ], + "columns": [ + { + "right_padding": "l" + }, + { + "right_padding": "l" + }, + { + "weight": 0 + } + ], + "type": "div-table-block" + } + ] + } + ] +} diff --git a/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/valid_title_universal_buttons_footer.json b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/valid_title_universal_buttons_footer.json new file mode 100644 index 000000000..e7ac92c00 --- /dev/null +++ b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/valid_title_universal_buttons_footer.json @@ -0,0 +1,84 @@ +{ + "states": [ + { + "state_id": 1, + "blocks": [ + { + "text": "СУПЕР ДИВ", + "menu_items": [ + { + "text": "Открыть", + "url": "https://afisha.yandex.ru/" + }, + { + "text": "Скрыть карточку на 2 недели", + "url": "flipcard://?card_ids=div1" + }, + { + "text": "Dialog Action", + "url": "dialog-action://?directives=%5B%7B%22name%22%3A%22open_uri%22%2C%22payload%22%3A%7B%22uri%22%3A%22viewport%3A%5C%2F%5C%2F%3Flr%3D2%26noreask%3D1%26text%3Dtest%26viewport_id%3Dserp%22%7D%2C%22type%22%3A%22client_action%22%7D%5D" + } + ], + "action": { + "url": "https://afisha.yandex.ru/", + "log_id": "super_div/title" + }, + "type": "div-title-block" + }, + { + "title": "Новый альбом Solange: A Seat at the Table", + "text": "Rise", + "side_element": { + "element": { + "image_url": "https://st.kp.yandex.net/images/film_iphone/iphone360_470689.jpg", + "size": "m", + "type": "div-image-element" + }, + "size": "m", + "position": "left" + }, + "action": { + "url": "https://afisha.yandex.ru/", + "log_id": "super_div/body" + }, + "type": "div-universal-block" + }, + { + "has_delimiter": 1, + "delimiter_color": "#FF00FF00", + "action": { + "url": "https://afisha.yandex.ru/", + "log_id": "super_div/separator" + }, + "type": "div-separator-block" + }, + { + "items": [ + { + "text": "Открыть", + "action": { + "url": "https://afisha.yandex.ru/", + "log_id": "super_div/button_open" + } + }, + { + "text": "Закрыть", + "action": { + "url": "https://afisha.yandex.ru/", + "log_id": "super_div/button_close" + } + } + ], + "type": "div-buttons-block" + }, + { + "text": "До скорых встреч...", + "action": { + "url": "https://afisha.yandex.ru/" + }, + "type": "div-footer-block" + } + ] + } + ] +} diff --git a/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/valid_traffic.json b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/valid_traffic.json new file mode 100644 index 000000000..33f4ffd26 --- /dev/null +++ b/client/android/div-legacy/src/test/resources/com/yandex/div/legacy/valid_traffic.json @@ -0,0 +1,23 @@ +{ + "states": [ + { + "state_id": 1, + "blocks": [ + { + "items": [ + { + "score": "3", + "text": "+25<\/font>" + }, + { + "score": "3", + "text": "21", + "color": "#ffce55" + } + ], + "type": "div-traffic-block" + } + ] + } + ] +} diff --git a/client/android/div-legacy/src/test/resources/robolectric.properties b/client/android/div-legacy/src/test/resources/robolectric.properties new file mode 100644 index 000000000..a6a9d445e --- /dev/null +++ b/client/android/div-legacy/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +constants=com.yandex.div.core.BuildConfig +sdk=28 +application=com.yandex.div.legacy.TestApplication diff --git a/client/android/div-library.gradle b/client/android/div-library.gradle new file mode 100644 index 000000000..cec833253 --- /dev/null +++ b/client/android/div-library.gradle @@ -0,0 +1,3 @@ +apply plugin: 'com.android.library' + +apply from: "${buildscript.sourceFile.parent}/div-common.gradle" diff --git a/client/android/div-lottie/YAOWNERS b/client/android/div-lottie/YAOWNERS new file mode 100644 index 000000000..7caa24636 --- /dev/null +++ b/client/android/div-lottie/YAOWNERS @@ -0,0 +1,11 @@ +gulevsky@yandex-team.ru +tayrinn@yandex-team.ru +pkurchatov@yandex-team.ru +ttok@yandex-team.ru +i-ts@yandex-team.ru +jelyscs@yandex-team.ru +syntezzz@yandex-team.ru +anarkh@yandex-team.ru + +set noparent +set lock diff --git a/client/android/div-lottie/build.gradle b/client/android/div-lottie/build.gradle new file mode 100644 index 000000000..d2f1959f9 --- /dev/null +++ b/client/android/div-lottie/build.gradle @@ -0,0 +1,17 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +dependencies { + implementation project(path: ':assertion') + implementation project(path: ':div-data') + implementation project(path: ':div') + implementation project(path: ':div-json') + + implementation 'com.airbnb.android:lottie:5.2.0' + implementation "androidx.appcompat:appcompat:$versions.androidx.appcompat" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines" + + testImplementation "androidx.test:core:$versions.androidx.test" +} diff --git a/client/android/div-lottie/jacoco.excludes b/client/android/div-lottie/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-lottie/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-lottie/proguard-rules.pro b/client/android/div-lottie/proguard-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/client/android/div-lottie/src/main/AndroidManifest.xml b/client/android/div-lottie/src/main/AndroidManifest.xml new file mode 100644 index 000000000..1e5806e59 --- /dev/null +++ b/client/android/div-lottie/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieCompositionRepository.kt b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieCompositionRepository.kt new file mode 100644 index 000000000..67fd1be68 --- /dev/null +++ b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieCompositionRepository.kt @@ -0,0 +1,68 @@ +package com.yandex.div.lottie + +import android.content.Context +import android.net.Uri +import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.LottieCompositionFactory +import com.airbnb.lottie.LottieResult +import com.yandex.div.lottie.DivLottieRawResProvider.Companion.ASSET_SCHEME +import com.yandex.div.lottie.DivLottieRawResProvider.Companion.HTTPS_SCHEME +import com.yandex.div.lottie.DivLottieRawResProvider.Companion.HTTP_SCHEME +import com.yandex.div.lottie.DivLottieRawResProvider.Companion.RES_SCHEME +import org.json.JSONObject + +internal class DivLottieCompositionRepository( + private val rawResProvider: DivLottieRawResProvider, + private val networkCache: DivLottieNetworkCache +) { + + internal fun receiveLottieComposition( + data: LottieData, + context: Context + ): LottieResult { + return when (data) { + is LottieData.External -> receiveExternalComposition(context, data.url) + is LottieData.Embedded -> receiveEmbeddedComposition(data.json) + } + } + + internal fun preloadLottieComposition(url: String) { + when (Uri.parse(url).scheme) { + HTTP_SCHEME, HTTPS_SCHEME -> { + networkCache.cacheComposition(url) + } + } + } + + private fun receiveExternalComposition(context: Context, url: String): LottieResult { + return when (Uri.parse(url).scheme) { + HTTP_SCHEME, HTTPS_SCHEME -> { + networkCache.loadCached(url) + ?.let { LottieCompositionFactory.fromJsonStringSync(it, url) } + ?: LottieCompositionFactory.fromUrlSync(context, url, url) + } + RES_SCHEME -> { + val rawRes = rawResProvider.provideRes(url) + if (rawRes == null) { + LottieResult(IllegalArgumentException("Failed to map $url to internal resource")) + } else { + LottieCompositionFactory.fromRawResSync(context, rawRes, url) + } + } + ASSET_SCHEME -> { + val assetFileAddress = rawResProvider.provideAssetFile(url) + if (assetFileAddress == null) { + LottieResult(IllegalArgumentException("Failed to map $url to internal resource")) + } else { + LottieCompositionFactory.fromAssetSync(context, assetFileAddress) + } + } + else -> LottieResult(IllegalArgumentException("Failed to retrieve lottie json from $url")) + } + } + + @Suppress("DEPRECATION") + private fun receiveEmbeddedComposition(json: JSONObject): LottieResult { + return LottieCompositionFactory.fromJsonSync(json, Integer.toHexString(json.hashCode())) + } +} diff --git a/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieExtensionHandler.kt b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieExtensionHandler.kt new file mode 100644 index 000000000..0017ee71b --- /dev/null +++ b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieExtensionHandler.kt @@ -0,0 +1,204 @@ +package com.yandex.div.lottie + +import android.view.View +import com.airbnb.lottie.LottieComposition +import com.airbnb.lottie.LottieDrawable +import com.airbnb.lottie.LottieResult +import com.yandex.div.core.extension.DivExtensionHandler +import com.yandex.div.core.util.KAssert +import com.yandex.div.core.view2.Div2View +import com.yandex.div.core.widget.LoadableImageView +import com.yandex.div.json.expressions.ExpressionResolver +import com.yandex.div.json.mapNotNull +import com.yandex.div2.DivBase +import com.yandex.div2.DivGifImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +private const val EXTENSION_ID = "lottie" +private const val LOTTIE_PARAM_URL = "lottie_url" +private const val LOTTIE_PARAM_JSON = "lottie_json" +private const val LOTTIE_PARAM_REPEATS = "repeats" +private const val LOTTIE_PARAM_REPEAT_MODE = "repeat_mode" +private const val LOTTIE_PARAM_REPEAT_MODE_REVERSE = "reverse" +private const val LOTTIE_PARAM_REPEAT_MODE_RESTART = "restart" +private const val LOTTIE_PARAM_REPEAT_COUNT = "repeat_count" +private const val LOTTIE_PARAM_MIN_FRAME = "min_frame" +private const val LOTTIE_PARAM_MAX_FRAME = "max_frame" + +/** + * An extension handler for [EXTENSION_ID] div gif image. Important thing is you can use this extension + * over existing [DivGifImage] divs and not worry about backward compatibility, as this extension inherit all + * [DivGifImage] attributes and use [DivGifImage.gifUrl] as fallback. + */ +open class DivLottieExtensionHandler( + private val rawResProvider: DivLottieRawResProvider = DivLottieRawResProvider.STUB, + private val logger: DivLottieLogger = DivLottieLogger.STUB, + cache: DivLottieNetworkCache = DivLottieNetworkCache.STUB, +) : DivExtensionHandler { + + private val repo = DivLottieCompositionRepository(rawResProvider, cache) + + override fun preprocess(div: DivBase, expressionResolver: ExpressionResolver) { + val lottieUrl = div.extensions + ?.find { extension -> extension.id == EXTENSION_ID } + ?.params?.lottieUrl ?: return + repo.preloadLottieComposition(lottieUrl) + } + + override fun beforeBindView(divView: Div2View, view: View, div: DivBase) { + val divGifImageView = view as? LoadableImageView ?: return + if (divGifImageView.delegate !is LottieController) { + divGifImageView.delegate = LottieController(divGifImageView).apply { + enableMergePathsForKitKatAndAbove(true) + setImageAssetsFolder(rawResProvider.provideAssetFolder()) + } + } + } + + override fun matches(div: DivBase): Boolean { + if (div !is DivGifImage) { + return false + } + + return div.extensions?.any { extension -> + extension.id == EXTENSION_ID && extension.params?.run { + has(LOTTIE_PARAM_URL) || has(LOTTIE_PARAM_JSON) + } ?: false + } ?: false + } + + override fun bindView(divView: Div2View, view: View, div: DivBase) { + val params: JSONObject = div.extensions?.find { extension -> + return@find extension.id == EXTENSION_ID + }?.params ?: return logger.fail("failed to get extension params from extension $EXTENSION_ID") + val lottieView = view as? LoadableImageView + ?: return logger.fail("view is not instance of DivGifImageView") + val lottieController = lottieView.delegate as? LottieController + ?: return logger.fail("DivGifImageView delegate not instance of lottieController!") + + val lottieData = params.lottieData ?: run { + KAssert.fail { "Neither $LOTTIE_PARAM_URL nor $LOTTIE_PARAM_JSON found" } + return + } + + if (lottieData == lottieController.data) { + return + } + lottieController.clearComposition() + lottieController.data = lottieData + + lottieView.launchOnAttachedToWindow { + val result: LottieResult + withContext(Dispatchers.IO) { + result = repo.receiveLottieComposition(lottieData, view.context) + } + withContext(Dispatchers.Main) { + val composition: LottieComposition? = result.value + if (composition != null) { + logger.log("successfully received lotte composition for ${lottieData.description}") + lottieController.setComposition(composition) + + val repeatList = params.getRepeatList(composition) + + if (repeatList != null) { + lottieController.setupRepeatList(repeatList) + } else { + lottieController.setRepeatCount(params.repeatCount) + lottieController.setRepeatMode(params.repeatMode) + } + + lottieController.playAnimation() + } else { + logger.fail("failed to receive lotte composition on ${lottieData.description}", result.exception) + } + } + } + } + + private fun LottieController.setupRepeatList( + repeatList: List + ) { + var repeatIndex = 0 + var currentRepeat = repeatList.getOrNull(repeatIndex) + + val update: (LottieRepeat) -> Unit = { lottieRepeat -> + setRepeatCount(lottieRepeat.repeatCount) + setRepeatMode(lottieRepeat.repeatMode) + setMinFrame(lottieRepeat.minFrame) + setMaxFrame(lottieRepeat.maxFrame) + } + + currentRepeat?.let(update) + + setEndListener { + repeatIndex++ + + currentRepeat = repeatList.getOrNull(repeatIndex) + + currentRepeat?.let(update) + + resumeAnimation() + } + } + + private fun JSONObject.getRepeatList(composition: LottieComposition): List? { + return optJSONArray(LOTTIE_PARAM_REPEATS) + ?.mapNotNull { it as? JSONObject } + ?.map { repeat -> + val from = repeat.optInt(LOTTIE_PARAM_MIN_FRAME, -1) + val to = repeat.optInt(LOTTIE_PARAM_MAX_FRAME, -1) + + LottieRepeat( + repeat.repeatCount, + repeat.repeatMode, + if (from != -1) from else composition.startFrame.toInt(), + if (to != -1) to else composition.endFrame.toInt() + ) + } + } + + override fun unbindView(divView: Div2View, view: View, div: DivBase) { + val lottieView = view as? LoadableImageView + ?: return logger.fail("view is not instance of DivGifImageView") + val lottieController = lottieView.delegate as? LottieController + ?: return logger.fail("DivGifImageView delegate not instance of lottieController!") + lottieView.clearOnAttachedToWindowScope() + lottieController.clearComposition() + lottieController.data = null + } +} + +private val JSONObject.lottieData: LottieData? + get() { + lottieUrl?.let { url -> + return LottieData.External(url) + } + lottieJson?.let { json -> + return LottieData.Embedded(json) + } + return null + } + +private val JSONObject.lottieUrl: String? + get() = opt(LOTTIE_PARAM_URL) as? String + +private val JSONObject.lottieJson: JSONObject? + get() = optJSONObject(LOTTIE_PARAM_JSON) + +private val JSONObject.repeatCount: Int + get() { + if (this.has(LOTTIE_PARAM_REPEAT_COUNT)) { + return optInt(LOTTIE_PARAM_REPEAT_COUNT) + } + return 1 + } + +@LottieDrawable.RepeatMode +private val JSONObject.repeatMode: Int + get() = when (this.optString(LOTTIE_PARAM_REPEAT_MODE)) { + LOTTIE_PARAM_REPEAT_MODE_REVERSE -> LottieDrawable.REVERSE + LOTTIE_PARAM_REPEAT_MODE_RESTART -> LottieDrawable.RESTART + else -> LottieDrawable.RESTART + } diff --git a/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieLogger.kt b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieLogger.kt new file mode 100644 index 000000000..363c86940 --- /dev/null +++ b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieLogger.kt @@ -0,0 +1,15 @@ +package com.yandex.div.lottie + +interface DivLottieLogger { + + fun log(message: String) + + fun fail(message: String, throwable: Throwable? = null) + + companion object { + val STUB = object : DivLottieLogger { + override fun log(message: String) = Unit + override fun fail(message: String, throwable: Throwable?) = Unit + } + } +} diff --git a/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieNetworkCache.kt b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieNetworkCache.kt new file mode 100644 index 000000000..039fab714 --- /dev/null +++ b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieNetworkCache.kt @@ -0,0 +1,26 @@ +package com.yandex.div.lottie + +/** + * Network cache for [com.airbnb.lottie.LottieComposition] + */ +interface DivLottieNetworkCache { + + /** + * @returns lottie composition as json string if it was cached, + * null otherwise + */ + fun loadCached(url: String): String? + + /** + * Requests caching lottie composition from network + */ + fun cacheComposition(url: String) + + companion object { + @JvmStatic + val STUB = object : DivLottieNetworkCache { + override fun loadCached(url: String): String? = null + override fun cacheComposition(url: String) = Unit + } + } +} diff --git a/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieRawResProvider.kt b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieRawResProvider.kt new file mode 100644 index 000000000..5602820c9 --- /dev/null +++ b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieRawResProvider.kt @@ -0,0 +1,43 @@ +package com.yandex.div.lottie + +import androidx.annotation.RawRes +import com.airbnb.lottie.LottieDrawable +import com.yandex.div2.DivGifImage + +/** + * Implement this, if you planning on showing lottie jsons stored in /res/raw folder or in assets. + */ +interface DivLottieRawResProvider { + + /** + * @param url with scheme [RES_SCHEME], that was in lottie extension under [EXTENSION_PARAM_URL] param. + * @return raw res, or null if there isn't any. On null [DivLottieExtensionHandler] will + * use [DivGifImage.gifUrl] as fallback. + */ + @RawRes + fun provideRes(url: String): Int? + /** + * Please, consider using [provideRes] for local lottie animations, as it uses a hard reference to R. + * @param url with scheme [ASSET_SCHEME], that was in lottie extension under [EXTENSION_PARAM_URL] param. + * @return raw res, or null if there isn't any. On null [DivLottieExtensionHandler] will + * use [DivGifImage.gifUrl] as fallback. + */ + fun provideAssetFile(url: String): String? + + /** + * @see [LottieDrawable.imageAssetsFolder]. + */ + fun provideAssetFolder(): String? = "/" + + companion object { + const val HTTP_SCHEME = "http" + const val HTTPS_SCHEME = "https" + const val RES_SCHEME = "res" + const val ASSET_SCHEME = "asset" + + val STUB = object : DivLottieRawResProvider { + override fun provideRes(url: String): Int? = null + override fun provideAssetFile(url: String): String? = null + } + } +} diff --git a/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieUtils.kt b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieUtils.kt new file mode 100644 index 000000000..39e4686db --- /dev/null +++ b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/DivLottieUtils.kt @@ -0,0 +1,55 @@ +package com.yandex.div.lottie + +import android.view.View +import androidx.core.view.ViewCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +internal fun View.launchOnAttachedToWindow( + action: suspend CoroutineScope.() -> Unit +) { + val listener = ScopedOnAttachStateChangeListener(action) + if (ViewCompat.isAttachedToWindow(this)) { + listener.performActionInScope() + } + addOnAttachStateChangeListener(listener) + setTag(R.id.lottie_on_attach_to_window_listener, listener) +} + +internal fun View.clearOnAttachedToWindowScope() { + val listener = getTag(R.id.lottie_on_attach_to_window_listener) as? ScopedOnAttachStateChangeListener + if (listener != null) { + listener.clearScope() + removeOnAttachStateChangeListener(listener) + } +} + +private class ScopedOnAttachStateChangeListener( + val action: suspend CoroutineScope.() -> Unit +) : View.OnAttachStateChangeListener { + + private var attachScope: CoroutineScope? = null + + override fun onViewAttachedToWindow(view: View) { + performActionInScope() + } + + override fun onViewDetachedFromWindow(view: View) { + clearScope() + } + + fun performActionInScope() { + val scope = attachScope ?: CoroutineScope(Dispatchers.Unconfined) + scope.launch { + action() + } + attachScope = scope + } + + fun clearScope() { + attachScope?.cancel() + attachScope = null + } +} diff --git a/client/android/div-lottie/src/main/java/com/yandex/div/lottie/LottieController.kt b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/LottieController.kt new file mode 100644 index 000000000..6be3f103e --- /dev/null +++ b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/LottieController.kt @@ -0,0 +1,280 @@ +package com.yandex.div.lottie + +import android.animation.Animator +import android.annotation.SuppressLint +import android.graphics.drawable.Drawable +import android.os.Build +import android.view.View +import android.view.View.LAYER_TYPE_HARDWARE +import android.view.View.LAYER_TYPE_SOFTWARE +import androidx.annotation.MainThread +import com.airbnb.lottie.* +import com.yandex.div.core.widget.DivViewDelegate +import com.yandex.div.core.widget.LoadableImageView + +/** + * LottieAnimationView implements only happy flow scenario, so this is custom view + * derived both [LottieAnimationView] lottie animation rendering implementation and [DivGifImageView] + * aspect, scale, image stub/preview logic. + */ +internal class LottieController( + private val gifImageView: LoadableImageView, +) : DivViewDelegate { + + // LottieAnimationView fields + private val lottieDrawable = LottieDrawable() + private var composition: LottieComposition? = null + private var isInitialized: Boolean + private var renderMode = RenderMode.AUTOMATIC + private var ignoreUnschedule: Boolean = false + private var playAnimationWhenShown = false + private var wasAnimatingWhenNotShown = false + private var wasAnimatingWhenDetached = false + private var autoPlay = false + private var buildDrawingCacheDepth = 0 + + // Div Lottie Extension fields + var data: LottieData? = null + + private var onEndListener: (() -> Unit)? = null + + private val animatorListener = object : Animator.AnimatorListener { + override fun onAnimationStart(animation: Animator) = Unit + override fun onAnimationEnd(animation: Animator) { + onEndListener?.invoke() + } + override fun onAnimationCancel(animation: Animator) = Unit + override fun onAnimationRepeat(animation: Animator) = Unit + } + + // From here goes LottieAnimationView implementations. Logic that do not serve LottieComposition drawing + // purposes was cut off, everything else left untouched. + init { + enableMergePathsForKitKatAndAbove(true) + enableOrDisableHardwareLayer() + isInitialized = true + } + + override fun unscheduleDrawable(who: Drawable?) { + if (!ignoreUnschedule && who === lottieDrawable && lottieDrawable.isAnimating) { + pauseAnimation() + } else if (!ignoreUnschedule && who is LottieDrawable && who.isAnimating) { + who.pauseAnimation() + } + } + + override fun invalidateDrawable(dr: Drawable): Drawable { + return if (gifImageView.drawable === lottieDrawable) lottieDrawable else dr + } + + fun setEndListener(listener: (() -> Unit)?) { + onEndListener = listener + } + + override fun onVisibilityChanged(changedView: View, visibility: Int): Boolean { + if (!isInitialized) { + return true + } + if (gifImageView.isShown) { + if (wasAnimatingWhenNotShown) { + resumeAnimation() + } else if (playAnimationWhenShown) { + playAnimation() + } + wasAnimatingWhenNotShown = false + playAnimationWhenShown = false + } else { + if (isAnimating()) { + pauseAnimation() + wasAnimatingWhenNotShown = true + } + } + return true + } + + override fun onAttachedToWindow() { + lottieDrawable.addAnimatorListener(animatorListener) + if (!gifImageView.isInEditMode && (autoPlay || wasAnimatingWhenDetached)) { + playAnimation() + autoPlay = false + wasAnimatingWhenDetached = false + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + onVisibilityChanged(gifImageView, gifImageView.visibility) + } + } + + override fun onDetachedFromWindow() { + lottieDrawable.removeAnimatorListener(animatorListener) + if (isAnimating()) { + cancelAnimation() + wasAnimatingWhenDetached = true + } + } + + override fun buildDrawingCache(autoScale: Boolean) { + buildDrawingCacheDepth++ + if (buildDrawingCacheDepth == 1 + && gifImageView.width > 0 + && gifImageView.height > 0 + && gifImageView.layerType == LAYER_TYPE_SOFTWARE + && gifImageView.getDrawingCache(autoScale) == null + ) { + setRenderMode(RenderMode.HARDWARE) + } + buildDrawingCacheDepth-- + } + + fun setIgnoreDisabledSystemAnimations(ignore: Boolean) { + lottieDrawable.setIgnoreDisabledSystemAnimations(ignore) + } + + fun enableMergePathsForKitKatAndAbove(enable: Boolean) { + lottieDrawable.enableMergePathsForKitKatAndAbove(enable) + } + + fun setImageAssetsFolder(imageAssetsFolder: String?) { + lottieDrawable.setImagesAssetsFolder(imageAssetsFolder) + } + + fun setComposition(composition: LottieComposition) { + lottieDrawable.callback = gifImageView + gifImageView.externalImage = lottieDrawable + this.composition = composition + ignoreUnschedule = true + val isNewComposition = lottieDrawable.setComposition(composition) + ignoreUnschedule = false + enableOrDisableHardwareLayer() + if (gifImageView.drawable === lottieDrawable && !isNewComposition) { + return + } else if (!isNewComposition) { + setLottieDrawable() + } + onVisibilityChanged(gifImageView, gifImageView.visibility) + gifImageView.requestLayout() + } + + fun clearComposition() { + composition = null + lottieDrawable.clearComposition() + gifImageView.externalImage = null + gifImageView.setImageDrawable(null) + } + + @MainThread + fun playAnimation() { + if (gifImageView.isShown) { + lottieDrawable.playAnimation() + enableOrDisableHardwareLayer() + } else { + playAnimationWhenShown = true + } + } + + @MainThread + fun resumeAnimation() { + if (gifImageView.isShown) { + lottieDrawable.resumeAnimation() + enableOrDisableHardwareLayer() + } else { + playAnimationWhenShown = false + wasAnimatingWhenNotShown = true + } + } + + fun setRepeatMode(@LottieDrawable.RepeatMode mode: Int) { + lottieDrawable.repeatMode = mode + } + + fun setMinFrame(minFrame: Int) { + lottieDrawable.setMinFrame(minFrame) + } + + fun setMaxFrame(maxFrame: Int) { + lottieDrawable.setMaxFrame(maxFrame) + } + + @LottieDrawable.RepeatMode + fun getRepeatMode(): Int { + return lottieDrawable.repeatMode + } + + fun setRepeatCount(count: Int) { + lottieDrawable.repeatCount = count + } + + fun getRepeatCount(): Int { + return lottieDrawable.repeatCount + } + + fun isAnimating(): Boolean = lottieDrawable.isAnimating + + @MainThread + fun cancelAnimation() { + wasAnimatingWhenDetached = false + wasAnimatingWhenNotShown = false + playAnimationWhenShown = false + lottieDrawable.cancelAnimation() + enableOrDisableHardwareLayer() + } + + @MainThread + fun pauseAnimation() { + autoPlay = false + wasAnimatingWhenDetached = false + wasAnimatingWhenNotShown = false + playAnimationWhenShown = false + lottieDrawable.pauseAnimation() + enableOrDisableHardwareLayer() + } + + fun setSafeMode(safeMode: Boolean) { + lottieDrawable.setSafeMode(safeMode) + } + + fun setRenderMode(renderMode: RenderMode) { + this.renderMode = renderMode + enableOrDisableHardwareLayer() + } + + fun setApplyingOpacityToLayersEnabled(isApplyingOpacityToLayersEnabled: Boolean) { + lottieDrawable.isApplyingOpacityToLayersEnabled = isApplyingOpacityToLayersEnabled + } + + @SuppressLint("RestrictedApi") + private fun enableOrDisableHardwareLayer() { + var layerType = LAYER_TYPE_SOFTWARE + val composition = this.composition + when (renderMode) { + RenderMode.HARDWARE -> layerType = LAYER_TYPE_HARDWARE + RenderMode.SOFTWARE -> layerType = LAYER_TYPE_SOFTWARE + RenderMode.AUTOMATIC -> { + var useHardwareLayer = true + if (composition != null && composition.hasDashPattern() && Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + useHardwareLayer = false + } else if (composition != null && composition.maskAndMatteCount > 4) { + useHardwareLayer = false + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N || Build.VERSION.SDK_INT == Build.VERSION_CODES.N_MR1) { + useHardwareLayer = false + } + layerType = if (useHardwareLayer) LAYER_TYPE_HARDWARE else LAYER_TYPE_SOFTWARE + } + } + if (layerType != gifImageView.layerType) { + gifImageView.setLayerType(layerType, null) + } + } + + private fun setLottieDrawable() { + val wasAnimating = isAnimating() + // Set the drawable to null first because the underlying LottieDrawable's intrinsic bounds can change + // if the composition changes. + gifImageView.setImageDrawable(null) + gifImageView.setImageDrawable(lottieDrawable) + gifImageView.imageLoaded() + if (wasAnimating) { + // This is necessary because lottieDrawable will get unscheduled and canceled when the drawable is set to null. + lottieDrawable.resumeAnimation() + } + } +} diff --git a/client/android/div-lottie/src/main/java/com/yandex/div/lottie/LottieData.kt b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/LottieData.kt new file mode 100644 index 000000000..386064534 --- /dev/null +++ b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/LottieData.kt @@ -0,0 +1,16 @@ +package com.yandex.div.lottie + +import org.json.JSONObject + +internal sealed class LottieData { + + abstract val description: String + + data class Embedded(val json: JSONObject) : LottieData() { + override val description = "Embedded Lottie JSON" + } + + data class External(val url: String) : LottieData() { + override val description = url + } +} diff --git a/client/android/div-lottie/src/main/java/com/yandex/div/lottie/LottieRepeat.kt b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/LottieRepeat.kt new file mode 100644 index 000000000..c4c075699 --- /dev/null +++ b/client/android/div-lottie/src/main/java/com/yandex/div/lottie/LottieRepeat.kt @@ -0,0 +1,8 @@ +package com.yandex.div.lottie + +class LottieRepeat( + val repeatCount: Int, + val repeatMode: Int, + val minFrame: Int, + val maxFrame: Int +) diff --git a/client/android/div-lottie/src/main/res/values/ids.xml b/client/android/div-lottie/src/main/res/values/ids.xml new file mode 100644 index 000000000..6fbfd0f5a --- /dev/null +++ b/client/android/div-lottie/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + diff --git a/client/android/div-lottie/src/test/resources/robolectric.properties b/client/android/div-lottie/src/test/resources/robolectric.properties new file mode 100644 index 000000000..932b01b9e --- /dev/null +++ b/client/android/div-lottie/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/client/android/div-pinch-to-zoom/YAOWNERS b/client/android/div-pinch-to-zoom/YAOWNERS new file mode 100644 index 000000000..a47a61bbe --- /dev/null +++ b/client/android/div-pinch-to-zoom/YAOWNERS @@ -0,0 +1,10 @@ +gulevsky@yandex-team.ru +tayrinn@yandex-team.ru +pkurchatov@yandex-team.ru +ttok@yandex-team.ru +i-ts@yandex-team.ru +jelyscs@yandex-team.ru +syntezzz@yandex-team.ru + +set noparent +set lock diff --git a/client/android/div-pinch-to-zoom/build.gradle b/client/android/div-pinch-to-zoom/build.gradle new file mode 100644 index 000000000..02f244fde --- /dev/null +++ b/client/android/div-pinch-to-zoom/build.gradle @@ -0,0 +1,17 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +dependencies { + implementation project(path: ':assertion') + implementation project(path: ':div') + implementation project(path: ':div-data') + implementation project(path: ':div-json') + implementation project(path: ':utils') + + implementation "androidx.appcompat:appcompat:$versions.androidx.appcompat" + implementation "androidx.core:core:$versions.androidx.core" + implementation "androidx.core:core-ktx:$versions.androidx.core_ktx" + + testImplementation "androidx.test:core:$versions.androidx.test" +} diff --git a/client/android/div-pinch-to-zoom/jacoco.excludes b/client/android/div-pinch-to-zoom/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-pinch-to-zoom/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-pinch-to-zoom/proguard-rules.pro b/client/android/div-pinch-to-zoom/proguard-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/client/android/div-pinch-to-zoom/src/main/AndroidManifest.xml b/client/android/div-pinch-to-zoom/src/main/AndroidManifest.xml new file mode 100644 index 000000000..bd2056615 --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/DivPinchToZoomConfiguration.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/DivPinchToZoomConfiguration.kt new file mode 100644 index 000000000..61d256e1a --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/DivPinchToZoomConfiguration.kt @@ -0,0 +1,48 @@ +package com.yandex.div.zoom + +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.view.Window +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.Interpolator +import androidx.annotation.ColorInt + +class DivPinchToZoomConfiguration private constructor( + internal val context: Context, + internal val imageHost: ImageHost, + internal val dimColor: Int, + internal val animationInterpolator: Interpolator +) { + + class Builder(private val activity: Activity) { + + private var host: ImageHost? = null + private var dimColor: Int = Color.TRANSPARENT + private var animationInterpolator: Interpolator? = null + + fun host(window: Window): Builder { + host = WindowImageHost(window) + return this + } + + fun dimColor(@ColorInt color: Int): Builder { + dimColor = color + return this + } + + fun animationInterpolator(interpolator: Interpolator): Builder { + animationInterpolator = interpolator + return this + } + + fun build(): DivPinchToZoomConfiguration { + return DivPinchToZoomConfiguration( + context = activity, + imageHost = host ?: WindowImageHost(activity.window), + dimColor = dimColor, + animationInterpolator = animationInterpolator ?: AccelerateDecelerateInterpolator() + ) + } + } +} diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/DivPinchToZoomExtensionHandler.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/DivPinchToZoomExtensionHandler.kt new file mode 100644 index 000000000..21c48848d --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/DivPinchToZoomExtensionHandler.kt @@ -0,0 +1,40 @@ +package com.yandex.div.zoom + +import android.view.View +import com.yandex.div.core.extension.DivExtensionHandler +import com.yandex.div.core.view2.Div2View +import com.yandex.div2.DivBase +import com.yandex.div2.DivImage + +class DivPinchToZoomExtensionHandler( + private val configuration: DivPinchToZoomConfiguration +) : DivExtensionHandler { + + private val viewController = ZoomViewController(configuration) + + override fun matches(div: DivBase): Boolean { + if (div !is DivImage) { + return false + } + + val extension = div.extensions?.find { extension -> + extension.id == EXTENSION_ID + } + return extension != null + } + + override fun bindView(divView: Div2View, view: View, div: DivBase) { + val touchListener = ZoomTouchListener( + ZoomTouchController(configuration, viewController) + ) + view.setOnTouchListener(touchListener) + } + + override fun unbindView(divView: Div2View, view: View, div: DivBase) { + view.setOnTouchListener(null) + } + + private companion object { + private const val EXTENSION_ID = "pinch-to-zoom" + } +} diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ImageHost.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ImageHost.kt new file mode 100644 index 000000000..d1bebe317 --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ImageHost.kt @@ -0,0 +1,10 @@ +package com.yandex.div.zoom + +import android.widget.ImageView + +internal interface ImageHost { + + fun addImage(imageView: ImageView) + + fun removeImage(imageView: ImageView) +} diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/MotionEvents.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/MotionEvents.kt new file mode 100644 index 000000000..39c386517 --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/MotionEvents.kt @@ -0,0 +1,21 @@ +package com.yandex.div.zoom + +import android.graphics.PointF +import android.view.MotionEvent + +internal fun MotionEvent.focalPoint(outPoint: PointF) { + val isPointerUpAction = actionMasked == MotionEvent.ACTION_POINTER_UP + val activePointerCount = if (isPointerUpAction) pointerCount - 1 else pointerCount + var sumX = 0.0f; + var sumY = 0.0f; + + for (index in 0 until pointerCount) { + if (isPointerUpAction && actionIndex == index) { + continue + } + sumX += getX(index) + sumY += getY(index) + } + + outPoint.set(sumX / activePointerCount, sumY / activePointerCount) +} diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/Views.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/Views.kt new file mode 100644 index 000000000..98121ffc5 --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/Views.kt @@ -0,0 +1,19 @@ +package com.yandex.div.zoom + +import android.graphics.Bitmap +import android.view.View +import androidx.core.graphics.applyCanvas + +internal val View.isActuallyLaidOut: Boolean + get() = width > 0 && height > 0 + +internal fun View.drawToBitmap(config: Bitmap.Config = Bitmap.Config.ARGB_8888): Bitmap { + if (!isActuallyLaidOut) { + throw IllegalStateException("View needs to be laid out before calling drawToBitmap()") + } + + return Bitmap.createBitmap(width, height, config).applyCanvas { + translate(-scrollX.toFloat(), -scrollY.toFloat()) + draw(this) + } +} diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/WindowImageHost.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/WindowImageHost.kt new file mode 100644 index 000000000..612d29d85 --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/WindowImageHost.kt @@ -0,0 +1,21 @@ +package com.yandex.div.zoom + +import android.view.ViewGroup +import android.view.Window +import android.widget.ImageView + +internal class WindowImageHost( + private val window: Window +) : ImageHost { + + private val decorView + get() = window.decorView as ViewGroup + + override fun addImage(imageView: ImageView) { + decorView.addView(imageView) + } + + override fun removeImage(imageView: ImageView) { + decorView.removeView(imageView) + } +} diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomModel.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomModel.kt new file mode 100644 index 000000000..0efba8166 --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomModel.kt @@ -0,0 +1,59 @@ +package com.yandex.div.zoom + +import android.graphics.Matrix +import android.graphics.PointF +import androidx.annotation.FloatRange + +internal class ZoomModel { + + private val location = PointF() + private val pivotPoint = PointF() + private val matrix = Matrix() + + private var scale = MIN_SCALE + private var translateX = 0.0f + private var translateY = 0.0f + + fun prepare(location: PointF, pivotPoint: PointF) { + this.location.set(location) + this.pivotPoint.set(pivotPoint) + + scale = MIN_SCALE + translateX = 0.0f + translateY = 0.0f + } + + fun scaleBy(scaleFactor: Float) { + scale = (scale * scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE) + } + + fun translateBy(dx: Float, dy: Float) { + translateX += dx + translateY += dy + } + + fun scale(@FloatRange(from = 0.0, to = 1.0) progress: Float = 1.0f): Float { + if (progress == 1.0f) { + return scale + } + return MIN_SCALE + (scale - MIN_SCALE) * progress + } + + fun transformMatrix(@FloatRange(from = 0.0, to = 1.0) progress: Float = 1.0f): Matrix { + val scaleProgress = scale(progress) + val translateSensitivity = ((scaleProgress - MIN_SCALE) / (PAN_SCALE - MIN_SCALE)).coerceAtMost(1.0f) + + matrix.setScale(scaleProgress, scaleProgress, pivotPoint.x, pivotPoint.y) + matrix.postTranslate( + location.x + translateX * translateSensitivity * progress, + location.y + translateY * translateSensitivity * progress + ) + return matrix + } + + private companion object { + private const val MIN_SCALE = 1.0f + private const val MAX_SCALE = 4.0f + private const val PAN_SCALE = 2.0f + } +} diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomState.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomState.kt new file mode 100644 index 000000000..7efe8582c --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomState.kt @@ -0,0 +1,7 @@ +package com.yandex.div.zoom + +enum class ZoomState { + IDLE, + ACTIVE, + EXITING +} diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomTouchController.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomTouchController.kt new file mode 100644 index 000000000..fae464ec9 --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomTouchController.kt @@ -0,0 +1,96 @@ +package com.yandex.div.zoom + +import android.content.Context +import android.graphics.PointF +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import com.yandex.div.core.view2.divs.widgets.TransientView + +internal class ZoomTouchController( + private val configuration: DivPinchToZoomConfiguration, + private val viewController: ZoomViewController +) { + + val isInZoom: Boolean + get() = viewController.state != ZoomState.IDLE + + private val context: Context + get() = configuration.context + + private val gestureDetector = ScaleGestureDetector(context, OnGestureListenerImpl()) + private val initialFocalPoint = PointF() + private val focalPoint = PointF() + + private val tempPoint = PointF() + private val tempIntArray = IntArray(2) + + fun startZoomTouch(sourceView: View, event: MotionEvent): Boolean { + if (isInZoom) { + return false + } + + gestureDetector.onTouchEvent(event) + event.focalPoint(initialFocalPoint) + focalPoint.set(initialFocalPoint) + + sourceView.getLocationInWindow(tempIntArray) + tempPoint.set(tempIntArray[INDEX_X].toFloat(), tempIntArray[INDEX_Y].toFloat()) + (sourceView as? TransientView)?.isTransient = true + viewController.showImage( + location = tempPoint, + pivotPoint = initialFocalPoint, + imageBitmap = sourceView.drawToBitmap() + ) + (sourceView as? TransientView)?.isTransient = false + + sourceView.parent?.requestDisallowInterceptTouchEvent(true) + sourceView.visibility = View.INVISIBLE + + return true + } + + fun updateZoomTouch(event: MotionEvent) { + gestureDetector.onTouchEvent(event) + + if (viewController.state != ZoomState.ACTIVE) { + return + } + + tempPoint.set(focalPoint) + event.focalPoint(focalPoint) + viewController.translateImageBy(focalPoint.x - tempPoint.x, focalPoint.y - tempPoint.y) + } + + fun finishZoomTouch(sourceView: View, event: MotionEvent) { + if (viewController.state != ZoomState.ACTIVE) { + return + } + + gestureDetector.onTouchEvent(event) + viewController.hideImage { + sourceView.visibility = View.VISIBLE + } + } + + private inner class OnGestureListenerImpl : ScaleGestureDetector.OnScaleGestureListener { + + + override fun onScaleBegin(detector: ScaleGestureDetector) = true + + override fun onScale(detector: ScaleGestureDetector): Boolean { + if (viewController.state != ZoomState.ACTIVE) { + return true + } + viewController.scaleImageBy(detector.scaleFactor) + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) = Unit + } + + private companion object { + private const val INDEX_X = 0 + private const val INDEX_Y = 1 + } +} diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomTouchListener.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomTouchListener.kt new file mode 100644 index 000000000..c307de9b2 --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomTouchListener.kt @@ -0,0 +1,63 @@ +package com.yandex.div.zoom + +import android.view.MotionEvent +import android.view.View +import com.yandex.div.core.util.KLog + +internal class ZoomTouchListener( + private val touchController: ZoomTouchController +) : View.OnTouchListener { + + override fun onTouch(view: View, event: MotionEvent): Boolean { + KLog.d(TAG) { "onTouch(event = $event)" } + + when (event.actionMasked) { + + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + if (event.pointerCount >= 2 && !touchController.isInZoom) { + view.sendCancelEvent(event) + return touchController.startZoomTouch(view, event) + } + view.onTouchEvent(event) + return true + } + + MotionEvent.ACTION_MOVE -> { + if (touchController.isInZoom) { + touchController.updateZoomTouch(event) + return true + } + return view.onTouchEvent(event) + } + + MotionEvent.ACTION_POINTER_UP -> { + if (event.pointerCount <= 2 && touchController.isInZoom) { + touchController.finishZoomTouch(view, event) + return true + } + return view.onTouchEvent(event) + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (touchController.isInZoom) { + touchController.finishZoomTouch(view, event) + return true + } + return view.onTouchEvent(event) + } + } + + return false + } + + private fun View.sendCancelEvent(event: MotionEvent) { + val cancelEvent = MotionEvent.obtain(event) + cancelEvent.action = MotionEvent.ACTION_CANCEL + onTouchEvent(cancelEvent) + cancelEvent.recycle() + } + + companion object { + private const val TAG = "ZoomTouchListener" + } +} diff --git a/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomViewController.kt b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomViewController.kt new file mode 100644 index 000000000..b688d6f2c --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/main/java/com/yandex/div/zoom/ZoomViewController.kt @@ -0,0 +1,126 @@ +package com.yandex.div.zoom + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.PointF +import android.view.ViewGroup.LayoutParams +import android.view.animation.Interpolator +import android.widget.ImageView +import androidx.appcompat.widget.AppCompatImageView +import androidx.core.animation.doOnEnd +import com.yandex.div.core.util.KAssert +import com.yandex.div.util.ArgbEvaluatorCompat + +internal class ZoomViewController( + private val configuration: DivPinchToZoomConfiguration +) { + + var state = ZoomState.IDLE + + private val context: Context + get() = configuration.context + + private val imageHost: ImageHost + get() = configuration.imageHost + + private val animationInterpolator: Interpolator + get() = configuration.animationInterpolator + + private val dimColor: Int + get() = configuration.dimColor + + private val zoomView: ImageView by lazy { + AppCompatImageView(context).apply { + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + scaleType = ImageView.ScaleType.MATRIX + } + } + + private val argbEvaluator = ArgbEvaluatorCompat() + private val zoomModel = ZoomModel() + + private var dimFactor: Float = 0.0f + set(value) { + field = value.coerceIn(0.0f, 1.0f) + zoomView.setBackgroundColor(argbEvaluator.evaluate(field, Color.TRANSPARENT, dimColor)) + } + + fun showImage(location: PointF, pivotPoint: PointF, imageBitmap: Bitmap) { + if (state != ZoomState.IDLE) { + KAssert.fail { "Unable to show image in $state state" } + return + } + + zoomModel.prepare(location, pivotPoint) + zoomView.setImageBitmap(imageBitmap) + zoomView.imageMatrix = zoomModel.transformMatrix() + imageHost.addImage(zoomView) + dimFactor = 0.0f + + state = ZoomState.ACTIVE + } + + fun translateImageBy(dx: Float, dy: Float) { + if (state != ZoomState.ACTIVE) { + KAssert.fail { "Unable to translate image in $state state" } + return + } + + zoomModel.translateBy(dx, dy) + zoomView.imageMatrix = zoomModel.transformMatrix() + } + + fun scaleImageBy(scaleFactor: Float) { + if (state != ZoomState.ACTIVE) { + KAssert.fail { "Unable to scale image in $state state" } + return + } + + zoomModel.scaleBy(scaleFactor) + zoomView.imageMatrix = zoomModel.transformMatrix() + dimFactor = (zoomModel.scale() - MIN_DIM_SCALE) / (MAX_DIM_SCALE - MIN_DIM_SCALE) + } + + fun hideImage(animated: Boolean = true, callback: () -> Unit) { + if (state != ZoomState.ACTIVE) { + KAssert.fail { "Unable to hide image in $state state" } + return + } + + if (animated) { + state = ZoomState.EXITING + ValueAnimator.ofFloat(1.0f, 0.0f).apply { + duration = ANIMATION_DURATION + interpolator = animationInterpolator + addUpdateListener { + val progress = animatedValue as Float + zoomView.imageMatrix = zoomModel.transformMatrix(progress) + dimFactor = (zoomModel.scale(progress) - MIN_DIM_SCALE) / (MAX_DIM_SCALE - MIN_DIM_SCALE) + } + doOnEnd { + exit() + callback() + } + }.start() + } else { + exit() + callback() + } + } + + private fun exit() { + imageHost.removeImage(zoomView) + zoomView.setImageDrawable(null) + zoomView.setBackgroundColor(Color.TRANSPARENT) + zoomView.imageMatrix = null + state = ZoomState.IDLE + } + + private companion object { + private const val MIN_DIM_SCALE = 1.0f + private const val MAX_DIM_SCALE = 2.0f + private const val ANIMATION_DURATION = 200L + } +} diff --git a/client/android/div-pinch-to-zoom/src/test/java/com/yandex/div/zoom/ZoomModelTest.kt b/client/android/div-pinch-to-zoom/src/test/java/com/yandex/div/zoom/ZoomModelTest.kt new file mode 100644 index 000000000..aaa5c746e --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/test/java/com/yandex/div/zoom/ZoomModelTest.kt @@ -0,0 +1,232 @@ +package com.yandex.div.zoom + +import android.graphics.Matrix +import android.graphics.PointF +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ZoomModelTest { + + private val model = ZoomModel() + + @Test + fun `translate does not applied when there is no scale`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(0.0f, 0.0f) + ) + + model.translateBy(10.0f, 10.0f) + + val matrix = model.transformMatrix().toArray() + assertEquals(0.0f, matrix[Matrix.MTRANS_X]) + assertEquals(0.0f, matrix[Matrix.MTRANS_Y]) + } + + @Test + fun `translate affected by low scale`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(0.0f, 0.0f) + ) + + model.scaleBy(1.5f) + model.translateBy(10.0f, 10.0f) + + val matrix = model.transformMatrix().toArray() + assertEquals(5.0f, matrix[Matrix.MTRANS_X]) + assertEquals(5.0f, matrix[Matrix.MTRANS_Y]) + } + + @Test + fun `translate does not applied at 0 progress`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(0.0f, 0.0f) + ) + + model.scaleBy(2.0f) + model.translateBy(10.0f, 10.0f) + + val matrix = model.transformMatrix(progress = 0.0f).toArray() + assertEquals(0.0f, matrix[Matrix.MTRANS_X]) + assertEquals(0.0f, matrix[Matrix.MTRANS_Y]) + } + + @Test + fun `translate affected by low scale and partial progress`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(0.0f, 0.0f) + ) + + model.scaleBy(2.0f) + model.translateBy(10.0f, 10.0f) + + val matrix = model.transformMatrix(progress = 0.5f).toArray() + assertEquals(2.5f, matrix[Matrix.MTRANS_X]) + assertEquals(2.5f, matrix[Matrix.MTRANS_Y]) + } + + @Test + fun `translate affected by partial progress only at large scale`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(0.0f, 0.0f) + ) + + model.scaleBy(4.0f) + model.translateBy(10.0f, 10.0f) + + val matrix = model.transformMatrix(progress = 0.5f).toArray() + assertEquals(5.0f, matrix[Matrix.MTRANS_X]) + assertEquals(5.0f, matrix[Matrix.MTRANS_Y]) + } + + @Test + fun `translate applied at scale 2x`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(0.0f, 0.0f) + ) + + model.scaleBy(2.0f) + model.translateBy(10.0f, 10.0f) + + val matrix = model.transformMatrix().toArray() + assertEquals(10.0f, matrix[Matrix.MTRANS_X]) + assertEquals(10.0f, matrix[Matrix.MTRANS_Y]) + } + + @Test + fun `translate applied consistently`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(0.0f, 0.0f) + ) + + model.scaleBy(2.0f) + model.translateBy(10.0f, 10.0f) + model.translateBy(10.0f, 10.0f) + + val matrix = model.transformMatrix().toArray() + assertEquals(20.0f, matrix[Matrix.MTRANS_X]) + assertEquals(20.0f, matrix[Matrix.MTRANS_Y]) + } + + @Test + fun `scale does not applied at 0 progress`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f) + ) + + model.scaleBy(2.0f) + + val matrix = model.transformMatrix(progress = 0.0f).toArray() + assertEquals(1.0f, matrix[Matrix.MSCALE_X]) + assertEquals(1.0f, matrix[Matrix.MSCALE_Y]) + } + + @Test + fun `scale affected by partial progress`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f) + ) + + model.scaleBy(2.0f) + + val matrix = model.transformMatrix(progress = 0.5f).toArray() + assertEquals(1.5f, matrix[Matrix.MSCALE_X]) + assertEquals(1.5f, matrix[Matrix.MSCALE_Y]) + } + + @Test + fun `scale applied`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f) + ) + + model.scaleBy(2.0f) + + val matrix = model.transformMatrix().toArray() + assertEquals(2.0f, matrix[Matrix.MSCALE_X]) + assertEquals(2.0f, matrix[Matrix.MSCALE_Y]) + } + + @Test + fun `scale clamped to min 1`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f) + ) + + model.scaleBy(0.5f) + + val matrix = model.transformMatrix().toArray() + assertEquals(1.0f, matrix[Matrix.MSCALE_X]) + assertEquals(1.0f, matrix[Matrix.MSCALE_Y]) + } + + @Test + fun `scale clamped to min 4`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f) + ) + + model.scaleBy(6.0f) + + val matrix = model.transformMatrix().toArray() + assertEquals(4.0f, matrix[Matrix.MSCALE_X]) + assertEquals(4.0f, matrix[Matrix.MSCALE_Y]) + } + + @Test + fun `scale applied consistently`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f) + ) + + model.scaleBy(2.0f) + model.scaleBy(1.5f) + + val matrix = model.transformMatrix().toArray() + assertEquals(3.0f, matrix[Matrix.MSCALE_X]) + assertEquals(3.0f, matrix[Matrix.MSCALE_Y]) + } + + @Test + fun `prepare() clears translate and scale`() { + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(0.0f, 0.0f) + ) + + model.scale(2.0f) + model.translateBy(10.0f, 10.0f) + + model.prepare( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(0.0f, 0.0f) + ) + + val matrix = model.transformMatrix().toArray() + assertEquals(0.0f, matrix[Matrix.MTRANS_X]) + assertEquals(0.0f, matrix[Matrix.MTRANS_Y]) + assertEquals(1.0f, matrix[Matrix.MSCALE_X]) + assertEquals(1.0f, matrix[Matrix.MSCALE_Y]) + } + + private fun Matrix.toArray(): FloatArray { + return FloatArray(9).apply { + getValues(this) + } + } +} diff --git a/client/android/div-pinch-to-zoom/src/test/java/com/yandex/div/zoom/ZoomViewControllerTest.kt b/client/android/div-pinch-to-zoom/src/test/java/com/yandex/div/zoom/ZoomViewControllerTest.kt new file mode 100644 index 000000000..e220e7246 --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/test/java/com/yandex/div/zoom/ZoomViewControllerTest.kt @@ -0,0 +1,133 @@ +package com.yandex.div.zoom + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.PointF +import android.graphics.drawable.ColorDrawable +import android.view.View +import android.view.Window +import android.widget.FrameLayout +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ZoomViewControllerTest { + + private val activity = Robolectric.buildActivity(Activity::class.java).get() + + private val decorView = FrameLayout(activity) + + private val zoomView: View? + get() = decorView.getChildAt(0) + + private val window = mock { + on { decorView } doReturn decorView + } + + private val configuration = DivPinchToZoomConfiguration.Builder(activity) + .host(window) + .dimColor(DIM_COLOR) + .build() + + private val viewController = ZoomViewController(configuration) + + @Test + fun `controller is in active state after showImage called`() { + viewController.showImage( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f), + imageBitmap = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888) + ) + + assertEquals(ZoomState.ACTIVE, viewController.state) + } + + @Test + fun `controller is in exiting state after hideImage with animation called`() { + viewController.showImage( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f), + imageBitmap = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888) + ) + + viewController.hideImage(animated = true) { } + + assertEquals(ZoomState.EXITING, viewController.state) + } + + @Test + fun `controller is in exiting idle after hideImage without animation called`() { + viewController.showImage( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f), + imageBitmap = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888) + ) + + viewController.hideImage(animated = false) { } + + assertEquals(ZoomState.IDLE, viewController.state) + } + + @Test + fun `zoom view removed from decor view`() { + viewController.showImage( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f), + imageBitmap = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888) + ) + + viewController.hideImage(animated = false) { } + + assertEquals(0, decorView.childCount) + } + + @Test + fun `dim color is transparent when there is no scale`() { + viewController.showImage( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f), + imageBitmap = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888) + ) + + val backgroundColor = (zoomView?.background as? ColorDrawable)?.color + assertEquals(Color.TRANSPARENT, backgroundColor) + } + + @Test + fun `dim color is blended at 1,5x scale factor`() { + viewController.showImage( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f), + imageBitmap = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888) + ) + + viewController.scaleImageBy(1.5f) + + val backgroundColor = (zoomView?.background as? ColorDrawable)?.color + assertEquals(0x80000000.toInt(), backgroundColor) + } + + @Test + fun `dim color is fully applied at 2x scale factor`() { + viewController.showImage( + location = PointF(0.0f, 0.0f), + pivotPoint = PointF(20.0f, 20.0f), + imageBitmap = Bitmap.createBitmap(40, 40, Bitmap.Config.ARGB_8888) + ) + + viewController.scaleImageBy(2.0f) + + val backgroundColor = (zoomView?.background as? ColorDrawable)?.color + assertEquals(DIM_COLOR, backgroundColor) + } + + private companion object { + private const val DIM_COLOR = 0xFF000000.toInt() + } +} diff --git a/client/android/div-pinch-to-zoom/src/test/resources/robolectric.properties b/client/android/div-pinch-to-zoom/src/test/resources/robolectric.properties new file mode 100644 index 000000000..932b01b9e --- /dev/null +++ b/client/android/div-pinch-to-zoom/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/client/android/div-schema b/client/android/div-schema new file mode 100644 index 000000000..e21e9e087 --- /dev/null +++ b/client/android/div-schema @@ -0,0 +1 @@ +9b7c782361635d7fd3103d82c47076e62599c818 \ No newline at end of file diff --git a/client/android/div-states/YAOWNERS b/client/android/div-states/YAOWNERS new file mode 100644 index 000000000..9fe90bf7b --- /dev/null +++ b/client/android/div-states/YAOWNERS @@ -0,0 +1,6 @@ +yboyarinov@yandex-team.ru +tayrinn@yandex-team.ru +gulevsky@yandex-team.ru + +set noparent +set lock diff --git a/client/android/div-states/build.gradle b/client/android/div-states/build.gradle new file mode 100644 index 000000000..783f51ce3 --- /dev/null +++ b/client/android/div-states/build.gradle @@ -0,0 +1,22 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +android { + defaultConfig { + javaCompileOptions { + annotationProcessorOptions { + arguments = ["room.schemaLocation": "$projectDir/room/schemas".toString()] + } + } + } +} + +dependencies { + implementation project(path: ':assertion') + implementation project(path: ':div-core') + implementation project(path: ':utils') + + implementation "androidx.appcompat:appcompat:$versions.androidx.appcompat" + implementation "androidx.core:core-ktx:$versions.androidx.core_ktx" +} diff --git a/client/android/div-states/jacoco.excludes b/client/android/div-states/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-states/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-states/proguard-rules.pro b/client/android/div-states/proguard-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/client/android/div-states/src/main/AndroidManifest.xml b/client/android/div-states/src/main/AndroidManifest.xml new file mode 100644 index 000000000..617cc356b --- /dev/null +++ b/client/android/div-states/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/DivStateCache.java b/client/android/div-states/src/main/java/com/yandex/div/state/DivStateCache.java new file mode 100644 index 000000000..84d135482 --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/DivStateCache.java @@ -0,0 +1,26 @@ +package com.yandex.div.state; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.yandex.div.core.annotations.PublicApi; + +/** + * Storage of activated states that are keyed by pair of cardId and activation path. + */ +@PublicApi +public interface DivStateCache { + @AnyThread + void putState(@NonNull String cardId, @NonNull String path, @NonNull String state); + + @AnyThread + void putRootState(@NonNull String cardId, @NonNull String state); + + @AnyThread + @Nullable + String getState(@NonNull String cardId, @NonNull String path); + + @AnyThread + @Nullable + String getRootState(@NonNull String cardId); +} diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/DivStateCacheImpl.kt b/client/android/div-states/src/main/java/com/yandex/div/state/DivStateCacheImpl.kt new file mode 100644 index 000000000..651a8b1e6 --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/DivStateCacheImpl.kt @@ -0,0 +1,130 @@ +package com.yandex.div.state + +import androidx.annotation.AnyThread +import com.yandex.div.core.util.Assert +import com.yandex.div.state.db.DivStateEntity +import com.yandex.div.util.CompletedFuture +import com.yandex.div.util.SingleThreadExecutor +import java.util.Collections +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Future + +internal class DivStateCacheImpl( + private val divStateDatabase: DivStateDatabase, + executorService: ExecutorService, +) : DivStateCache { + + private val singleThreadExecutor = WorkerThreadExecutor(executorService) + + private val cache = Collections.synchronizedMap(mutableMapOf>>()) + private val rootState = Collections.synchronizedMap(mutableMapOf>()) + + @AnyThread + fun putState(cardId: String, future: Future>) { + synchronized(cache) { + if (!cache.containsKey(cardId)) { + cache[cardId] = future + } + } + } + + @AnyThread + fun putRootState(cardId: String, future: Future) { + synchronized(rootState) { + if (!rootState.containsKey(cardId)) { + rootState[cardId] = future + } + } + } + + @AnyThread + override fun putState(cardId: String, path: String, stateId: String) { + synchronized(cache) { + cache.getOrPut(cardId) { + CompletedFuture(mutableMapOf()) + }.get()[path] = stateId + + singleThreadExecutor.post { + divStateDatabase.divStateDao.updateState( + DivStateEntity( + id = 0, + cardId = cardId, + path = path, + stateId = stateId, + modificationTime = System.currentTimeMillis() + ) + ) + } + } + } + + @AnyThread + override fun putRootState(cardId: String, stateId: String) { + synchronized(rootState) { + rootState[cardId] = CompletedFuture(stateId) + singleThreadExecutor.post { + divStateDatabase.divStateDao.deleteCardRootState(cardId) + divStateDatabase.divStateDao.updateState( + DivStateEntity( + id = 0, + cardId = cardId, + path = "/", + stateId = stateId, + modificationTime = System.currentTimeMillis() + ) + ) + } + } + } + + @AnyThread + override fun getState(cardId: String, path: String): String? { + try { + val future = cache[cardId] + if (future?.isDone == true) { + return future.get()?.get(path) + } + } catch (e: ExecutionException) { + Assert.fail("", e) + } + + return null + } + + @AnyThread + override fun getRootState(cardId: String): String? { + try { + synchronized(rootState) { + val future = rootState[cardId] + if (future?.isDone == true) { + val result = future.get() + if (result == null) { + rootState[cardId] = null + } + return result + } + } + } catch (e: ExecutionException) { + Assert.fail("", e) + } + + return null + } + + @AnyThread + fun clear() { + cache.clear() + rootState.clear() + } + + private class WorkerThreadExecutor( + executor: Executor + ) : SingleThreadExecutor(executor, "DivStateCache") { + + override fun handleError(e: RuntimeException) { + Assert.fail("", e) + } + } +} diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/DivStateDatabase.kt b/client/android/div-states/src/main/java/com/yandex/div/state/DivStateDatabase.kt new file mode 100644 index 000000000..9f78d7016 --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/DivStateDatabase.kt @@ -0,0 +1,67 @@ +package com.yandex.div.state + +import android.content.Context +import androidx.annotation.AnyThread +import androidx.annotation.WorkerThread +import androidx.collection.ArrayMap +import com.yandex.div.core.annotations.Mockable +import com.yandex.div.core.annotations.PublicApi +import com.yandex.div.state.db.DivStateDao +import com.yandex.div.state.db.DivStateDaoImpl +import com.yandex.div.state.db.DivStateDbOpenHelper +import java.util.concurrent.Callable +import java.util.concurrent.ExecutorService +import java.util.concurrent.TimeUnit + +/** + * Persistent storage of div states. + */ +@PublicApi +@Mockable +class DivStateDatabase( + context: Context, + databaseName: String, + private val executorService: ExecutorService, +) : DivStateStorage { + + private val cacheImpl = DivStateCacheImpl(this, executorService) + internal val divStateDao: DivStateDao by lazy { + val dbOpenHelper = DivStateDbOpenHelper(context, databaseName) + DivStateDaoImpl(dbOpenHelper.writableDatabase).apply { + deleteModifiedBefore(System.currentTimeMillis() - STATE_MAX_AGE) + } + } + override val cache: DivStateCache + get() = cacheImpl + + @AnyThread + override fun preloadState(cardId: String) { + val future = executorService.submit(Callable> { + val result = ArrayMap() + divStateDao.getStates(cardId).forEach { + result[it.path] = it.stateId + } + result + }) + val rootStateFuture = executorService.submit(Callable { + divStateDao.getRootStateId(cardId) + }) + cacheImpl.putRootState(cardId, rootStateFuture) + cacheImpl.putState(cardId, future) + } + + @WorkerThread + override fun deleteAllStates() { + divStateDao.deleteAll() + cacheImpl.clear() + } + + @WorkerThread + override fun deleteStatesExceptGiven(cardIds: List) { + divStateDao.deleteAllExcept(cardIds) + } + + companion object { + private val STATE_MAX_AGE = TimeUnit.DAYS.toMillis(2) + } +} diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/DivStateStorage.kt b/client/android/div-states/src/main/java/com/yandex/div/state/DivStateStorage.kt new file mode 100644 index 000000000..a1d19da35 --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/DivStateStorage.kt @@ -0,0 +1,23 @@ +package com.yandex.div.state + +import androidx.annotation.AnyThread +import androidx.annotation.WorkerThread +import com.yandex.div.core.annotations.PublicApi + +/** + * Storage of div states + */ +@PublicApi +interface DivStateStorage { + + val cache: DivStateCache + + @AnyThread + fun preloadState(cardId: String) + + @WorkerThread + fun deleteAllStates() + + @WorkerThread + fun deleteStatesExceptGiven(cardIds: List) +} diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/InMemoryDivStateCache.kt b/client/android/div-states/src/main/java/com/yandex/div/state/InMemoryDivStateCache.kt new file mode 100644 index 000000000..b0918d54a --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/InMemoryDivStateCache.kt @@ -0,0 +1,27 @@ +package com.yandex.div.state + +import androidx.annotation.AnyThread +import java.util.Collections + +@AnyThread +class InMemoryDivStateCache : DivStateCache { + private val states = Collections.synchronizedMap(mutableMapOf, String>()) + private val rootStates = Collections.synchronizedMap(mutableMapOf()) + + override fun putState(cardId: String, path: String, state: String) { + states[cardId to path] = state + } + + override fun putRootState(cardId: String, state: String) { + rootStates[cardId] = state + } + + override fun getState(cardId: String, path: String) = states[cardId to path] + + override fun getRootState(cardId: String) = rootStates[cardId] + + internal fun clear() { + states.clear() + rootStates.clear() + } +} diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/InMemoryDivStateStorage.kt b/client/android/div-states/src/main/java/com/yandex/div/state/InMemoryDivStateStorage.kt new file mode 100644 index 000000000..9535f8d8a --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/InMemoryDivStateStorage.kt @@ -0,0 +1,23 @@ +package com.yandex.div.state + +import androidx.annotation.AnyThread +import androidx.annotation.WorkerThread + +/** + * In memory storage of div states. + */ +class InMemoryDivStateStorage : DivStateStorage { + + override val cache = InMemoryDivStateCache() + + @AnyThread + override fun preloadState(cardId: String) = Unit + + @WorkerThread + override fun deleteAllStates() { + cache.clear() + } + + @WorkerThread + override fun deleteStatesExceptGiven(cardIds: List) = Unit +} diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/db/DB.kt b/client/android/div-states/src/main/java/com/yandex/div/state/db/DB.kt new file mode 100644 index 000000000..7d41f03d2 --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/db/DB.kt @@ -0,0 +1,57 @@ +package com.yandex.div.state.db + +import android.provider.BaseColumns +import com.yandex.div.state.db.StateEntry.COLUMN_CARD_ID +import com.yandex.div.state.db.StateEntry.COLUMN_ID +import com.yandex.div.state.db.StateEntry.COLUMN_MOD_TIME +import com.yandex.div.state.db.StateEntry.COLUMN_PATH +import com.yandex.div.state.db.StateEntry.COLUMN_STATE_ID +import com.yandex.div.state.db.StateEntry.INDICES_NAME +import com.yandex.div.state.db.StateEntry.TABLE_NAME + +internal object StateEntry : BaseColumns { + const val TABLE_NAME = "div_card_states" + + const val COLUMN_ID = "id" + const val COLUMN_CARD_ID = "card_id" + const val COLUMN_PATH = "path" + const val COLUMN_STATE_ID = "state_id" + const val COLUMN_MOD_TIME = "modification_time" + + const val INDICES_NAME = "index_${TABLE_NAME}_${COLUMN_CARD_ID}_${COLUMN_PATH}" + + const val INIT_DB_VERSION = 1 +} + +internal object StateSchema { + const val SQL_CREATE_TABLE_QUERY = + "CREATE TABLE IF NOT EXISTS `$TABLE_NAME` (`$COLUMN_ID` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `$COLUMN_CARD_ID` TEXT NOT NULL, `$COLUMN_PATH` TEXT NOT NULL, `$COLUMN_STATE_ID` TEXT NOT NULL, `$COLUMN_MOD_TIME` INTEGER NOT NULL)" + const val SQL_CREATE_INDICES_TABLE_QUERY = + "CREATE UNIQUE INDEX IF NOT EXISTS `$INDICES_NAME` ON `$TABLE_NAME` (`$COLUMN_CARD_ID`, `$COLUMN_PATH`)" + + const val SQL_DROP_TABLE_QUERY = "DROP TABLE IF EXISTS $TABLE_NAME" + + const val SQL_UPSERT_QUERY_TEMPLATE = + "INSERT OR REPLACE INTO `$TABLE_NAME` (`$COLUMN_CARD_ID`,`$COLUMN_PATH`,`$COLUMN_STATE_ID`,`$COLUMN_MOD_TIME`) VALUES (?,?,?,?)" + + const val SQL_GET_STATES_QUERY_TEMPLATE = + "SELECT $COLUMN_PATH, $COLUMN_STATE_ID " + + "FROM $TABLE_NAME WHERE $COLUMN_CARD_ID=?" + + const val SQL_DELETE_BY_CARD_ID_QUERY_TEMPLATE = + "DELETE FROM $TABLE_NAME WHERE $COLUMN_CARD_ID=?" + + const val SQL_DELETE_ALL_EXCEPT_CARD_ID_QUERY_TEMPLATE = + "DELETE FROM $TABLE_NAME WHERE $COLUMN_CARD_ID NOT IN (%s)" + + const val SQL_DELETE_ALL_QUERY = "DELETE FROM $TABLE_NAME" + + const val SQL_DELETE_ALL_MODIFIED_BEFORE_QUERY_TEMPLATE = + "DELETE FROM $TABLE_NAME WHERE $COLUMN_MOD_TIME < ?" + + const val SQL_GET_ROOT_STATE_ID_QUERY_TEMPLATE = + "SELECT $COLUMN_STATE_ID FROM $TABLE_NAME WHERE $COLUMN_CARD_ID=? AND $COLUMN_PATH='/'" + + const val SQL_DELETE_CARD_ROOT_STATE_QUERY_TEMPLATE = + "DELETE FROM $TABLE_NAME WHERE $COLUMN_CARD_ID=? AND $COLUMN_PATH='/'" +} diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateDao.kt b/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateDao.kt new file mode 100644 index 000000000..939232f82 --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateDao.kt @@ -0,0 +1,29 @@ +package com.yandex.div.state.db + +import androidx.annotation.WorkerThread + +internal interface DivStateDao { + @WorkerThread + fun getStates(cardId: String): List + + @WorkerThread + fun updateState(state: DivStateEntity) + + @WorkerThread + fun deleteAll() + + @WorkerThread + fun deleteByCardId(cardId: String) + + @WorkerThread + fun deleteAllExcept(cardIds: List) + + @WorkerThread + fun deleteModifiedBefore(timestamp: Long) + + @WorkerThread + fun getRootStateId(cardId: String): String? + + @WorkerThread + fun deleteCardRootState(cardId: String) +} diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateDaoImpl.kt b/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateDaoImpl.kt new file mode 100644 index 000000000..bfbfd3a0f --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateDaoImpl.kt @@ -0,0 +1,127 @@ +package com.yandex.div.state.db + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteStatement +import androidx.core.database.getStringOrNull +import com.yandex.div.core.util.Assert +import com.yandex.div.state.db.StateSchema.SQL_UPSERT_QUERY_TEMPLATE + +internal class DivStateDaoImpl( + private val writableDatabase: SQLiteDatabase +) : DivStateDao { + + private val upsertStatement: SQLiteStatement + + init { + if (writableDatabase.isReadOnly) { + Assert.fail("${this.javaClass.name} require writable database!") + } + upsertStatement = writableDatabase.compileStatement(SQL_UPSERT_QUERY_TEMPLATE) + } + + override fun getStates(cardId: String): List { + val states = mutableListOf() + writableDatabase.inTransaction { + val cursor = writableDatabase.rawQuery( + StateSchema.SQL_GET_STATES_QUERY_TEMPLATE, + arrayOf(cardId) + ) + cursor.use { + while (cursor.moveToNext()) { + states.add(PathToState(cursor.getPath(), cursor.getStateId())) + } + } + } + + return states + } + + override fun updateState(state: DivStateEntity) { + writableDatabase.inTransaction { + upsertStatement.bindString(1, state.cardId) + upsertStatement.bindString(2, state.path) + upsertStatement.bindString(3, state.stateId) + upsertStatement.bindString(4, state.modificationTime.toString()) + upsertStatement.execute() + upsertStatement.clearBindings() + } + } + + override fun deleteAll() { + writableDatabase.inTransaction { + writableDatabase.rawQuery(StateSchema.SQL_DELETE_ALL_QUERY, emptyArray()).close() + } + } + + override fun deleteByCardId(cardId: String) { + writableDatabase.inTransaction { + writableDatabase.rawQuery( + StateSchema.SQL_DELETE_BY_CARD_ID_QUERY_TEMPLATE, + arrayOf(cardId) + ).applyAndClose() + } + } + + override fun deleteAllExcept(cardIds: List) { + writableDatabase.inTransaction { + val sqlInClauseTemplate = + Array(cardIds.size) { "?" }.joinToString(",") + writableDatabase.rawQuery( + StateSchema.SQL_DELETE_ALL_EXCEPT_CARD_ID_QUERY_TEMPLATE.format(sqlInClauseTemplate), + cardIds.toTypedArray()).applyAndClose() + } + } + + override fun deleteModifiedBefore(timestamp: Long) { + writableDatabase.inTransaction { + writableDatabase.rawQuery( + StateSchema.SQL_DELETE_ALL_MODIFIED_BEFORE_QUERY_TEMPLATE, + arrayOf(timestamp.toString()) + ).applyAndClose() + } + } + + override fun deleteCardRootState(cardId: String) { + writableDatabase.inTransaction { + writableDatabase.rawQuery( + StateSchema.SQL_DELETE_CARD_ROOT_STATE_QUERY_TEMPLATE, + arrayOf(cardId) + ).applyAndClose() + } + } + + override fun getRootStateId(cardId: String): String? { + var stateId: String? = null + writableDatabase.inTransaction { + val cursor = writableDatabase.rawQuery( + StateSchema.SQL_GET_ROOT_STATE_ID_QUERY_TEMPLATE, + arrayOf(cardId) + ) + cursor.use { + if (cursor.moveToNext()) { + stateId = cursor.getStateIdOrNull() + } + } + } + return stateId + } + + private fun Cursor.getPath() = getString(getColumnIndexOrThrow(StateEntry.COLUMN_PATH)) + private fun Cursor.getStateId() = getString(getColumnIndexOrThrow(StateEntry.COLUMN_STATE_ID)) + private fun Cursor.getStateIdOrNull() = getStringOrNull(getColumnIndexOrThrow(StateEntry.COLUMN_STATE_ID)) + private fun Cursor.applyAndClose() { + moveToLast() // Idiotic way to commit transaction? If remove this - nothing gonna be written. + close() + } + + private fun SQLiteDatabase.inTransaction(block: () -> Unit) { + beginTransaction() + try { + block() + setTransactionSuccessful() + } finally { + endTransaction() + } + } +} diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateDbOpenHelper.kt b/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateDbOpenHelper.kt new file mode 100644 index 000000000..db5c17b18 --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateDbOpenHelper.kt @@ -0,0 +1,17 @@ +package com.yandex.div.state.db + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +internal class DivStateDbOpenHelper(context: Context, databaseName: String) : SQLiteOpenHelper( + context, databaseName, null, StateEntry.INIT_DB_VERSION +) { + + override fun onCreate(db: SQLiteDatabase) { + db.execSQL(StateSchema.SQL_CREATE_TABLE_QUERY) + db.execSQL(StateSchema.SQL_CREATE_INDICES_TABLE_QUERY) + } + + override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {} +} \ No newline at end of file diff --git a/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateEntity.kt b/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateEntity.kt new file mode 100644 index 000000000..6fd489f21 --- /dev/null +++ b/client/android/div-states/src/main/java/com/yandex/div/state/db/DivStateEntity.kt @@ -0,0 +1,14 @@ +package com.yandex.div.state.db + +internal data class DivStateEntity( + val id: Int, + val cardId: String, + val path: String, + val stateId: String, + val modificationTime: Long +) + +internal data class PathToState( + val path: String, + val stateId: String +) diff --git a/client/android/div-storage/YAOWNERS b/client/android/div-storage/YAOWNERS new file mode 100644 index 000000000..5772486bd --- /dev/null +++ b/client/android/div-storage/YAOWNERS @@ -0,0 +1,7 @@ +gulevsky@yandex-team.ru +tayrinn@yandex-team.ru +jelyscs@yandex-team.ru +syntezzz@yandex-team.ru + +set noparent +set lock diff --git a/client/android/div-storage/build.gradle b/client/android/div-storage/build.gradle new file mode 100644 index 000000000..eac90b566 --- /dev/null +++ b/client/android/div-storage/build.gradle @@ -0,0 +1,13 @@ +apply from: "${project.projectDir}/../div-library.gradle" +apply from: "${project.projectDir}/../div-tests.gradle" +apply from: "${project.projectDir}/../publish-android.gradle" + +dependencies { + implementation project(path: ':div-core') + implementation project(path: ':assertion') + + implementation "androidx.appcompat:appcompat:$versions.androidx.appcompat" + + testImplementation "androidx.test:core:$versions.androidx.test" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines" +} diff --git a/client/android/div-storage/jacoco.excludes b/client/android/div-storage/jacoco.excludes new file mode 100644 index 000000000..231d1f0fb --- /dev/null +++ b/client/android/div-storage/jacoco.excludes @@ -0,0 +1,7 @@ +##################### +# Generated classes # +##################### +**/R.class +**/R$*.class +**/BuildConfig.* +**/Manifest*.* diff --git a/client/android/div-storage/proguard-rules.pro b/client/android/div-storage/proguard-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/client/android/div-storage/src/main/AndroidManifest.xml b/client/android/div-storage/src/main/AndroidManifest.xml new file mode 100644 index 000000000..51ad97e59 --- /dev/null +++ b/client/android/div-storage/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/client/android/div-storage/src/main/java/com/yandex/div/storage/DivDatabaseStorage.kt b/client/android/div-storage/src/main/java/com/yandex/div/storage/DivDatabaseStorage.kt new file mode 100644 index 000000000..0ca4328f6 --- /dev/null +++ b/client/android/div-storage/src/main/java/com/yandex/div/storage/DivDatabaseStorage.kt @@ -0,0 +1,94 @@ +package com.yandex.div.storage + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.annotation.WorkerThread +import com.yandex.div.core.annotations.PublicApi +import com.yandex.div.storage.db.DatabaseOpenHelper +import com.yandex.div.storage.db.TemplateDao +import com.yandex.div.storage.db.TemplateDaoImpl +import com.yandex.div.storage.db.TemplateUsageDao +import com.yandex.div.storage.db.TemplateUsageDaoImpl +import com.yandex.div.storage.entity.Template +import com.yandex.div.storage.entity.TemplateUsage +import java.io.Closeable + +/** + * Persistent div storage backed by the database. + */ +@PublicApi +class DivDatabaseStorage @JvmOverloads constructor( + context: Context, + databaseName: String = "div.db" +) : DivTemplateStorage, Closeable { + + private val database: SQLiteDatabase by lazy { + DatabaseOpenHelper(context, databaseName).writableDatabase + } + private val templateDao: TemplateDao by lazy { + TemplateDaoImpl(database) + } + private val templateUsageDao: TemplateUsageDao by lazy { + TemplateUsageDaoImpl(database) + } + + @WorkerThread + fun readAllTemplates(): Map { + return templateDao.getAllTemplates() + .associate { template -> template.id to template.data } + } + + @WorkerThread + override fun readTemplates(cardId: String): Map { + return templateDao.getTemplates(cardId) + .associate { template -> template.id to template.data } + } + + @WorkerThread + override fun readTemplatesByIds(vararg templateId: String): Map { + return templateDao.getTemplatesByIds(templateId.toList()) + .associate { template -> template.id to template.data } + } + + @WorkerThread + override fun writeTemplates(cardId: String, templates: Map) { + database.inTransaction { + templateUsageDao.deleteTemplateUsages(cardId) + templates.forEach { (templateId, templateData) -> + templateDao.insertTemplate(Template(templateId, templateData)) + templateUsageDao.insertTemplateUsage(TemplateUsage(cardId, templateId)) + } + templateDao.deleteUnusedTemplates() + } + } + + @WorkerThread + override fun deleteTemplates(cardId: String) { + database.inTransaction { + templateUsageDao.deleteTemplateUsages(cardId) + templateDao.deleteUnusedTemplates() + } + } + + @WorkerThread + override fun clear() { + database.inTransaction { + templateDao.deleteAllTemplates() + templateUsageDao.deleteAllTemplateUsages() + } + } + + override fun close() { + database.close() + } + + private inline fun SQLiteDatabase.inTransaction(block: () -> Unit) { + beginTransaction() + try { + block() + setTransactionSuccessful() + } finally { + endTransaction() + } + } +} diff --git a/client/android/div-storage/src/main/java/com/yandex/div/storage/DivTemplateStorage.kt b/client/android/div-storage/src/main/java/com/yandex/div/storage/DivTemplateStorage.kt new file mode 100644 index 000000000..9091ac801 --- /dev/null +++ b/client/android/div-storage/src/main/java/com/yandex/div/storage/DivTemplateStorage.kt @@ -0,0 +1,23 @@ +package com.yandex.div.storage + +import androidx.annotation.WorkerThread +import com.yandex.div.core.annotations.PublicApi + +@PublicApi +interface DivTemplateStorage { + + @WorkerThread + fun readTemplates(cardId: String): Map + + @WorkerThread + fun readTemplatesByIds(vararg templateId: String): Map + + @WorkerThread + fun writeTemplates(cardId: String, templates: Map) + + @WorkerThread + fun deleteTemplates(cardId: String) + + @WorkerThread + fun clear() +} diff --git a/client/android/div-storage/src/main/java/com/yandex/div/storage/db/DB.kt b/client/android/div-storage/src/main/java/com/yandex/div/storage/db/DB.kt new file mode 100644 index 000000000..f63c9acec --- /dev/null +++ b/client/android/div-storage/src/main/java/com/yandex/div/storage/db/DB.kt @@ -0,0 +1,72 @@ +package com.yandex.div.storage.db + +import com.yandex.div.storage.db.TemplateEntity.TEMPLATES_TABLE +import com.yandex.div.storage.db.TemplateEntity.TEMPLATE_DATA +import com.yandex.div.storage.db.TemplateEntity.TEMPLATE_ID +import com.yandex.div.storage.db.TemplateEntity.TEMPLATE_USAGES_CARD_ID +import com.yandex.div.storage.db.TemplateEntity.TEMPLATE_USAGES_TABLE + +internal object TemplateEntity { + const val TEMPLATES_TABLE = "templates" + const val TEMPLATE_ID = "template_id" + const val TEMPLATE_DATA = "template_data" + + const val TEMPLATE_USAGES_TABLE = "template_usages" + const val TEMPLATE_USAGES_CARD_ID = "card_id" +} + +/** + * If you change something here please up db version. + */ +internal object TemplateSchema { + + const val CREATE_TEMPLATES_TABLE_QUERY = + "CREATE TABLE IF NOT EXISTS `$TEMPLATES_TABLE` (`$TEMPLATE_ID` TEXT NOT NULL, `$TEMPLATE_DATA` BLOB NOT NULL, PRIMARY KEY(`$TEMPLATE_ID`))" + const val CREATE_TEMPLATE_USAGES_TABLE_QUERY = + "CREATE TABLE IF NOT EXISTS `$TEMPLATE_USAGES_TABLE` (`$TEMPLATE_USAGES_CARD_ID` TEXT NOT NULL, `$TEMPLATE_ID` TEXT NOT NULL, PRIMARY KEY(`$TEMPLATE_USAGES_CARD_ID`, `$TEMPLATE_ID`))" +} + +/** + * You can change queries in following objects without migration as they do not change schema. + */ +internal object TemplateQueries { + const val INSERT_TEMPLATE_QUERY_TEMPLATE = + "INSERT OR IGNORE INTO `$TEMPLATES_TABLE` (`$TEMPLATE_ID`,`$TEMPLATE_DATA`) VALUES (?,?)" + const val DELETE_UNUSED_TEMPLATES_QUERY_TEMPLATE = + "DELETE FROM $TEMPLATES_TABLE WHERE $TEMPLATE_ID NOT IN (SELECT DISTINCT $TEMPLATE_ID FROM $TEMPLATE_USAGES_TABLE)" + const val DELETE_ALL_TEMPLATES_QUERY = "DELETE FROM $TEMPLATES_TABLE" + const val GET_ALL_TEMPLATES_QUERY = "SELECT * FROM $TEMPLATES_TABLE" + const val GET_TEMPLATES_BY_CARD_ID_QUERY_TEMPLATE = + "SELECT $TEMPLATES_TABLE.$TEMPLATE_ID, $TEMPLATES_TABLE.$TEMPLATE_DATA FROM $TEMPLATES_TABLE INNER JOIN $TEMPLATE_USAGES_TABLE ON $TEMPLATES_TABLE.$TEMPLATE_ID = $TEMPLATE_USAGES_TABLE.$TEMPLATE_ID WHERE $TEMPLATE_USAGES_TABLE.$TEMPLATE_USAGES_CARD_ID = ?" + + /** + * Use this template with [appendPlaceholders] func below! + */ + const val GET_TEMPLATES_BY_IDS_QUERY_TEMPLATE_WITHOUT_PLACEHOLDER = + "SELECT $TEMPLATE_ID, $TEMPLATE_DATA FROM $TEMPLATES_TABLE WHERE $TEMPLATE_ID IN " +} + +internal object TemplateUsageQueries { + + const val INSERT_TEMPLATE_USAGE_QUERY_TEMPLATE = "INSERT OR IGNORE INTO `$TEMPLATE_USAGES_TABLE` (`$TEMPLATE_USAGES_CARD_ID`,`$TEMPLATE_ID`) VALUES (?,?)" + const val DELETE_ALL_TEMPLATE_USAGES_QUERY = "DELETE FROM $TEMPLATE_USAGES_TABLE" + const val DELETE_TEMPLATE_USAGE_BY_CARD_ID_QUERY_TEMPLATE = "DELETE FROM $TEMPLATE_USAGES_TABLE WHERE $TEMPLATE_USAGES_CARD_ID = ?" +} + +/** + * Adds bind variable placeholders (?) to the given string. Each placeholder is separated + * by a comma. + * + * @param count Number of placeholders + */ +internal fun StringBuilder.appendPlaceholders(count: Int): StringBuilder { + append("(") + for (i in 0 until count) { + append("?") + if (i < count - 1) { + append(",") + } + } + append(")") + return this +} diff --git a/client/android/div-storage/src/main/java/com/yandex/div/storage/db/DatabaseOpenHelper.kt b/client/android/div-storage/src/main/java/com/yandex/div/storage/db/DatabaseOpenHelper.kt new file mode 100644 index 000000000..6a86075f3 --- /dev/null +++ b/client/android/div-storage/src/main/java/com/yandex/div/storage/db/DatabaseOpenHelper.kt @@ -0,0 +1,19 @@ +package com.yandex.div.storage.db + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +private const val DB_INIT_VERSION = 1 + +internal class DatabaseOpenHelper( + context: Context, + dbName: String, +) : SQLiteOpenHelper(context, dbName, null, DB_INIT_VERSION) { + override fun onCreate(db: SQLiteDatabase) { + db.execSQL(TemplateSchema.CREATE_TEMPLATES_TABLE_QUERY) + db.execSQL(TemplateSchema.CREATE_TEMPLATE_USAGES_TABLE_QUERY) + } + + override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {} +} diff --git a/client/android/div-storage/src/main/java/com/yandex/div/storage/db/TemplateDao.kt b/client/android/div-storage/src/main/java/com/yandex/div/storage/db/TemplateDao.kt new file mode 100644 index 000000000..8395c499d --- /dev/null +++ b/client/android/div-storage/src/main/java/com/yandex/div/storage/db/TemplateDao.kt @@ -0,0 +1,12 @@ +package com.yandex.div.storage.db + +import com.yandex.div.storage.entity.Template + +internal interface TemplateDao { + fun getAllTemplates(): List